Files
wr.do/components/file/upload.tsx
weiruchenai1 509ad15652 feat: 实现分层存储配额管理和UI优化
🆕 新增功能:
- 添加存储桶容量限制配置功能,支持按存储桶设置最大容量
- 新增存储桶使用情况API端点 (/api/storage/bucket-usage)
- 实现Plan配额和存储桶配额的分层显示

🎨 界面优化:
- 存储使用情况面板支持双层配额显示
- 上传错误提示改为右对齐显示
- Toast通知位置改为右下角
- 为存储桶配置添加帮助提示

🔧 功能改进:
- 优化上传前容量检查逻辑
- 改进错误信息显示,使用中文提示
- 智能显示更严格的配额限制
- 完善国际化支持

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 12:47:23 +08:00

355 lines
16 KiB
TypeScript

import React, { useEffect, useRef, useState } from "react";
import { Play, RotateCcw, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { cn, formatFileSize } from "@/lib/utils";
import { useFileUpload } from "@/hooks/use-file-upload";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerPortal,
} from "@/components/ui/drawer";
import { CopyButton } from "@/components/shared/copy-button";
import { Icons } from "@/components/shared/icons";
import { BucketInfo, StorageUserPlan } from ".";
import DragAndDrop from "./drag-and-drop";
export const FileUploader = ({
bucketInfo,
action,
plan,
userId,
onRefresh,
}: {
bucketInfo: BucketInfo;
action: string;
userId: string;
plan?: StorageUserPlan;
onRefresh: () => void;
}) => {
const t = useTranslations("Components");
const [isOpen, setIsOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File[] | null>(null);
const {
files,
isUploading,
stats,
addFiles,
removeFile,
cancelUpload,
retryUpload,
startUpload,
clearAll,
} = useFileUpload({ api: `${action}/s3/upload`, bucketInfo, userId });
useEffect(() => {
if (selectedFile) {
const outOfLimitSizeFiles = selectedFile.some(
(file) => file.size > Number(plan?.stMaxFileSize ?? 0),
);
const notOutOfLimitSizeFiles = selectedFile.filter(
(file) => file.size <= Number(plan?.stMaxFileSize ?? 0),
);
addFiles(notOutOfLimitSizeFiles);
if (outOfLimitSizeFiles) {
toast.warning(
`File size exceeds the limit of ${formatFileSize(
Number(plan?.stMaxFileSize ?? 0),
)}`,
);
}
}
}, [selectedFile]);
return (
<>
<Button
className="flex h-9 items-center gap-1 text-nowrap"
onClick={() => setIsOpen(true)}
>
<Icons.cloudUpload className="size-5" />
{t("Upload Files")}
</Button>
{isOpen && (
<Drawer open={isOpen} direction="right" onOpenChange={setIsOpen}>
<DrawerPortal>
<DrawerContent className="h-screen w-full overflow-y-auto rounded-none sm:max-w-xl">
<div className="mb-2 flex items-center justify-between">
<p className="mx-4 text-lg font-bold">{t("Upload Files")}</p>
<DrawerClose asChild>
<Button variant="ghost">
<Icons.close className="size-4" />
</Button>
</DrawerClose>
</div>
<div className="flex items-center justify-between px-4">
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
<div className="truncate">{bucketInfo.provider_name}</div>
<Icons.arrowRight className="size-3" />
<div className="font-medium text-blue-600 dark:text-blue-400">
{bucketInfo.bucket}
</div>
</div>
<Badge className="text-xs">
{t("Limit")}:{" "}
{formatFileSize(Number(plan?.stMaxFileSize || "0"), {
precision: 0,
})}{" "}
/{" "}
{formatFileSize(Number(plan?.stMaxTotalSize || "0"), {
precision: 0,
})}
</Badge>
</div>
<div className="space-y-3 p-4">
<DragAndDrop
setSelectedFile={setSelectedFile}
bucketInfo={bucketInfo}
/>
{/* 统计信息 */}
{stats.total > 0 && (
<div className="flex items-center justify-between gap-3">
<h2 className="font-semibold">{t("Upload List")}</h2>
<Badge className="flex items-center gap-1">
{stats.completed}
<span>/</span>
{stats.total}
</Badge>
</div>
// <div className="mt-6 rounded-lg bg-gray-50 p-4">
// <div className="flex flex-wrap gap-4 text-sm">
// <span className="text-gray-600">总计: {stats.total}</span>
// <span className="text-gray-600">等待: {stats.pending}</span>
// <span className="text-blue-600">
// 上传中: {stats.uploading}
// </span>
// <span className="text-green-600">
// 完成: {stats.completed}
// </span>
// <span className="text-red-600">失败: {stats.error}</span>
// <span className="text-orange-600">
// 取消: {stats.cancelled}
// </span>
// </div>
// </div>
)}
{/* 文件列表 */}
{files.length > 0 && (
<div className="space-y-2 rounded-lg">
{files.some((file) => file.status === "uploading") && (
<div className="flex items-center gap-1 rounded-md border border-dashed bg-yellow-100 p-2 text-sm text-muted-foreground dark:bg-neutral-600">
<Icons.info className="size-4" />
{t(
"Do not close the window until the upload is complete",
)}
.
</div>
)}
{/* 文件列表 */}
{files.map((file) => (
<div
key={file.id}
className={cn(
"relative overflow-hidden rounded-lg border",
file.status === "uploading" &&
"border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800",
file.status === "completed" &&
"border-green-300 bg-green-50 dark:border-green-600 dark:bg-green-900/20",
file.status === "error" &&
"border-red-300 bg-red-50 dark:border-red-600 dark:bg-red-900/20",
"backdrop-blur-sm transition-all duration-300",
file.status === "cancelled" &&
"border-yellow-300 bg-yellow-50 dark:border-yellow-600 dark:bg-yellow-900/20",
)}
>
{/* 主进度条背景 */}
{file.status === "uploading" && (
<div className="absolute inset-0 overflow-hidden rounded-lg">
<div
className="h-full bg-gray-200 transition-all duration-500 ease-out dark:bg-gray-700"
style={{ width: `${file.progress}%` }}
/>
</div>
)}
{/* 内容区域 */}
<div className="relative z-10 px-4 py-3">
<div
className={cn(
"flex justify-between gap-3",
file.status === "uploading"
? "items-start"
: "items-center",
)}
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
{file.originalName}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(file.file.size)}
</p>
</div>
{/* 状态指示器和操作按钮 */}
<div className="flex items-center gap-2">
{file.status === "pending" ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full bg-neutral-600 px-3 py-1 text-xs text-white dark:bg-neutral-700">
<div className="h-2 w-2 rounded-full bg-neutral-300 dark:bg-neutral-400"></div>
{t("Pending Upload")}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeFile(file.id)}
className="size-[30px] p-1.5 text-red-600 transition-colors hover:text-red-800"
>
<X className="size-4" />
</Button>
</div>
) : file.status === "uploading" ? (
<div className="flex items-center gap-2">
<span className="text-sm">
{file.progress}%
</span>
<div className="flex items-center gap-1 rounded-full bg-gray-700 px-3 py-1 text-xs text-white dark:bg-gray-600">
<div className="h-2 w-2 animate-pulse rounded-full bg-gray-300 dark:bg-gray-400"></div>
{t("Uploading")}
</div>
<Button
className="size-6"
size="icon"
variant="destructive"
onClick={() => cancelUpload(file.id)}
title="取消上传"
>
<Icons.close className="size-4" />
</Button>
</div>
) : file.status === "completed" ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full bg-green-600 px-3 py-1 text-xs text-white dark:bg-green-700">
<div className="h-2 w-2 rounded-full bg-green-300 dark:bg-green-400"></div>
{t("Completed")}
</div>
<CopyButton
value={`${bucketInfo.custom_domain}/${file.fileName}`}
/>
<Button
size="sm"
variant="ghost"
onClick={() => removeFile(file.id)}
className="size-[30px] p-1.5 text-red-600 transition-colors hover:text-red-800"
>
<X className="size-4" />
</Button>
</div>
) : (
(file.status === "error" ||
file.status === "cancelled") && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-1 rounded-full bg-red-600 px-3 py-1 text-xs text-white dark:bg-red-700">
<div className="h-2 w-2 rounded-full bg-red-300 dark:bg-red-400"></div>
{file.status === "cancelled" ? t("Aborted") : t("Failed")}
</div>
<Button
className="size-6"
size="icon"
variant="secondary"
onClick={() => retryUpload(file.id)}
>
<RotateCcw className="size-4" />
</Button>
<Button
className="size-6"
size="icon"
variant="destructive"
onClick={() => removeFile(file.id)}
>
<X className="size-4" />
</Button>
</div>
{file.status === "error" && file.error && (
<div className="text-xs text-red-600 dark:text-red-400 text-right">
{file.error}
</div>
)}
</div>
)
)}
</div>
</div>
{/* 进度条 */}
{file.status === "uploading" && (
<div className="mt-3">
<div className="relative h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="absolute left-0 top-0 h-full bg-gray-800 transition-all duration-300 ease-out dark:bg-gray-300"
style={{ width: `${file.progress}%` }}
/>
<div
className="absolute top-0 h-full w-16 bg-gradient-to-r from-transparent via-white/30 to-transparent transition-all duration-1000 ease-out dark:via-white/20"
style={{
left: `${Math.max(0, file.progress - 8)}%`,
opacity:
file.progress > 0 && file.progress < 100
? 1
: 0,
}}
/>
</div>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
<DrawerFooter className="sticky bottom-0 flex flex-row items-center justify-between gap-2 backdrop-blur-md">
<DrawerClose asChild>
<Button variant="outline">{t("Cancel")}</Button>
</DrawerClose>
{files.length > 0 && (
<div className="flex items-center gap-2">
<Button
onClick={startUpload}
disabled={isUploading || stats.pending === 0}
className="flex items-center gap-2 rounded-md bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<Play className="h-4 w-4" />
{t("Start Upload")}
</Button>
<Button
variant="destructive"
onClick={() => {
clearAll();
setSelectedFile(null);
}}
>
{t("Clear")}
</Button>
</div>
)}
</DrawerFooter>
</DrawerContent>
</DrawerPortal>
</Drawer>
)}
</>
);
};