This commit is contained in:
ryan
2026-07-13 15:17:45 +08:00
parent 08e61ce833
commit e0eb4e5975
334 changed files with 17212 additions and 12321 deletions
@@ -1,8 +1,8 @@
"use client"
'use client';
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {AnimatePresence, motion} from "motion/react"
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'motion/react';
import {
Download,
Eye,
@@ -16,14 +16,21 @@ import {
Trash2,
Upload,
X,
} from "lucide-react"
import {toast} from "sonner"
} from 'lucide-react';
import { toast } from 'sonner';
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
import {Checkbox} from "@/components/ui/checkbox"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
@@ -33,207 +40,236 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
import {FileImagePreview} from "@/components/common/file-image-preview"
import services, {formatFileSize} from "@/lib/services"
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
} from '@/components/ui/alert-dialog';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { FileImagePreview } from '@/components/common/file-image-preview';
import services, { formatFileSize } from '@/lib/services';
import type { Upload as UploadRecord } from '@/lib/services/upload/types';
/* ─── 工具函数 ─────────────────────────────────────────── */
function getFileIcon(mimeType: string, className = "size-8") {
if (mimeType.startsWith("image/")) return <FileImage className={`${className} text-blue-400`} />
if (mimeType.startsWith("video/")) return <FileVideo className={`${className} text-purple-400`} />
if (mimeType.startsWith("audio/")) return <FileAudio className={`${className} text-green-400`} />
if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
return <FileArchive className={`${className} text-amber-400`} />
return <FileText className={`${className} text-slate-400`} />
function getFileIcon(mimeType: string, className = 'size-8') {
if (mimeType.startsWith('image/'))
return <FileImage className={`${className} text-blue-400`} />;
if (mimeType.startsWith('video/'))
return <FileVideo className={`${className} text-purple-400`} />;
if (mimeType.startsWith('audio/'))
return <FileAudio className={`${className} text-green-400`} />;
if (
mimeType.includes('zip') ||
mimeType.includes('tar') ||
mimeType.includes('gzip')
)
return <FileArchive className={`${className} text-amber-400`} />;
return <FileText className={`${className} text-slate-400`} />;
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function FileList() {
const queryClient = useQueryClient()
const [keyword, setKeyword] = React.useState("")
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set())
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(null)
const [page, setPage] = React.useState(1)
const pageSize = 15 // 表格视图下 15 条更紧凑
const queryClient = useQueryClient();
const [keyword, setKeyword] = React.useState('');
const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(
null,
);
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(
null,
);
const [page, setPage] = React.useState(1);
const pageSize = 15; // 表格视图下 15 条更紧凑
// 搜索防抖
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword)
setPage(1)
}, 400)
return () => clearTimeout(timer)
}, [keyword])
setDebouncedKeyword(keyword);
setPage(1);
}, 400);
return () => clearTimeout(timer);
}, [keyword]);
// 文件列表查询
const listQuery = useQuery({
queryKey: ["files", "all", page, pageSize, debouncedKeyword],
queryFn: () => services.adminUpload.listUploads(page, pageSize, debouncedKeyword || undefined),
})
queryKey: ['files', 'all', page, pageSize, debouncedKeyword],
queryFn: () =>
services.adminUpload.listUploads(
page,
pageSize,
debouncedKeyword || undefined,
),
});
const storageDriverQuery = useQuery({
queryKey: ["admin", "storage-config", "driver"],
queryKey: ['admin', 'storage-config', 'driver'],
queryFn: async () => {
const record = await services.adminSystemConfig.getSystemConfig("storage_config")
const cfg = JSON.parse(record.value) as {driver?: string}
return cfg.driver ?? "local"
const record =
await services.adminSystemConfig.getSystemConfig('storage_config');
const cfg = JSON.parse(record.value) as { driver?: string };
return cfg.driver ?? 'local';
},
})
});
const files = listQuery.data?.items ?? []
const total = listQuery.data?.total ?? 0
const totalPages = Math.ceil(total / pageSize)
const files = listQuery.data?.items ?? [];
const total = listQuery.data?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
const isAllSelected = files.length > 0 && files.every((f) => selectedIds.has(f.id))
const isSomeSelected = files.length > 0 && files.some((f) => selectedIds.has(f.id)) && !isAllSelected
const isAllSelected =
files.length > 0 && files.every((f) => selectedIds.has(f.id));
const isSomeSelected =
files.length > 0 &&
files.some((f) => selectedIds.has(f.id)) &&
!isAllSelected;
const handleSelectAll = () => {
if (isAllSelected) {
setSelectedIds((prev) => {
const next = new Set(prev)
files.forEach((f) => next.delete(f.id))
return next
})
const next = new Set(prev);
files.forEach((f) => next.delete(f.id));
return next;
});
} else {
setSelectedIds((prev) => {
const next = new Set(prev)
files.forEach((f) => next.add(f.id))
return next
})
const next = new Set(prev);
files.forEach((f) => next.add(f.id));
return next;
});
}
}
};
// 删除单文件
const deleteMutation = useMutation({
mutationFn: (id: string) => services.adminUpload.deleteFile(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["files", "my"] })
void queryClient.invalidateQueries({ queryKey: ["files", "stats"] })
toast.success("文件已删除")
setDeleteTarget(null)
void queryClient.invalidateQueries({ queryKey: ['files', 'my'] });
void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] });
toast.success('文件已删除');
setDeleteTarget(null);
},
onError: (err: Error) => toast.error(err.message || "删除失败"),
})
onError: (err: Error) => toast.error(err.message || '删除失败'),
});
// 批量 ZIP 下载
const batchDownloadMutation = useMutation({
mutationFn: (ids: string[]) => services.adminUpload.batchDownload(ids),
onSuccess: (blob) => {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "batch_download.zip"
a.click()
URL.revokeObjectURL(url)
toast.success("批量下载已开始")
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'batch_download.zip';
a.click();
URL.revokeObjectURL(url);
toast.success('批量下载已开始');
},
onError: () => toast.error("批量下载失败"),
})
onError: () => toast.error('批量下载失败'),
});
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
const next = new Set(prev);
if (next.has(id)) {
next.delete(id)
next.delete(id);
} else {
next.add(id)
next.add(id);
}
return next
})
}
return next;
});
};
const clearSelection = () => setSelectedIds(new Set())
const clearSelection = () => setSelectedIds(new Set());
const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id)))
const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id)));
const handleDownload = (file: UploadRecord) => {
const url = services.adminUpload.getDownloadUrl(file.id)
const a = document.createElement("a")
a.href = url
a.download = file.file_name
a.click()
}
const url = services.adminUpload.getDownloadUrl(file.id);
const a = document.createElement('a');
a.href = url;
a.download = file.file_name;
a.click();
};
return (
<div className="space-y-6">
<div className='space-y-6'>
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<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"
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("")}
className='absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground'
onClick={() => setKeyword('')}
>
<X className="size-3" />
<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}
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 ? "取消全选" : "全选本页"}
{selectedIds.size === files.length ? '取消全选' : '全选本页'}
</Button>
)}
{/* 打包下载等批量操作 */}
<div className="flex items-center gap-2 ml-auto shrink-0">
<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"
className='flex items-center gap-2'
>
<Badge variant="secondary" className="text-xs px-2.5">
<Badge variant='secondary' className='text-xs px-2.5'>
已选 {selectedIds.size} 个
</Badge>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-8"
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" />
<Loader2 className='size-3.5 mr-1 animate-spin' />
) : (
<FileArchive className="size-3.5 mr-1" />
<FileArchive className='size-3.5 mr-1' />
)}
打包下载
</Button>
<Button
size="sm"
variant="ghost"
className="text-xs h-8 px-2"
size='sm'
variant='ghost'
className='text-xs h-8 px-2'
onClick={clearSelection}
>
<X className="size-3.5" />
<X className='size-3.5' />
</Button>
</motion.div>
)}
@@ -241,131 +277,152 @@ export function FileList() {
</div>
{total > 0 && (
<span className="text-xs text-muted-foreground shrink-0">共 {total} 个文件</span>
<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 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 ? "没有匹配的文件" : "您还没有上传任何文件"}
<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">
<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">
<TableRow className='hover:bg-transparent border-dashed'>
<TableHead className='w-[50px] pl-4 py-3'>
<Checkbox
checked={isAllSelected || (isSomeSelected ? "indeterminate" : false)}
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>
<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)
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" : ""
isSelected ? 'bg-sky-500/5 hover:bg-sky-500/10' : ''
}`}
>
<TableCell className="pl-4 py-3">
<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/") ? (
<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/') ? (
<FileImagePreview
fileId={file.id}
alt={file.file_name}
quality="low"
variant="compact"
className="size-full object-cover"
quality='low'
variant='compact'
className='size-full object-cover'
/>
) : (
getFileIcon(file.mime_type, "size-4.5")
getFileIcon(file.mime_type, 'size-4.5')
)}
</div>
</TableCell>
<TableCell className="py-3 font-mono text-[11px] text-muted-foreground select-all">
<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}>
<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}>
<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">
<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}>
<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">
<TableCell className='py-3 text-xs'>
{formatFileSize(file.file_size)}
</TableCell>
<TableCell className="py-3 text-xs text-muted-foreground">
<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">
<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="查看详情"
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" />
<Eye className='size-3.5' />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted"
title="下载"
size='icon'
variant='ghost'
className='h-7 w-7 rounded-md hover:bg-muted'
title='下载'
onClick={() => handleDownload(file)}
>
<Download className="size-3.5" />
<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="删除"
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" />
<Trash2 className='size-3.5' />
</Button>
</div>
</TableCell>
</TableRow>
)
);
})}
</TableBody>
</Table>
@@ -374,23 +431,23 @@ export function FileList() {
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<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"
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">
<span className='text-xs text-muted-foreground'>
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-7 px-3"
size='sm'
variant='outline'
className='border-dashed text-xs h-7 px-3'
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
@@ -400,29 +457,34 @@ export function FileList() {
)}
{/* 详情查看 Sheet */}
<Sheet open={!!detailTarget} onOpenChange={(open) => !open && setDetailTarget(null)}>
<SheetContent className="w-full sm:max-w-md overflow-y-auto p-6">
<SheetHeader className="border-b pb-4 px-0">
<Sheet
open={!!detailTarget}
onOpenChange={(open) => !open && setDetailTarget(null)}
>
<SheetContent className='w-full sm:max-w-md overflow-y-auto p-6'>
<SheetHeader className='border-b pb-4 px-0'>
<SheetTitle>文件详情</SheetTitle>
<SheetDescription>查看文件的完整元数据与配置属性。</SheetDescription>
<SheetDescription>
查看文件的完整元数据与配置属性。
</SheetDescription>
</SheetHeader>
{detailTarget && (
<div className="space-y-6 py-4">
<div className='space-y-6 py-4'>
{/* 大图/格式预览 */}
<div className="flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2">
{detailTarget.mime_type.startsWith("image/") ? (
<div className='flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2'>
{detailTarget.mime_type.startsWith('image/') ? (
<FileImagePreview
fileId={detailTarget.id}
alt={detailTarget.file_name}
quality="low"
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
fallbackClassName="min-h-32 w-full rounded-lg"
quality='low'
className='max-h-full max-w-full object-contain rounded-lg shadow-sm'
fallbackClassName='min-h-32 w-full rounded-lg'
/>
) : (
<div className="flex flex-col items-center gap-3">
{getFileIcon(detailTarget.mime_type, "size-14")}
<span className="text-xs font-medium text-muted-foreground uppercase">
<div className='flex flex-col items-center gap-3'>
{getFileIcon(detailTarget.mime_type, 'size-14')}
<span className='text-xs font-medium text-muted-foreground uppercase'>
{detailTarget.extension} 文件
</span>
</div>
@@ -430,76 +492,123 @@ export function FileList() {
</div>
{/* 元数据列表 */}
<div className="space-y-4 text-xs">
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件 ID</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.id}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件名</span>
<span className="col-span-2 break-all font-medium text-foreground/90">{detailTarget.file_name}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储路径</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.file_path}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">大小</span>
<span className="col-span-2 text-foreground/90">
{formatFileSize(detailTarget.file_size)} ({detailTarget.file_size.toLocaleString()} 字节)
<div className='space-y-4 text-xs'>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件 ID
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.id}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">MIME 类型</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.mime_type}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件名
</span>
<span className='col-span-2 break-all font-medium text-foreground/90'>
{detailTarget.file_name}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件后缀</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.extension}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
存储路径
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.file_path}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">Hash (SHA-256)</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.hash}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
大小
</span>
<span className='col-span-2 text-foreground/90'>
{formatFileSize(detailTarget.file_size)} (
{detailTarget.file_size.toLocaleString()} 字节)
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">业务类型</span>
<span className="col-span-2">
<Badge variant="secondary" className="text-[10px] py-0 px-2 rounded-md font-normal">
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
MIME 类型
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.mime_type}
</span>
</div>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件后缀
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.extension}
</span>
</div>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
Hash (SHA-256)
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.hash}
</span>
</div>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
业务类型
</span>
<span className='col-span-2'>
<Badge
variant='secondary'
className='text-[10px] py-0 px-2 rounded-md font-normal'
>
{detailTarget.type}
</Badge>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储驱动</span>
<span className="col-span-2 font-mono text-foreground/90">{storageDriverQuery.data ?? "local"}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
存储驱动
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{storageDriverQuery.data ?? 'local'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传者 ID</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.user_id}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
上传者 ID
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.user_id}
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传时间</span>
<span className="col-span-2 text-foreground/90">{formatDate(detailTarget.created_at)}</span>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
上传时间
</span>
<span className='col-span-2 text-foreground/90'>
{formatDate(detailTarget.created_at)}
</span>
</div>
{detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && (
<div className="space-y-1.5 pt-2">
<span className="text-muted-foreground font-medium block">额外元数据</span>
<pre className="p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto">
{JSON.stringify(detailTarget.metadata, null, 2)}
</pre>
</div>
)}
{detailTarget.metadata &&
Object.keys(detailTarget.metadata).length > 0 && (
<div className='space-y-1.5 pt-2'>
<span className='text-muted-foreground font-medium block'>
额外元数据
</span>
<pre className='p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto'>
{JSON.stringify(detailTarget.metadata, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
@@ -507,29 +616,40 @@ export function FileList() {
</Sheet>
{/* 删除确认 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{" "}
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
确定要删除文件{' '}
<span className='font-semibold text-foreground'>
「{deleteTarget?.file_name}」
</span>{' '}
吗?此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
disabled={deleteMutation.isPending}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
className='bg-destructive hover:bg-destructive/90 text-destructive-foreground'
>
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
{deleteMutation.isPending && (
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
)}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
);
}
@@ -1,12 +1,35 @@
"use client"
'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 * 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 { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
@@ -14,265 +37,322 @@ import {
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import services, {formatFileSize} from "@/lib/services"
} from '@/components/ui/chart';
import services, { formatFileSize } from '@/lib/services';
const categoryMap: Record<string, string> = {
"图片": "images",
"视频": "videos",
"音频": "audio",
"文档": "documents",
"压缩包": "archives",
"其他": "others",
}
图片: 'images',
视频: 'videos',
音频: 'audio',
文档: 'documents',
压缩包: 'archives',
其他: 'others',
};
const statsChartConfig = {
images: {
label: "图片",
color: "hsl(var(--chart-1))",
label: '图片',
color: 'hsl(var(--chart-1))',
},
videos: {
label: "视频",
color: "hsl(var(--chart-2))",
label: '视频',
color: 'hsl(var(--chart-2))',
},
audio: {
label: "音频",
color: "hsl(var(--chart-3))",
label: '音频',
color: 'hsl(var(--chart-3))',
},
documents: {
label: "文档",
color: "hsl(var(--chart-4))",
label: '文档',
color: 'hsl(var(--chart-4))',
},
archives: {
label: "压缩包",
color: "hsl(var(--chart-5))",
label: '压缩包',
color: 'hsl(var(--chart-5))',
},
others: {
label: "其他",
color: "hsl(var(--chart-6))",
label: '其他',
color: 'hsl(var(--chart-6))',
},
count: {
label: "新增文件数",
color: "hsl(var(--primary))",
label: '新增文件数',
color: 'hsl(var(--primary))',
},
size: {
label: "新增大小",
color: "hsl(var(--chart-2))",
label: '新增大小',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig
} satisfies ChartConfig;
export function FileStats() {
const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count")
const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>(
'count',
);
const statsQuery = useQuery({
queryKey: ["files", "stats"],
queryKey: ['files', 'stats'],
queryFn: () => services.adminUpload.getFileStats(),
staleTime: 0,
refetchOnMount: "always",
})
refetchOnMount: 'always',
});
const stats = statsQuery.data
const stats = statsQuery.data;
const trendData = React.useMemo(() => {
if (!stats?.trend) return []
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])
value: trendMetric === 'count' ? item.count : item.size,
}));
}, [stats?.trend, trendMetric]);
const categoryCountData = React.useMemo(() => {
if (!stats?.categories) return []
if (!stats?.categories) return [];
return stats.categories
.filter((c) => c.count > 0)
.map((c) => {
const key = categoryMap[c.name] || "others"
const key = categoryMap[c.name] || 'others';
return {
name: key,
value: c.count,
fill: `var(--color-${key})`,
}
})
}, [stats?.categories])
};
});
}, [stats?.categories]);
const categorySizeData = React.useMemo(() => {
if (!stats?.categories) return []
if (!stats?.categories) return [];
return stats.categories
.filter((c) => c.size > 0)
.map((c) => {
const key = categoryMap[c.name] || "others"
const key = categoryMap[c.name] || 'others';
return {
name: key,
value: c.size,
fill: `var(--color-${key})`,
}
})
}, [stats?.categories])
};
});
}, [stats?.categories]);
const maxStats = React.useMemo(() => {
if (!stats?.categories || stats.categories.length === 0) {
return { maxCountName: "无", maxCount: 0, maxSizeName: "无", maxSize: 0 }
return { maxCountName: '无', maxCount: 0, maxSizeName: '无', maxSize: 0 };
}
let maxCountName = "无"
let maxCount = 0
let maxSizeName = "无"
let 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
maxCount = cat.count;
maxCountName = cat.name;
}
if (cat.size > maxSize) {
maxSize = cat.size
maxSizeName = cat.name
maxSize = cat.size;
maxSizeName = cat.name;
}
})
});
return { maxCountName, maxCount, maxSizeName, maxSize }
}, [stats?.categories])
return { maxCountName, maxCount, maxSizeName, maxSize };
}, [stats?.categories]);
const trendSummary = React.useMemo(() => {
if (!stats?.trend) return { count: 0, size: 0 }
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])
{ 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 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 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='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" />
<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 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>
<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" />
<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 className='text-2xl font-bold font-mono text-emerald-500'>
{formatFileSize(stats.total_size)}
</div>
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传成功的文件总数</p>
<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>
<HardDrive className="size-4 text-amber-500" />
<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-amber-500">+{formatFileSize(trendSummary.size)}</div>
<p className="text-[10px] text-muted-foreground mt-1">最近 7 天上传所消耗的存储带宽</p>
<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">
<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>
<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">
<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")}
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")}
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 }}>
<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 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 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)" />
<CartesianGrid
strokeDasharray='3 3'
vertical={false}
stroke='hsl(var(--border)/40)'
/>
<XAxis
dataKey="formattedDate"
dataKey='formattedDate'
tickLine={false}
axisLine={false}
tickMargin={8}
style={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
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())}
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickFormatter={(v) =>
trendMetric === 'size' ? formatFileSize(v) : v.toString()
}
/>
<ChartTooltip
cursor={false}
@@ -280,37 +360,52 @@ export function FileStats() {
<ChartTooltipContent
hideLabel
formatter={(value) => {
const label = trendMetric === "count" ? "新增文件数" : "新增大小"
const label =
trendMetric === 'count' ? '新增文件数' : '新增大小';
const formattedValue =
trendMetric === "size" ? formatFileSize(Number(value)) : `${value} 个`
trendMetric === 'size'
? formatFileSize(Number(value))
: `${value} 个`;
return (
<>
<div className="flex items-center gap-1.5">
<div className='flex items-center gap-1.5'>
<span
className="size-2 rounded-full animate-pulse"
className='size-2 rounded-full animate-pulse'
style={{
backgroundColor:
trendMetric === "count" ? "var(--color-count)" : "var(--color-size)",
trendMetric === 'count'
? 'var(--color-count)'
: 'var(--color-size)',
}}
/>
<span className="text-muted-foreground">{label}</span>
<span className='text-muted-foreground'>
{label}
</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums ml-auto">
<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)"}
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)"}
fill={
trendMetric === 'count'
? 'url(#colorCount)'
: 'url(#colorSize)'
}
/>
</AreaChart>
</ChartContainer>
@@ -319,30 +414,37 @@ export function FileStats() {
</Card>
{/* 两个饼图分布 */}
<div className="grid gap-6 md:grid-cols-2">
<div className='grid gap-6 md:grid-cols-2'>
{/* 饼图 1: 格式数量分布 */}
<Card className="bg-card/20 border-border/40 flex flex-col">
<Card className='bg-card/20 border-border/40 flex flex-col'>
<CardHeader>
<CardTitle className="text-sm font-bold">文件格式数量分布</CardTitle>
<CardDescription className="text-xs">各种格式类型的文件个数占比</CardDescription>
<CardTitle className='text-sm font-bold'>
文件格式数量分布
</CardTitle>
<CardDescription className='text-xs'>
各种格式类型的文件个数占比
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4">
<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 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">
<div className='h-[240px] w-full'>
<ChartContainer
config={statsChartConfig}
className='w-full h-full'
>
<PieChart>
<Pie
data={categoryCountData}
cx="50%"
cy="50%"
cx='50%'
cy='50%'
innerRadius={60}
outerRadius={80}
paddingAngle={3}
dataKey="value"
dataKey='value'
>
{categoryCountData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
@@ -354,66 +456,88 @@ export function FileStats() {
<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))"
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 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">
<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"
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> 个文件。
<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">
<Card className='bg-card/20 border-border/40 flex flex-col'>
<CardHeader>
<CardTitle className="text-sm font-bold">文件占用容量分布</CardTitle>
<CardDescription className="text-xs">各种格式类型的文件大小占比</CardDescription>
<CardTitle className='text-sm font-bold'>
文件占用容量分布
</CardTitle>
<CardDescription className='text-xs'>
各种格式类型的文件大小占比
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-4">
<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 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">
<div className='h-[240px] w-full'>
<ChartContainer
config={statsChartConfig}
className='w-full h-full'
>
<PieChart>
<Pie
data={categorySizeData}
cx="50%"
cy="50%"
cx='50%'
cy='50%'
innerRadius={60}
outerRadius={80}
paddingAngle={3}
dataKey="value"
dataKey='value'
>
{categorySizeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
@@ -425,38 +549,52 @@ export function FileStats() {
<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))"
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 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">
<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"
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>{" "}
<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>
@@ -464,5 +602,5 @@ export function FileStats() {
</Card>
</div>
</div>
)
);
}
@@ -1,104 +1,130 @@
"use client"
'use client';
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {Database, Loader2, Play, Save} from "lucide-react"
import {toast} from "sonner"
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, Loader2, Play, Save } from 'lucide-react';
import { toast } from 'sonner';
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"
import {Field, FieldDescription, FieldGroup, FieldLabel} from "@/components/ui/field"
import {Input} from "@/components/ui/input"
import {Progress} from "@/components/ui/progress"
import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Switch} from "@/components/ui/switch"
import services from "@/lib/services"
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import services from '@/lib/services';
import type {
ObjectStorageConfig,
StorageConfig,
StorageDriver,
TaskExecution,
TaskExecutionStatus
} from "@/lib/services/admin/types"
TaskExecutionStatus,
} from '@/lib/services/admin/types';
const storageConfigKey = "storage_config"
const storageMigrationTaskType = "storage_migration"
const storageConfigKey = 'storage_config';
const storageMigrationTaskType = 'storage_migration';
const driverLabels: Record<StorageDriver, string> = {
local: "本地文件系统",
s3: "AWS S3",
r2: "Cloudflare R2",
minio: "MinIO",
oss: "阿里云 OSS",
webdav: "WebDAV",
}
local: '本地文件系统',
s3: 'AWS S3',
r2: 'Cloudflare R2',
minio: 'MinIO',
oss: '阿里云 OSS',
webdav: 'WebDAV',
};
const emptyObjectConfig: ObjectStorageConfig = {
endpoint: "",
region: "",
bucket: "",
access_key_id: "",
secret_access_key: "",
endpoint: '',
region: '',
bucket: '',
access_key_id: '',
secret_access_key: '',
path_style: false,
key_prefix: "",
cdn_url: "",
}
key_prefix: '',
cdn_url: '',
};
function normalizeConfig(config: StorageConfig): StorageConfig {
const s3 = {...emptyObjectConfig, ...config.s3}
const r2 = {...emptyObjectConfig, ...config.r2}
const minio = {...emptyObjectConfig, ...config.minio}
const oss = {...emptyObjectConfig, ...config.oss}
const webdav = {...config.webdav}
const s3 = { ...emptyObjectConfig, ...config.s3 };
const r2 = { ...emptyObjectConfig, ...config.r2 };
const minio = { ...emptyObjectConfig, ...config.minio };
const oss = { ...emptyObjectConfig, ...config.oss };
const webdav = { ...config.webdav };
return {
...config,
local: {root: config.local?.root || "."},
s3: {...s3, region: s3.region || "us-east-1"},
r2: {...r2, region: r2.region || "auto"},
minio: {...minio, region: minio.region || "us-east-1", path_style: config.minio?.path_style ?? true},
local: { root: config.local?.root || '.' },
s3: { ...s3, region: s3.region || 'us-east-1' },
r2: { ...r2, region: r2.region || 'auto' },
minio: {
...minio,
region: minio.region || 'us-east-1',
path_style: config.minio?.path_style ?? true,
},
oss,
webdav: {
endpoint: webdav.endpoint || "",
username: webdav.username || "",
password: webdav.password || "",
base_path: webdav.base_path || "",
endpoint: webdav.endpoint || '',
username: webdav.username || '',
password: webdav.password || '',
base_path: webdav.base_path || '',
},
}
};
}
type StorageMigrationView = {
state: "idle" | TaskExecutionStatus
id?: string
task_id?: string
source_driver?: StorageDriver
target_driver?: StorageDriver
error?: string
}
state: 'idle' | TaskExecutionStatus;
id?: string;
task_id?: string;
source_driver?: StorageDriver;
target_driver?: StorageDriver;
error?: string;
};
type StorageMigrationPayload = {
target: StorageConfig
}
target: StorageConfig;
};
function parseMigrationPayload(payload: string): StorageConfig | undefined {
try {
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>
return parsed.target ? normalizeConfig(parsed.target) : undefined
const parsed = JSON.parse(payload) as Partial<StorageMigrationPayload>;
return parsed.target ? normalizeConfig(parsed.target) : undefined;
} catch {
return undefined
return undefined;
}
}
function driverLabel(driver?: StorageDriver): string {
return driver ? driverLabels[driver] : "未知"
return driver ? driverLabels[driver] : '未知';
}
function latestMigration(executions: TaskExecution[], current?: StorageConfig): StorageMigrationView {
const execution = executions[0]
if (!execution || execution.status === "succeeded") {
return {state: "idle"}
function latestMigration(
executions: TaskExecution[],
current?: StorageConfig,
): StorageMigrationView {
const execution = executions[0];
if (!execution || execution.status === 'succeeded') {
return { state: 'idle' };
}
const target = parseMigrationPayload(execution.payload)
const target = parseMigrationPayload(execution.payload);
return {
state: execution.status,
id: execution.id,
@@ -106,227 +132,339 @@ function latestMigration(executions: TaskExecution[], current?: StorageConfig):
source_driver: current?.driver,
target_driver: target?.driver,
error: execution.error_message,
}
};
}
export function StorageConfigTab() {
const queryClient = useQueryClient()
const [config, setConfig] = React.useState<StorageConfig | null>(null)
const queryClient = useQueryClient();
const [config, setConfig] = React.useState<StorageConfig | null>(null);
const query = useQuery({
queryKey: ["admin", "storage-config"],
queryKey: ['admin', 'storage-config'],
queryFn: async () => {
const [configRecord, executions] = await Promise.all([
services.adminSystemConfig.getSystemConfig(storageConfigKey),
services.adminTask.listTaskExecutions({task_type: storageMigrationTaskType, page: 1, page_size: 1}),
])
const current = normalizeConfig(JSON.parse(configRecord.value) as StorageConfig)
const migration = latestMigration(executions.items, current)
services.adminTask.listTaskExecutions({
task_type: storageMigrationTaskType,
page: 1,
page_size: 1,
}),
]);
const current = normalizeConfig(
JSON.parse(configRecord.value) as StorageConfig,
);
const migration = latestMigration(executions.items, current);
return {
config: current,
migration,
}
};
},
refetchInterval: (result) => {
const state = result.state.data?.migration.state
return state === "pending" || state === "running" ? 2000 : false
const state = result.state.data?.migration.state;
return state === 'pending' || state === 'running' ? 2000 : false;
},
})
});
React.useEffect(() => {
if (query.data?.config) {
setConfig((current) => {
const normalized = normalizeConfig(query.data.config)
const normalized = normalizeConfig(query.data.config);
if (current) {
return {
...normalized,
driver: current.driver,
}
};
}
return normalized
})
return normalized;
});
}
}, [query.data?.config])
}, [query.data?.config]);
const saveMutation = useMutation({
mutationFn: async (value: StorageConfig) => {
await services.adminSystemConfig.updateSystemConfig(storageConfigKey, {
value: JSON.stringify(value),
})
});
},
onSuccess: () => {
toast.success("存储配置已保存")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
toast.success('存储配置已保存');
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => {
toast.error(error.message || "保存存储配置失败")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
toast.error(error.message || '保存存储配置失败');
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
})
});
const migrateMutation = useMutation({
mutationFn: async (value: StorageConfig) => {
const payload: StorageMigrationPayload = {target: value}
const payload: StorageMigrationPayload = { target: value };
return services.adminTask.dispatchTask({
task_type: storageMigrationTaskType,
payload: JSON.stringify(payload),
})
});
},
onSuccess: () => {
toast.success("存储迁移任务已下发")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
toast.success('存储迁移任务已下发');
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => {
toast.error(error.message || "下发存储迁移任务失败")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
toast.error(error.message || '下发存储迁移任务失败');
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
})
});
const runMutation = useMutation({
mutationFn: (executionID: string) => services.adminTask.retryTaskExecution(executionID),
mutationFn: (executionID: string) =>
services.adminTask.retryTaskExecution(executionID),
onSuccess: () => {
toast.success("存储迁移任务已重新下发")
void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]})
toast.success('存储迁移任务已重新下发');
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => toast.error(error.message || "运行存储迁移失败"),
})
onError: (error: Error) => toast.error(error.message || '运行存储迁移失败'),
});
if (query.isPending || !config) {
return <div className="flex justify-center py-20"><Loader2 className="animate-spin" /></div>
return (
<div className='flex justify-center py-20'>
<Loader2 className='animate-spin' />
</div>
);
}
const migration = query.data?.migration
const isReadOnly = migration ? migration.state !== "idle" : false
const migration = query.data?.migration;
const isReadOnly = migration ? migration.state !== 'idle' : false;
const isFormDisabled =
(migration ? (migration.state === "pending" || migration.state === "running") : false) ||
(migration
? migration.state === 'pending' || migration.state === 'running'
: false) ||
saveMutation.isPending ||
migrateMutation.isPending
migrateMutation.isPending;
const updateObject = (driver: "s3" | "r2" | "minio" | "oss", patch: Partial<ObjectStorageConfig>) => {
setConfig((current) => current ? {
...current,
[driver]: {...current[driver], ...patch},
} : current)
}
const updateObject = (
driver: 's3' | 'r2' | 'minio' | 'oss',
patch: Partial<ObjectStorageConfig>,
) => {
setConfig((current) =>
current
? {
...current,
[driver]: { ...current[driver], ...patch },
}
: current,
);
};
return (
<div className="flex w-full flex-col gap-6">
<div className='flex w-full flex-col gap-6'>
{isReadOnly && migration && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CardTitle className='flex items-center gap-2'>
存储维护模式
<Badge variant={migration.state === "failed" ? "destructive" : "secondary"}>
<Badge
variant={
migration.state === 'failed' ? 'destructive' : 'secondary'
}
>
{migration.state}
</Badge>
</CardTitle>
<CardDescription>
{driverLabel(migration.source_driver)} → {driverLabel(migration.target_driver)}。迁移期间文件只允许读取,禁止上传、删除和清理。
{driverLabel(migration.source_driver)} →{' '}
{driverLabel(migration.target_driver)}
。迁移期间文件只允许读取,禁止上传、删除和清理。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Progress value={migration.state === "succeeded" ? 100 : undefined} />
<p className="text-sm text-muted-foreground">
迁移进度请查看任务执行日志:{migration.task_id || "尚未下发"}
<CardContent className='flex flex-col gap-3'>
<Progress
value={migration.state === 'succeeded' ? 100 : undefined}
/>
<p className='text-sm text-muted-foreground'>
迁移进度请查看任务执行日志:{migration.task_id || '尚未下发'}
</p>
{migration.error && <p className="text-sm text-destructive">{migration.error}</p>}
{migration.error && (
<p className='text-sm text-destructive'>{migration.error}</p>
)}
</CardContent>
<CardFooter>
<Button
variant="outline"
disabled={migration.state !== "failed" || !migration.id || runMutation.isPending}
variant='outline'
disabled={
migration.state !== 'failed' ||
!migration.id ||
runMutation.isPending
}
onClick={() => migration.id && runMutation.mutate(migration.id)}
>
{runMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
{runMutation.isPending ? (
<Loader2 data-icon='inline-start' className='animate-spin' />
) : (
<Play data-icon='inline-start' />
)}
重试迁移
</Button>
</CardFooter>
</Card>
)}
<Card className="border border-dashed shadow-sm">
<CardHeader className="border-b border-dashed pb-4">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
<Database className="size-4" />
<Card className='border border-dashed shadow-sm'>
<CardHeader className='border-b border-dashed pb-4'>
<div className='flex items-center gap-2'>
<div className='rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500'>
<Database className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">文件存储</CardTitle>
<CardDescription className="text-xs">
<CardTitle className='text-base font-semibold'>
文件存储
</CardTitle>
<CardDescription className='text-xs'>
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<FieldGroup className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field className="flex flex-col gap-1.5 md:col-span-2">
<FieldLabel className="text-xs font-semibold">存储类型</FieldLabel>
<CardContent className='pt-6'>
<FieldGroup className='space-y-6'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<Field className='flex flex-col gap-1.5 md:col-span-2'>
<FieldLabel className='text-xs font-semibold'>
存储类型
</FieldLabel>
<Select
value={config.driver}
disabled={isFormDisabled}
onValueChange={(value) => setConfig({...config, driver: value as StorageDriver})}
onValueChange={(value) =>
setConfig({ ...config, driver: value as StorageDriver })
}
>
<SelectTrigger className="w-full border-dashed bg-card text-xs">
<SelectTrigger className='w-full border-dashed bg-card text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(driverLabels) as StorageDriver[]).map((driver) => (
<SelectItem key={driver} value={driver}>{driverLabels[driver]}</SelectItem>
))}
{(Object.keys(driverLabels) as StorageDriver[]).map(
(driver) => (
<SelectItem key={driver} value={driver}>
{driverLabels[driver]}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
</Field>
{config.driver === "local" && (
<div className="md:col-span-2">
{config.driver === 'local' && (
<div className='md:col-span-2'>
<TextField
label="根目录"
label='根目录'
value={config.local.root}
placeholder="."
onChange={(root) => setConfig({...config, local: {root}})}
placeholder='.'
onChange={(root) =>
setConfig({ ...config, local: { root } })
}
/>
</div>
)}
{(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && (
{(config.driver === 's3' ||
config.driver === 'r2' ||
config.driver === 'minio' ||
config.driver === 'oss') && (
<ObjectFields
driver={config.driver as "s3" | "r2" | "minio" | "oss"}
value={config[config.driver as "s3" | "r2" | "minio" | "oss"]}
onChange={(patch) => updateObject(config.driver as "s3" | "r2" | "minio" | "oss", patch)}
driver={config.driver as 's3' | 'r2' | 'minio' | 'oss'}
value={config[config.driver as 's3' | 'r2' | 'minio' | 'oss']}
onChange={(patch) =>
updateObject(
config.driver as 's3' | 'r2' | 'minio' | 'oss',
patch,
)
}
/>
)}
{config.driver === "webdav" && (
{config.driver === 'webdav' && (
<>
<div className="md:col-span-2">
<TextField label="服务地址" value={config.webdav.endpoint} placeholder="https://dav.example.com" onChange={(endpoint) => setConfig({...config, webdav: {...config.webdav, endpoint}})} />
<div className='md:col-span-2'>
<TextField
label='服务地址'
value={config.webdav.endpoint}
placeholder='https://dav.example.com'
onChange={(endpoint) =>
setConfig({
...config,
webdav: { ...config.webdav, endpoint },
})
}
/>
</div>
<TextField label="用户名" value={config.webdav.username} onChange={(username) => setConfig({...config, webdav: {...config.webdav, username}})} />
<TextField label="密码" type="password" value={config.webdav.password} onChange={(password) => setConfig({...config, webdav: {...config.webdav, password}})} />
<div className="md:col-span-2">
<TextField label="基础路径" value={config.webdav.base_path} placeholder="wavelet" onChange={(base_path) => setConfig({...config, webdav: {...config.webdav, base_path}})} />
<TextField
label='用户名'
value={config.webdav.username}
onChange={(username) =>
setConfig({
...config,
webdav: { ...config.webdav, username },
})
}
/>
<TextField
label='密码'
type='password'
value={config.webdav.password}
onChange={(password) =>
setConfig({
...config,
webdav: { ...config.webdav, password },
})
}
/>
<div className='md:col-span-2'>
<TextField
label='基础路径'
value={config.webdav.base_path}
placeholder='wavelet'
onChange={(base_path) =>
setConfig({
...config,
webdav: { ...config.webdav, base_path },
})
}
/>
</div>
</>
)}
</div>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6">
<CardFooter className='justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6'>
{config.driver !== query.data?.config.driver && (
<span className="text-xs text-amber-500 mr-auto text-left max-w-md">
⚠️ 您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
<span className='text-xs text-amber-500 mr-auto text-left max-w-md'>
⚠️
您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
</span>
)}
<div className="flex gap-2">
<div className='flex gap-2'>
<Button
variant="outline"
variant='outline'
disabled={isFormDisabled}
onClick={() => saveMutation.mutate(config)}
className="border-dashed"
className='border-dashed'
>
{saveMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
{saveMutation.isPending ? (
<Loader2 data-icon='inline-start' className='animate-spin' />
) : (
<Save data-icon='inline-start' />
)}
保存配置
</Button>
{config.driver !== query.data?.config.driver && (
@@ -334,7 +472,11 @@ export function StorageConfigTab() {
disabled={isFormDisabled}
onClick={() => migrateMutation.mutate(config)}
>
{migrateMutation.isPending ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Play data-icon="inline-start" />}
{migrateMutation.isPending ? (
<Loader2 data-icon='inline-start' className='animate-spin' />
) : (
<Play data-icon='inline-start' />
)}
开始迁移
</Button>
)}
@@ -342,7 +484,7 @@ export function StorageConfigTab() {
</CardFooter>
</Card>
</div>
)
);
}
function ObjectFields({
@@ -350,41 +492,87 @@ function ObjectFields({
value,
onChange,
}: {
driver: "s3" | "r2" | "minio" | "oss"
value: ObjectStorageConfig
onChange: (patch: Partial<ObjectStorageConfig>) => void
driver: 's3' | 'r2' | 'minio' | 'oss';
value: ObjectStorageConfig;
onChange: (patch: Partial<ObjectStorageConfig>) => void;
}) {
return (
<>
{driver === "r2" && (
<div className="md:col-span-2">
<TextField label="Account ID" value={value.account_id || ""} onChange={(account_id) => onChange({account_id})} />
{driver === 'r2' && (
<div className='md:col-span-2'>
<TextField
label='Account ID'
value={value.account_id || ''}
onChange={(account_id) => onChange({ account_id })}
/>
</div>
)}
{driver !== "s3" && (
<div className="md:col-span-2">
<TextField label="Endpoint" value={value.endpoint} placeholder="https://..." onChange={(endpoint) => onChange({endpoint})} />
{driver !== 's3' && (
<div className='md:col-span-2'>
<TextField
label='Endpoint'
value={value.endpoint}
placeholder='https://...'
onChange={(endpoint) => onChange({ endpoint })}
/>
</div>
)}
<TextField label="Region" value={value.region} onChange={(region) => onChange({region})} />
<TextField label="Bucket" value={value.bucket} onChange={(bucket) => onChange({bucket})} />
<TextField label="Access Key ID" value={value.access_key_id} onChange={(access_key_id) => onChange({access_key_id})} />
<TextField label="Secret Access Key" type="password" value={value.secret_access_key} onChange={(secret_access_key) => onChange({secret_access_key})} />
<TextField label="对象前缀" value={value.key_prefix} placeholder="uploads" onChange={(key_prefix) => onChange({key_prefix})} />
<TextField label="CDN 地址" value={value.cdn_url} placeholder="https://cdn.example.com" onChange={(cdn_url) => onChange({cdn_url})} />
{(driver === "s3" || driver === "minio") && (
<div className="md:col-span-2">
<Field orientation="horizontal" className="flex items-center justify-between border border-dashed rounded-lg p-4 bg-muted/30">
<div className="flex flex-col gap-0.5">
<FieldLabel className="text-xs font-semibold cursor-pointer">Path Style</FieldLabel>
<FieldDescription className="text-[11px] leading-relaxed text-muted-foreground">MinIO 等自托管 S3 通常需要开启。</FieldDescription>
<TextField
label='Region'
value={value.region}
onChange={(region) => onChange({ region })}
/>
<TextField
label='Bucket'
value={value.bucket}
onChange={(bucket) => onChange({ bucket })}
/>
<TextField
label='Access Key ID'
value={value.access_key_id}
onChange={(access_key_id) => onChange({ access_key_id })}
/>
<TextField
label='Secret Access Key'
type='password'
value={value.secret_access_key}
onChange={(secret_access_key) => onChange({ secret_access_key })}
/>
<TextField
label='对象前缀'
value={value.key_prefix}
placeholder='uploads'
onChange={(key_prefix) => onChange({ key_prefix })}
/>
<TextField
label='CDN 地址'
value={value.cdn_url}
placeholder='https://cdn.example.com'
onChange={(cdn_url) => onChange({ cdn_url })}
/>
{(driver === 's3' || driver === 'minio') && (
<div className='md:col-span-2'>
<Field
orientation='horizontal'
className='flex items-center justify-between border border-dashed rounded-lg p-4 bg-muted/30'
>
<div className='flex flex-col gap-0.5'>
<FieldLabel className='text-xs font-semibold cursor-pointer'>
Path Style
</FieldLabel>
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
MinIO 等自托管 S3 通常需要开启。
</FieldDescription>
</div>
<Switch checked={value.path_style} onCheckedChange={(path_style) => onChange({path_style})} />
<Switch
checked={value.path_style}
onCheckedChange={(path_style) => onChange({ path_style })}
/>
</Field>
</div>
)}
</>
)
);
}
function TextField({
@@ -392,24 +580,24 @@ function TextField({
value,
onChange,
placeholder,
type = "text",
type = 'text',
}: {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
type?: React.HTMLInputTypeAttribute
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
type?: React.HTMLInputTypeAttribute;
}) {
return (
<Field className="flex flex-col gap-1.5">
<FieldLabel className="text-xs font-semibold">{label}</FieldLabel>
<Field className='flex flex-col gap-1.5'>
<FieldLabel className='text-xs font-semibold'>{label}</FieldLabel>
<Input
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="border-dashed bg-card text-xs"
className='border-dashed bg-card text-xs'
/>
</Field>
)
);
}