mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
feat(upload): refactor file management with statistics and multi-tab layout
This commit is contained in:
+107
-1
@@ -3379,7 +3379,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Redis 异常或构造 URL 失败",
|
"description": "Redis 异常 or 构造 URL 失败",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
}
|
}
|
||||||
@@ -3830,6 +3830,55 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/upload/stats": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "返回当前用户的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"upload"
|
||||||
|
],
|
||||||
|
"summary": "获取文件统计数据",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "获取成功",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/upload.fileStatsResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "内部错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/upload/{id}": {
|
"/api/v1/upload/{id}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -5893,6 +5942,49 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"upload.distributionItem": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upload.fileStatsResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"categories": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/upload.distributionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_size": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"trend": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/upload.trendItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/upload.distributionItem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"upload.listMyFilesResponse": {
|
"upload.listMyFilesResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5913,6 +6005,20 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"upload.trendItem": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"user.changePasswordRequest": {
|
"user.changePasswordRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+107
-1
@@ -3372,7 +3372,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Redis 异常或构造 URL 失败",
|
"description": "Redis 异常 or 构造 URL 失败",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/util.ResponseAny"
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
}
|
}
|
||||||
@@ -3823,6 +3823,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/upload/stats": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "返回当前用户的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"upload"
|
||||||
|
],
|
||||||
|
"summary": "获取文件统计数据",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "获取成功",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/upload.fileStatsResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "内部错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/upload/{id}": {
|
"/api/v1/upload/{id}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -5886,6 +5935,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"upload.distributionItem": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upload.fileStatsResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"categories": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/upload.distributionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_size": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"trend": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/upload.trendItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/upload.distributionItem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"upload.listMyFilesResponse": {
|
"upload.listMyFilesResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5906,6 +5998,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"upload.trendItem": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"user.changePasswordRequest": {
|
"user.changePasswordRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+66
-1
@@ -899,6 +899,34 @@ definitions:
|
|||||||
required:
|
required:
|
||||||
- ids
|
- ids
|
||||||
type: object
|
type: object
|
||||||
|
upload.distributionItem:
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
upload.fileStatsResponse:
|
||||||
|
properties:
|
||||||
|
categories:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/upload.distributionItem'
|
||||||
|
type: array
|
||||||
|
total_count:
|
||||||
|
type: integer
|
||||||
|
total_size:
|
||||||
|
type: integer
|
||||||
|
trend:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/upload.trendItem'
|
||||||
|
type: array
|
||||||
|
types:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/upload.distributionItem'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
upload.listMyFilesResponse:
|
upload.listMyFilesResponse:
|
||||||
properties:
|
properties:
|
||||||
items:
|
items:
|
||||||
@@ -912,6 +940,15 @@ definitions:
|
|||||||
total:
|
total:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
upload.trendItem:
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
user.changePasswordRequest:
|
user.changePasswordRequest:
|
||||||
properties:
|
properties:
|
||||||
new_password:
|
new_password:
|
||||||
@@ -3129,7 +3166,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/util.ResponseAny'
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
"500":
|
"500":
|
||||||
description: Redis 异常或构造 URL 失败
|
description: Redis 异常 or 构造 URL 失败
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/util.ResponseAny'
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
summary: 获取登录授权地址
|
summary: 获取登录授权地址
|
||||||
@@ -3394,6 +3431,34 @@ paths:
|
|||||||
summary: 获取我的文件列表
|
summary: 获取我的文件列表
|
||||||
tags:
|
tags:
|
||||||
- upload
|
- upload
|
||||||
|
/api/v1/upload/stats:
|
||||||
|
get:
|
||||||
|
description: 返回当前用户的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 获取成功
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/util.ResponseAny'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/upload.fileStatsResponse'
|
||||||
|
type: object
|
||||||
|
"401":
|
||||||
|
description: 未登录
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"500":
|
||||||
|
description: 内部错误
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 获取文件统计数据
|
||||||
|
tags:
|
||||||
|
- upload
|
||||||
/api/v1/user-info:
|
/api/v1/user-info:
|
||||||
get:
|
get:
|
||||||
description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。
|
description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。
|
||||||
|
|||||||
@@ -16,14 +16,32 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
|
HardDrive,
|
||||||
|
Database,
|
||||||
|
TrendingUp,
|
||||||
|
Files,
|
||||||
|
Info,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {toast} from "sonner"
|
import {toast} from "sonner"
|
||||||
|
import {
|
||||||
|
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"
|
||||||
import {Badge} from "@/components/ui/badge"
|
import {Badge} from "@/components/ui/badge"
|
||||||
import {Checkbox} from "@/components/ui/checkbox"
|
import {Checkbox} from "@/components/ui/checkbox"
|
||||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -34,7 +52,9 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
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 {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||||
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"
|
||||||
|
|
||||||
@@ -59,10 +79,20 @@ function formatDate(dateStr: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PIE_COLORS = [
|
||||||
|
"#3b82f6", // Blue (图片)
|
||||||
|
"#10b981", // Emerald (视频)
|
||||||
|
"#8b5cf6", // Purple (音频)
|
||||||
|
"#f59e0b", // Amber (文档)
|
||||||
|
"#ec4899", // Pink (压缩包)
|
||||||
|
"#64748b", // Slate (其他)
|
||||||
|
]
|
||||||
|
|
||||||
/* ─── 文件管理主组件 ────────────────────────────────────── */
|
/* ─── 文件管理主组件 ────────────────────────────────────── */
|
||||||
|
|
||||||
export function FilesMain() {
|
export function FilesMain() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const [activeTab, setActiveTab] = React.useState("stats")
|
||||||
const [keyword, setKeyword] = React.useState("")
|
const [keyword, setKeyword] = React.useState("")
|
||||||
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
|
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
|
||||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set())
|
||||||
@@ -70,6 +100,9 @@ export function FilesMain() {
|
|||||||
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(null)
|
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(null)
|
||||||
const [page, setPage] = React.useState(1)
|
const [page, setPage] = React.useState(1)
|
||||||
const pageSize = 15 // 表格视图下 15 条更紧凑
|
const pageSize = 15 // 表格视图下 15 条更紧凑
|
||||||
|
|
||||||
|
// 趋势图指标类型切换 (数量 vs 大小)
|
||||||
|
const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count")
|
||||||
|
|
||||||
// 搜索防抖
|
// 搜索防抖
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -84,6 +117,14 @@ export function FilesMain() {
|
|||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: ["files", "my", page, pageSize, debouncedKeyword],
|
queryKey: ["files", "my", page, pageSize, debouncedKeyword],
|
||||||
queryFn: () => UploadService.listMyFiles(page, pageSize, debouncedKeyword || undefined),
|
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 files = listQuery.data?.items ?? []
|
||||||
@@ -114,6 +155,7 @@ export function FilesMain() {
|
|||||||
mutationFn: (id: string) => UploadService.deleteFile(id),
|
mutationFn: (id: string) => UploadService.deleteFile(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["files", "my"] })
|
void queryClient.invalidateQueries({ queryKey: ["files", "my"] })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["files", "stats"] })
|
||||||
toast.success("文件已删除")
|
toast.success("文件已删除")
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
},
|
},
|
||||||
@@ -159,6 +201,67 @@ export function FilesMain() {
|
|||||||
a.click()
|
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) => ({ name: c.name, value: c.count }))
|
||||||
|
}, [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 }
|
||||||
|
}, [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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 15 }}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
@@ -166,250 +269,572 @@ export function FilesMain() {
|
|||||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||||
className="py-6 space-y-6 w-full"
|
className="py-6 space-y-6 w-full"
|
||||||
>
|
>
|
||||||
|
{/* 顶部标题区 */}
|
||||||
{/* 头部 */}
|
|
||||||
<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 className="flex items-center gap-4">
|
<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 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>
|
||||||
</h1>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">
|
管理您上传的所有文件,支持下载、数据统计与批量操作
|
||||||
管理您上传的所有文件,支持下载与批量操作
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮区 */}
|
|
||||||
<div className="flex items-center gap-2 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索栏 */}
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 分页 */}
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6">
|
||||||
{totalPages > 1 && (
|
<TabsList className="grid w-fit grid-cols-2">
|
||||||
<div className="flex items-center justify-center gap-2 pt-2">
|
<TabsTrigger value="stats">文件存储信息</TabsTrigger>
|
||||||
<Button
|
<TabsTrigger value="list">文件列表</TabsTrigger>
|
||||||
size="sm"
|
</TabsList>
|
||||||
variant="outline"
|
|
||||||
className="border-dashed text-xs h-7 px-3"
|
{/* ──────── TAB 1: 统计看板 ──────── */}
|
||||||
disabled={page <= 1}
|
<TabsContent value="stats" className="space-y-6 outline-hidden">
|
||||||
onClick={() => setPage((p) => p - 1)}
|
{statsQuery.isPending ? (
|
||||||
>
|
<div className="flex items-center justify-center py-32">
|
||||||
上一页
|
<Loader2 className="size-8 animate-spin text-sky-500" />
|
||||||
</Button>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
) : !stats || stats.total_count === 0 ? (
|
||||||
{page} / {totalPages}
|
<div className="flex flex-col items-center justify-center py-24 border border-dashed rounded-xl bg-card text-muted-foreground gap-4">
|
||||||
</span>
|
<Upload className="size-14 text-muted-foreground/25" />
|
||||||
<Button
|
<div className="text-center space-y-1">
|
||||||
size="sm"
|
<p className="font-medium">暂无上传数据</p>
|
||||||
variant="outline"
|
<p className="text-xs text-muted-foreground">上传文件后即可在此查看容量与文件分布分析。</p>
|
||||||
className="border-dashed text-xs h-7 px-3"
|
</div>
|
||||||
disabled={page >= totalPages}
|
</div>
|
||||||
onClick={() => setPage((p) => p + 1)}
|
) : (
|
||||||
>
|
<div className="space-y-6">
|
||||||
下一页
|
{/* 四个指标卡片 */}
|
||||||
</Button>
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
</div>
|
<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">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<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>
|
||||||
|
</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())}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={trendMetric === "count" ? "#6366f1" : "#10b981"}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorTrend)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</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">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<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={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</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">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<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={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{/* 详情查看 Sheet */}
|
{/* 详情查看 Sheet */}
|
||||||
<Sheet open={!!detailTarget} onOpenChange={(open) => !open && setDetailTarget(null)}>
|
<Sheet open={!!detailTarget} onOpenChange={(open) => !open && setDetailTarget(null)}>
|
||||||
|
|||||||
@@ -59,4 +59,33 @@ export interface ListUploadsResponse {
|
|||||||
page: number
|
page: number
|
||||||
page_size: number
|
page_size: number
|
||||||
items: Upload[]
|
items: Upload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 近 7 天新增文件趋势项
|
||||||
|
*/
|
||||||
|
export interface TrendItem {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件分类/类型统计项
|
||||||
|
*/
|
||||||
|
export interface DistributionItem {
|
||||||
|
name: string
|
||||||
|
count: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件统计信息响应
|
||||||
|
*/
|
||||||
|
export interface FileStatsResponse {
|
||||||
|
total_count: number
|
||||||
|
total_size: number
|
||||||
|
trend: TrendItem[]
|
||||||
|
categories: DistributionItem[]
|
||||||
|
types: DistributionItem[]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {BaseService} from '../core/base.service'
|
import {BaseService} from '../core/base.service'
|
||||||
import type {ListUploadsResponse, Upload, UploadImageResponse} from './types'
|
import type {ListUploadsResponse, Upload, UploadImageResponse, FileStatsResponse} from './types'
|
||||||
import type {InternalAxiosRequestConfig} from 'axios'
|
import type {InternalAxiosRequestConfig} from 'axios'
|
||||||
|
|
||||||
export type ImageQuality = 'low' | 'medium' | 'high' | 'origin'
|
export type ImageQuality = 'low' | 'medium' | 'high' | 'origin'
|
||||||
@@ -86,6 +86,13 @@ export class UploadService extends BaseService {
|
|||||||
return this.get<ListUploadsResponse>('/my', params)
|
return this.get<ListUploadsResponse>('/my', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户文件统计信息
|
||||||
|
*/
|
||||||
|
static async getFileStats(): Promise<FileStatsResponse> {
|
||||||
|
return this.get<FileStatsResponse>('/stats')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除文件
|
* 删除文件
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||||
"github.com/Rain-kl/Wavelet/internal/db"
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
@@ -47,6 +48,7 @@ func setupTestRouter(authUser *model.User) *gin.Engine {
|
|||||||
|
|
||||||
uploadGroup.POST("", UploadFile)
|
uploadGroup.POST("", UploadFile)
|
||||||
uploadGroup.GET("/my", ListMyFiles)
|
uploadGroup.GET("/my", ListMyFiles)
|
||||||
|
uploadGroup.GET("/stats", GetFileStats)
|
||||||
uploadGroup.GET("/download/:id", DownloadFile)
|
uploadGroup.GET("/download/:id", DownloadFile)
|
||||||
uploadGroup.POST("/download/batch", BatchDownloadFiles)
|
uploadGroup.POST("/download/batch", BatchDownloadFiles)
|
||||||
return r
|
return r
|
||||||
@@ -710,3 +712,113 @@ func TestUploadAccessModeAccessControl(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetFileStats(t *testing.T) {
|
||||||
|
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
authUser := &model.User{ID: 1001, Username: "test_user"}
|
||||||
|
router := setupTestRouter(authUser)
|
||||||
|
|
||||||
|
// Insert some dummy uploads
|
||||||
|
uploads := []model.Upload{
|
||||||
|
{
|
||||||
|
ID: 3101,
|
||||||
|
UserID: authUser.ID,
|
||||||
|
FileName: "photo.png",
|
||||||
|
FilePath: "uploads/photo.png",
|
||||||
|
FileSize: 100,
|
||||||
|
MimeType: "image/png",
|
||||||
|
Extension: "png",
|
||||||
|
StorageDriver: "local",
|
||||||
|
Type: "generic",
|
||||||
|
Status: model.UploadStatusUsed,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3102,
|
||||||
|
UserID: authUser.ID,
|
||||||
|
FileName: "video.mp4",
|
||||||
|
FilePath: "uploads/video.mp4",
|
||||||
|
FileSize: 500,
|
||||||
|
MimeType: "video/mp4",
|
||||||
|
Extension: "mp4",
|
||||||
|
StorageDriver: "local",
|
||||||
|
Type: "generic",
|
||||||
|
Status: model.UploadStatusUsed,
|
||||||
|
CreatedAt: time.Now().AddDate(0, 0, -2), // 2 days ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3103,
|
||||||
|
UserID: authUser.ID,
|
||||||
|
FileName: "document.pdf",
|
||||||
|
FilePath: "uploads/document.pdf",
|
||||||
|
FileSize: 200,
|
||||||
|
MimeType: "application/pdf",
|
||||||
|
Extension: "pdf",
|
||||||
|
StorageDriver: "local",
|
||||||
|
Type: "avatar", // different type
|
||||||
|
Status: model.UploadStatusUsed,
|
||||||
|
CreatedAt: time.Now().AddDate(0, 0, -10), // older than 7 days
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range uploads {
|
||||||
|
if err := dbConn.Create(&uploads[i]).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create upload: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/upload/stats", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
ErrorMsg string `json:"error_msg"`
|
||||||
|
Data fileStatsResponse `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ErrorMsg != "" {
|
||||||
|
t.Fatalf("expected no error, got: %s", resp.ErrorMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify total count and size
|
||||||
|
if resp.Data.TotalCount != 3 {
|
||||||
|
t.Errorf("expected 3 total files, got %d", resp.Data.TotalCount)
|
||||||
|
}
|
||||||
|
if resp.Data.TotalSize != 800 {
|
||||||
|
t.Errorf("expected 800 total size, got %d", resp.Data.TotalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify trend (last 7 days should include photo.png (100) and video.mp4 (500), but NOT pdf (older))
|
||||||
|
// Total size in trend should be 600
|
||||||
|
var trendSizeSum int64
|
||||||
|
for _, trendItem := range resp.Data.Trend {
|
||||||
|
trendSizeSum += trendItem.Size
|
||||||
|
}
|
||||||
|
if trendSizeSum != 600 {
|
||||||
|
t.Errorf("expected 7-day trend size sum to be 600, got %d", trendSizeSum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify categories
|
||||||
|
categoryMap := make(map[string]int64)
|
||||||
|
for _, cat := range resp.Data.Categories {
|
||||||
|
categoryMap[cat.Name] = cat.Count
|
||||||
|
}
|
||||||
|
if categoryMap["图片"] != 1 {
|
||||||
|
t.Errorf("expected 1 image category, got %d", categoryMap["图片"])
|
||||||
|
}
|
||||||
|
if categoryMap["视频"] != 1 {
|
||||||
|
t.Errorf("expected 1 video category, got %d", categoryMap["视频"])
|
||||||
|
}
|
||||||
|
if categoryMap["文档"] != 1 {
|
||||||
|
t.Errorf("expected 1 document category, got %d", categoryMap["文档"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/util"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type trendItem struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type distributionItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileStatsResponse struct {
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
TotalSize int64 `json:"total_size"`
|
||||||
|
Trend []trendItem `json:"trend"`
|
||||||
|
Categories []distributionItem `json:"categories"`
|
||||||
|
Types []distributionItem `json:"types"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileStats 获取当前登录用户的文件统计数据
|
||||||
|
// @Summary 获取文件统计数据
|
||||||
|
// @Description 返回当前用户的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据
|
||||||
|
// @Tags upload
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Success 200 {object} util.ResponseAny{data=fileStatsResponse} "获取成功"
|
||||||
|
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||||
|
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||||
|
// @Router /api/v1/upload/stats [get]
|
||||||
|
func GetFileStats(c *gin.Context) {
|
||||||
|
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID := currUser.ID
|
||||||
|
|
||||||
|
// 1. 获取总文件数与总文件大小
|
||||||
|
var summary struct {
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
TotalSize int64 `json:"total_size"`
|
||||||
|
}
|
||||||
|
err := db.DB(ctx).Model(&model.Upload{}).
|
||||||
|
Select("COUNT(*) as total_count, COALESCE(SUM(file_size), 0) as total_size").
|
||||||
|
Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted).
|
||||||
|
Scan(&summary).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取业务类型分布 (Group By type)
|
||||||
|
type rawDist struct {
|
||||||
|
Key string `gorm:"column:key"`
|
||||||
|
Count int64 `gorm:"column:count"`
|
||||||
|
Size int64 `gorm:"column:size"`
|
||||||
|
}
|
||||||
|
var typeRaw []rawDist
|
||||||
|
err = db.DB(ctx).Model(&model.Upload{}).
|
||||||
|
Select("type as key, COUNT(*) as count, COALESCE(SUM(file_size), 0) as size").
|
||||||
|
Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted).
|
||||||
|
Group("type").
|
||||||
|
Scan(&typeRaw).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
types := make([]distributionItem, 0, len(typeRaw))
|
||||||
|
for _, tr := range typeRaw {
|
||||||
|
name := tr.Key
|
||||||
|
if name == "" {
|
||||||
|
name = "generic"
|
||||||
|
}
|
||||||
|
types = append(types, distributionItem{
|
||||||
|
Name: name,
|
||||||
|
Count: tr.Count,
|
||||||
|
Size: tr.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取所有文件的大小、后缀与MIME,用于在 Go 中内存分类统计 (避免数据库中写复杂的 JSON/String 匹配逻辑)
|
||||||
|
type fileCategoryRaw struct {
|
||||||
|
Extension string `gorm:"column:extension"`
|
||||||
|
MimeType string `gorm:"column:mime_type"`
|
||||||
|
FileSize int64 `gorm:"column:file_size"`
|
||||||
|
}
|
||||||
|
var fileRaws []fileCategoryRaw
|
||||||
|
err = db.DB(ctx).Model(&model.Upload{}).
|
||||||
|
Select("extension, mime_type, file_size").
|
||||||
|
Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted).
|
||||||
|
Scan(&fileRaws).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
catCount := make(map[string]int64)
|
||||||
|
catSize := make(map[string]int64)
|
||||||
|
categoriesList := []string{"图片", "视频", "音频", "文档", "压缩包", "其他"}
|
||||||
|
for _, cat := range categoriesList {
|
||||||
|
catCount[cat] = 0
|
||||||
|
catSize[cat] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fr := range fileRaws {
|
||||||
|
cat := getFileCategory(fr.MimeType, fr.Extension)
|
||||||
|
catCount[cat]++
|
||||||
|
catSize[cat] += fr.FileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
categories := make([]distributionItem, 0, len(categoriesList))
|
||||||
|
for _, cat := range categoriesList {
|
||||||
|
categories = append(categories, distributionItem{
|
||||||
|
Name: cat,
|
||||||
|
Count: catCount[cat],
|
||||||
|
Size: catSize[cat],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 获取近 7 天的新增文件趋势 (在 Go 中补全没有新增记录的日期为 0)
|
||||||
|
type fileTrendRaw struct {
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
FileSize int64 `gorm:"column:file_size"`
|
||||||
|
}
|
||||||
|
var trendRaws []fileTrendRaw
|
||||||
|
// 7天前 00:00:00 (即 6 天前 00:00:00 至今天)
|
||||||
|
now := time.Now()
|
||||||
|
startTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -6)
|
||||||
|
err = db.DB(ctx).Model(&model.Upload{}).
|
||||||
|
Select("created_at, file_size").
|
||||||
|
Where("user_id = ? AND status != ? AND created_at >= ?", userID, model.UploadStatusDeleted, startTime).
|
||||||
|
Scan(&trendRaws).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trendCountMap := make(map[string]int64)
|
||||||
|
trendSizeMap := make(map[string]int64)
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
dStr := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||||
|
trendCountMap[dStr] = 0
|
||||||
|
trendSizeMap[dStr] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tr := range trendRaws {
|
||||||
|
dStr := tr.CreatedAt.Format("2006-01-02")
|
||||||
|
if _, exists := trendCountMap[dStr]; exists {
|
||||||
|
trendCountMap[dStr]++
|
||||||
|
trendSizeMap[dStr] += tr.FileSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendDays = 7
|
||||||
|
trend := make([]trendItem, 0, trendDays)
|
||||||
|
for i := trendDays - 1; i >= 0; i-- {
|
||||||
|
dStr := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||||
|
trend = append(trend, trendItem{
|
||||||
|
Date: dStr,
|
||||||
|
Count: trendCountMap[dStr],
|
||||||
|
Size: trendSizeMap[dStr],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, util.OK(fileStatsResponse{
|
||||||
|
TotalCount: summary.TotalCount,
|
||||||
|
TotalSize: summary.TotalSize,
|
||||||
|
Trend: trend,
|
||||||
|
Categories: categories,
|
||||||
|
Types: types,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFileCategory(mimeType, ext string) string {
|
||||||
|
mimeType = strings.ToLower(mimeType)
|
||||||
|
ext = strings.ToLower(ext)
|
||||||
|
|
||||||
|
if strings.HasPrefix(mimeType, "image/") || isImageExtension(ext) {
|
||||||
|
return "图片"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mimeType, "video/") {
|
||||||
|
return "视频"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mimeType, "audio/") {
|
||||||
|
return "音频"
|
||||||
|
}
|
||||||
|
if isArchiveExtension(ext) || strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") || strings.Contains(mimeType, "gzip") {
|
||||||
|
return "压缩包"
|
||||||
|
}
|
||||||
|
if isDocumentExtension(ext) || strings.HasPrefix(mimeType, "text/") || mimeType == "application/pdf" {
|
||||||
|
return "文档"
|
||||||
|
}
|
||||||
|
return "其他"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isArchiveExtension(ext string) bool {
|
||||||
|
for _, e := range []string{"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz"} {
|
||||||
|
if ext == e {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDocumentExtension(ext string) bool {
|
||||||
|
for _, e := range []string{"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv", "json", "yaml", "yml", "xml"} {
|
||||||
|
if ext == e {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -179,8 +179,20 @@ func registerRoutes(r *gin.Engine) {
|
|||||||
}
|
}
|
||||||
return enabled
|
return enabled
|
||||||
}), user.Login)
|
}), user.Login)
|
||||||
userRouter.POST("/register", user.Register)
|
userRouter.POST("/register", capApp.VerifyMiddleware(capUtil.GetDefaultManager(), "register", func() bool {
|
||||||
userRouter.POST("/send-email-code", user.SendEmailCode)
|
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return enabled
|
||||||
|
}), user.Register)
|
||||||
|
userRouter.POST("/send-email-code", capApp.VerifyMiddleware(capUtil.GetDefaultManager(), "send_email_code", func() bool {
|
||||||
|
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return enabled
|
||||||
|
}), user.SendEmailCode)
|
||||||
userRouter.GET("/logout", user.Logout)
|
userRouter.GET("/logout", user.Logout)
|
||||||
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
|
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
|
||||||
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
||||||
@@ -203,6 +215,7 @@ func registerRoutes(r *gin.Engine) {
|
|||||||
{
|
{
|
||||||
uploadRouter.POST("", upload.UploadFile)
|
uploadRouter.POST("", upload.UploadFile)
|
||||||
uploadRouter.GET("/my", upload.ListMyFiles)
|
uploadRouter.GET("/my", upload.ListMyFiles)
|
||||||
|
uploadRouter.GET("/stats", upload.GetFileStats)
|
||||||
uploadRouter.DELETE("/:id", upload.DeleteFile)
|
uploadRouter.DELETE("/:id", upload.DeleteFile)
|
||||||
uploadRouter.GET("/download/:id", upload.DownloadFile)
|
uploadRouter.GET("/download/:id", upload.DownloadFile)
|
||||||
uploadRouter.POST("/download/batch", upload.BatchDownloadFiles)
|
uploadRouter.POST("/download/batch", upload.BatchDownloadFiles)
|
||||||
|
|||||||
Reference in New Issue
Block a user