Files
OpenFlare/frontend/components/common/user/file-manager.tsx
T
ryan 1f391e9ec2 fix(storage): apply driver switch on save and repoint upload records
Saving storage settings now activates the selected driver immediately
instead of staging it until migration. When the driver changes, existing
upload rows are repointed to the new storage_driver so /f/{id} reads use
the correct backend. ForDriver can also open non-active drivers from the
saved multi-backend config.
2026-06-18 13:40:29 +08:00

367 lines
14 KiB
TypeScript

"use client"
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {
ChevronLeft,
ChevronRight,
Download,
FileArchive,
FileAudio,
FileImage,
FileText,
FileVideo,
Globe,
Loader2,
Lock,
Plus,
Search,
Trash2,
Upload
} 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 {Card, CardContent} from "@/components/ui/card"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
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-10") {
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",
})
}
export function UserFileManager() {
const queryClient = useQueryClient()
const [keyword, setKeyword] = React.useState("")
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
const [page, setPage] = React.useState(1)
const [uploading, setUploading] = React.useState(false)
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
const [uploadAccessMode, setUploadAccessMode] = React.useState<number>(0) // 默认私有
const fileInputRef = React.useRef<HTMLInputElement>(null)
const pageSize = 12 // 每页 12 个卡片更均衡(3x4 或 4x3 布局)
// 搜索防抖
React.useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword)
setPage(1)
}, 400)
return () => clearTimeout(timer)
}, [keyword])
// 我的文件列表查询
const { data, isPending } = useQuery({
queryKey: ["user-files", page, pageSize, debouncedKeyword],
queryFn: () => services.upload.listMyUploads(page, pageSize, debouncedKeyword || undefined),
})
const files = data?.items ?? []
const total = data?.total ?? 0
const totalPages = Math.ceil(total / pageSize)
// 上传文件 Mutation
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
return services.upload.uploadFile(file, "generic", undefined, uploadAccessMode)
},
onMutate: () => {
setUploading(true)
},
onSuccess: () => {
toast.success("文件上传成功")
void queryClient.invalidateQueries({ queryKey: ["user-files"] })
},
onError: (err: Error) => {
toast.error(err.message || "上传失败")
},
onSettled: () => {
setUploading(false)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
})
// 删除文件 Mutation
const deleteMutation = useMutation({
mutationFn: (id: string) => services.upload.deleteMyFile(id),
onSuccess: () => {
toast.success("文件已成功删除")
void queryClient.invalidateQueries({ queryKey: ["user-files"] })
setDeleteTarget(null)
},
onError: (err: Error) => {
toast.error(err.message || "删除失败")
}
})
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
uploadMutation.mutate(file)
}
}
const triggerUploadClick = () => {
fileInputRef.current?.click()
}
const handleDownload = (file: UploadRecord) => {
const url = services.upload.getDownloadUrl(file.id)
const a = document.createElement("a")
a.href = url
a.download = file.file_name
a.click()
}
return (
<div className="flex w-full flex-col gap-6">
{/* 操作栏:搜索、权限选择、上传按钮 */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="搜索文件名..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="pl-9 h-9"
/>
</div>
<div className="flex items-center gap-3 self-end sm:self-auto">
{/* 上传权限设置 */}
<div className="flex items-center gap-1.5 border rounded-lg p-1 bg-muted/40 h-9 text-xs">
<Button
variant={uploadAccessMode === 0 ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2.5 text-xs font-normal"
onClick={() => setUploadAccessMode(0)}
>
<Lock className="mr-1 size-3 text-amber-500" />
私有
</Button>
<Button
variant={uploadAccessMode === 1 ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2.5 text-xs font-normal"
onClick={() => setUploadAccessMode(1)}
>
<Globe className="mr-1 size-3 text-sky-500" />
公开
</Button>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
disabled={uploading}
/>
<Button
onClick={triggerUploadClick}
disabled={uploading}
className="h-9 gap-1.5 text-xs font-medium"
>
{uploading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Plus className="size-3.5" />
)}
{uploading ? "正在上传..." : "上传文件"}
</Button>
</div>
</div>
{/* 文件展示区 */}
{isPending ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
) : files.length === 0 ? (
<Card className="border border-dashed py-16 flex flex-col items-center justify-center text-center">
<CardContent className="flex flex-col items-center gap-4">
<div className="p-4 bg-muted rounded-full text-muted-foreground/60">
<Upload className="size-10" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-sm">还没有上传文件</h3>
<p className="text-xs text-muted-foreground max-w-xs">
{debouncedKeyword ? "未搜索到匹配的文件" : "上传您的第一个文件,支持图片、视频、文档和压缩包等格式。"}
</p>
</div>
{!debouncedKeyword && (
<Button onClick={triggerUploadClick} disabled={uploading} size="sm" className="mt-2 text-xs">
选择文件上传
</Button>
)}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{files.map((file) => (
<Card
key={file.id}
className="group border border-border/40 hover:border-border/80 bg-card/60 hover:bg-card/95 transition-all duration-300 shadow-xs hover:shadow-md overflow-hidden relative flex flex-col h-full"
>
{/* 文件预览/图标区 */}
<div className="h-36 bg-muted/30 border-b border-dashed relative flex items-center justify-center overflow-hidden p-2 group-hover:bg-muted/10 transition-colors">
{file.mime_type.startsWith("image/") ? (
<FileImagePreview
fileId={file.id}
alt={file.file_name}
quality="medium"
className="max-h-full max-w-full object-contain rounded-md shadow-xs transition-transform duration-350 group-hover:scale-103"
fallbackClassName="min-h-full w-full rounded-md"
/>
) : (
getFileIcon(file.mime_type)
)}
{/* 访问权限 Badge */}
<div className="absolute top-2 right-2">
{file.access_mode === 0 ? (
<Badge variant="secondary" className="bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-amber-500/20 font-normal">
<Lock className="size-2.5" />
私有
</Badge>
) : (
<Badge variant="secondary" className="bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-sky-500/20 font-normal">
<Globe className="size-2.5" />
公开
</Badge>
)}
</div>
</div>
{/* 文件详情区 */}
<CardContent className="p-4 flex-1 flex flex-col justify-between gap-3">
<div className="space-y-1">
<h4 className="font-semibold text-xs text-foreground truncate select-all" title={file.file_name}>
{file.file_name}
</h4>
<div className="flex items-center justify-between text-[10px] text-muted-foreground font-mono">
<span>{formatFileSize(file.file_size)}</span>
<span className="truncate max-w-[120px]" title={file.mime_type}>{file.mime_type}</span>
</div>
</div>
<div className="flex items-center justify-between pt-1 border-t border-dashed">
<span className="text-[9px] text-muted-foreground/80">{formatDate(file.created_at)}</span>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="size-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
title="下载文件"
onClick={() => handleDownload(file)}
>
<Download className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="size-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="删除文件"
onClick={() => setDeleteTarget(file)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-8 px-3"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="mr-1 size-3.5" />
上一页
</Button>
<span className="text-xs text-muted-foreground font-medium px-2">
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
className="border-dashed text-xs h-8 px-3"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
<ChevronRight className="ml-1 size-3.5" />
</Button>
</div>
)}
{/* 删除确认 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent suppressHydrationWarning>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{" "}
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
吗?删除后此文件将无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}