diff --git a/app/(protected)/admin/system/s3-list.tsx b/app/(protected)/admin/system/s3-list.tsx index c4b982f..6b29ee5 100644 --- a/app/(protected)/admin/system/s3-list.tsx +++ b/app/(protected)/admin/system/s3-list.tsx @@ -446,6 +446,7 @@ export default function S3Configs({}: {}) { region: "auto", custom_domain: "", file_size: "26214400", + max_storage: "", public: true, }); setS3Configs( @@ -538,6 +539,38 @@ export default function S3Configs({}: {}) { /> +
+
+ + + + + + + + {t("maxStorageTooltip")} + + + +
+
+ + updateBucket(index2, { max_storage: e.target.value }) + } + /> + {bucket.max_storage && ( + + ≈{(Number(bucket.max_storage) / (1024 * 1024 * 1024)).toFixed(1)}GB + + )} +
+
+
@@ -592,6 +625,7 @@ export default function S3Configs({}: {}) { region: "auto", custom_domain: "", file_size: "26214400", + max_storage: "", public: true, }, ], diff --git a/app/api/storage/bucket-usage/route.ts b/app/api/storage/bucket-usage/route.ts new file mode 100644 index 0000000..2996f6c --- /dev/null +++ b/app/api/storage/bucket-usage/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getBucketStorageUsage } from "@/lib/dto/files"; +import { getMultipleConfigs } from "@/lib/dto/system-config"; +import { checkUserStatus } from "@/lib/dto/user"; +import { getCurrentUser } from "@/lib/session"; + +export async function GET(request: NextRequest) { + try { + const user = checkUserStatus(await getCurrentUser()); + if (user instanceof Response) return user; + + const url = new URL(request.url); + const bucket = url.searchParams.get("bucket"); + const provider = url.searchParams.get("provider"); + + if (!bucket || !provider) { + return NextResponse.json("Missing bucket or provider parameters", { + status: 400, + }); + } + + // 获取存储桶配置 + const configs = await getMultipleConfigs(["s3_config_list"]); + if (!configs || !configs.s3_config_list) { + return NextResponse.json("Invalid S3 configs", { + status: 400, + }); + } + + const providerConfig = configs.s3_config_list.find( + (c) => c.provider_name === provider, + ); + if (!providerConfig) { + return NextResponse.json("Provider does not exist", { + status: 400, + }); + } + + const bucketConfig = providerConfig.buckets?.find( + (b) => b.bucket === bucket, + ); + if (!bucketConfig) { + return NextResponse.json("Bucket does not exist", { + status: 400, + }); + } + + // 获取存储桶使用情况 + const bucketUsage = await getBucketStorageUsage(bucket, provider); + if (!bucketUsage.success) { + return NextResponse.json("Failed to get bucket usage", { + status: 500, + }); + } + + return NextResponse.json({ + bucket: bucket, + provider: provider, + usage: { + totalSize: bucketUsage.data.totalSize, + totalFiles: bucketUsage.data.totalFiles, + }, + limits: { + maxStorage: bucketConfig.max_storage ? Number(bucketConfig.max_storage) : null, + }, + }); + } catch (error) { + console.error("Error getting bucket usage:", error); + return NextResponse.json({ error: "Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/storage/s3/upload/route.ts b/app/api/storage/s3/upload/route.ts index 98e8ec1..c4258fc 100644 --- a/app/api/storage/s3/upload/route.ts +++ b/app/api/storage/s3/upload/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { getBucketStorageUsage } from "@/lib/dto/files"; import { getPlanQuota } from "@/lib/dto/plan"; import { getMultipleConfigs } from "@/lib/dto/system-config"; import { checkUserStatus } from "@/lib/dto/user"; @@ -60,6 +61,27 @@ export async function POST(request: NextRequest) { // }); // if (limit) return Response.json(limit.statusText, { status: limit.status }); + // 检查存储桶容量限制 + const bucketConfig = buckets.find((b) => b.bucket === bucket); + const totalUploadSize = files.reduce((sum, file) => sum + Number(file.size), 0); + + if (bucketConfig?.max_storage) { + const bucketUsage = await getBucketStorageUsage(bucket, provider); + if (bucketUsage.success && bucketUsage.data) { + const currentUsage = bucketUsage.data.totalSize; + const maxStorage = Number(bucketConfig.max_storage); + + if (currentUsage + totalUploadSize > maxStorage) { + const remainingSpace = maxStorage - currentUsage; + const remainingSpaceGB = (remainingSpace / (1024 * 1024 * 1024)).toFixed(2); + return Response.json( + `存储桶容量不足!剩余 ${remainingSpaceGB}GB,请更换存储桶`, + { status: 403 } + ); + } + } + } + const R2 = createS3Client( providerChannel.endpoint, providerChannel.access_key_id, diff --git a/app/layout.tsx b/app/layout.tsx index 5e3e093..d2a2fcc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -43,7 +43,7 @@ export default async function RootLayout({ children }: RootLayoutProps) { disableTransitionOnChange > {children} - + diff --git a/components/file/index.tsx b/components/file/index.tsx index 01d1457..8702f39 100644 --- a/components/file/index.tsx +++ b/components/file/index.tsx @@ -124,6 +124,17 @@ export default function UserFileManager({ user, action }: FileListProps) { fetcher, ); + const { data: bucketUsage } = useSWR( + currentBucketInfo.bucket && currentBucketInfo.provider_name + ? `/api/storage/bucket-usage?bucket=${currentBucketInfo.bucket}&provider=${currentBucketInfo.provider_name}` + : null, + fetcher, + { + revalidateOnFocus: false, + refreshInterval: 30000, // 每30秒更新一次 + }, + ); + useEffect(() => { if (s3Configs && s3Configs.length > 0) { setCurrentProvider(s3Configs[0]); @@ -261,11 +272,22 @@ export default function UserFileManager({ user, action }: FileListProps) { - +
} > - + )} {/* Bucket Select */} diff --git a/components/file/storage-size.tsx b/components/file/storage-size.tsx index 53b5d08..e3e2306 100644 --- a/components/file/storage-size.tsx +++ b/components/file/storage-size.tsx @@ -1,14 +1,21 @@ import React from "react"; -import { AlertTriangle, CheckCircle, HardDrive } from "lucide-react"; +import { AlertTriangle, CheckCircle, HardDrive, Folder } from "lucide-react"; import { formatFileSize } from "@/lib/utils"; -export function FileSizeDisplay({ files, plan, t }) { +export function FileSizeDisplay({ files, plan, bucketInfo, bucketUsage, t }) { const totalSize = files?.totalSize || 0; const maxSize = Number(plan?.stMaxTotalSize || 0); const usagePercentage = maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0; + // 存储桶级别的配额信息 + const bucketTotalSize = bucketUsage?.usage?.totalSize || 0; + const bucketMaxSize = bucketUsage?.limits?.maxStorage || 0; + const bucketUsagePercentage = + bucketMaxSize > 0 ? Math.min((bucketTotalSize / bucketMaxSize) * 100, 100) : 0; + const hasBucketLimit = bucketMaxSize > 0; + const getStatusColor = (percentage) => { if (percentage >= 90) return "text-red-600"; if (percentage >= 70) return "text-yellow-600"; @@ -55,8 +62,11 @@ export function FileSizeDisplay({ files, plan, t }) {
- {/* 详细信息 */} + {/* Plan级别详细信息 */}
+
+ {t("planQuota")} +
{t("usedSpace")}: @@ -83,34 +93,103 @@ export function FileSizeDisplay({ files, plan, t }) {
+ {/* 存储桶级别信息 */} + {hasBucketLimit && ( + <> +
+
+ + + {t("bucketQuota")} - {bucketInfo?.bucket} + +
+
+ + {t("used")} + + + {bucketUsagePercentage.toFixed(1)}% + +
+
+
+
+
+
+ + {t("usedSpace")}: + + + {formatFileSize(bucketTotalSize, { precision: 2 })} + +
+
+ + {t("bucketCapacity")}: + + + {formatFileSize(bucketMaxSize, { precision: 2 })} + +
+
+ + {t("availableSpace")}: + + + {formatFileSize(bucketMaxSize - bucketTotalSize, { precision: 2 })} + +
+
+
+ + )} + {/* 状态提示 */}
usagePercentage ? bucketUsagePercentage : usagePercentage)}`} > - {getStatusIcon(usagePercentage)} + {getStatusIcon(hasBucketLimit && bucketUsagePercentage > usagePercentage ? bucketUsagePercentage : usagePercentage)} - {usagePercentage >= 90 - ? t("storageFull") - : usagePercentage >= 70 - ? t("storageHigh") - : t("storageGood")} + {(() => { + const criticalPercentage = hasBucketLimit && bucketUsagePercentage > usagePercentage ? bucketUsagePercentage : usagePercentage; + if (criticalPercentage >= 90) { + return hasBucketLimit && bucketUsagePercentage >= 90 ? t("bucketStorageFull") : t("storageFull"); + } else if (criticalPercentage >= 70) { + return hasBucketLimit && bucketUsagePercentage >= 70 ? t("bucketStorageHigh") : t("storageHigh"); + } else { + return t("storageGood"); + } + })()}
); } -export function CircularStorageIndicator({ files, plan, size = 32 }) { +export function CircularStorageIndicator({ files, plan, bucketUsage, size = 32 }) { const totalSize = files?.totalSize || 0; const maxSize = Number(plan?.stMaxTotalSize || 0); const usagePercentage = maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0; + // 存储桶级别的配额信息 + const bucketTotalSize = bucketUsage?.usage?.totalSize || 0; + const bucketMaxSize = bucketUsage?.limits?.maxStorage || 0; + const bucketUsagePercentage = + bucketMaxSize > 0 ? Math.min((bucketTotalSize / bucketMaxSize) * 100, 100) : 0; + const hasBucketLimit = bucketMaxSize > 0; + + // 使用更严格的限制来显示 + const displayPercentage = hasBucketLimit && bucketUsagePercentage > usagePercentage ? bucketUsagePercentage : usagePercentage; + // 圆形参数 const radius = (size - 6) / 2; const circumference = 2 * Math.PI * radius; const strokeDashoffset = - circumference - (usagePercentage / 100) * circumference; + circumference - (displayPercentage / 100) * circumference; // 根据使用率确定颜色 const getColor = (percentage) => { @@ -139,7 +218,7 @@ export function CircularStorageIndicator({ files, plan, size = 32 }) { cx={size / 2} cy={size / 2} r={radius} - stroke={getColor(usagePercentage)} + stroke={getColor(displayPercentage)} strokeWidth="3" fill="none" strokeLinecap="round" @@ -150,9 +229,9 @@ export function CircularStorageIndicator({ files, plan, size = 32 }) {
- {Math.round(usagePercentage)}% + {Math.round(displayPercentage)}%
); diff --git a/components/file/upload.tsx b/components/file/upload.tsx index ac977a6..77f355f 100644 --- a/components/file/upload.tsx +++ b/components/file/upload.tsx @@ -257,27 +257,34 @@ export const FileUploader = ({ ) : ( (file.status === "error" || file.status === "cancelled") && ( -
-
-
- {t("Aborted")} +
+
+
+
+ {file.status === "cancelled" ? t("Aborted") : t("Failed")} +
+ +
- - + {file.status === "error" && file.error && ( +
+ {file.error} +
+ )}
) )} diff --git a/hooks/use-file-upload.ts b/hooks/use-file-upload.ts index c0302fd..f938484 100644 --- a/hooks/use-file-upload.ts +++ b/hooks/use-file-upload.ts @@ -146,7 +146,25 @@ export function useFileUpload({ bucketInfo, userId, api }: Props) { }); if (!response.ok) { - throw new Error("获取预签名 URL 失败"); + // 尝试获取后端返回的具体错误信息 + let errorMessage = "获取预签名 URL 失败"; + try { + const errorText = await response.text(); + if (errorText) { + // 如果返回的是JSON格式的错误信息,尝试解析 + try { + const errorData = JSON.parse(errorText); + errorMessage = errorData.message || errorData.error || errorText; + } catch { + // 如果不是JSON,直接使用文本内容 + errorMessage = errorText; + } + } + } catch { + // 如果无法读取响应内容,使用默认错误信息 + errorMessage = `上传失败 (${response.status})`; + } + throw new Error(errorMessage); } const data = await response.json(); @@ -331,14 +349,15 @@ export function useFileUpload({ bucketInfo, userId, api }: Props) { await Promise.allSettled(uploadPromises); } catch (error) { console.error("上传失败:", error); - // 将所有 pending 状态的文件设置为错误状态 + // 将所有 pending 状态的文件设置为错误状态,并显示具体错误信息 + const errorMessage = error instanceof Error ? error.message : "上传失败"; setFiles((prev) => prev.map((file) => file.status === "pending" ? { ...file, status: "error", - error: "上传失败", + error: errorMessage, } : file, ), diff --git a/lib/dto/files.ts b/lib/dto/files.ts index df7b65c..4b2a44c 100644 --- a/lib/dto/files.ts +++ b/lib/dto/files.ts @@ -276,7 +276,7 @@ export async function getUserFileStats(userId: string) { success: true, data: { totalFiles, - totalSize: totalSize._sum.size || 0, + totalSize: storageValueToBytes(totalSize._sum.size || 0), filesByProvider, }, }; @@ -359,3 +359,39 @@ export async function cleanupExpiredFiles(days: number = 30) { return { success: false, error: "Failed to clean up expired files" }; } } + +// 获取特定存储桶的使用量统计 +export async function getBucketStorageUsage( + bucket: string, + providerName: string, +): Promise< + | { success: true; data: { totalSize: number; totalFiles: number } } + | { success: false; error: string } +> { + try { + const result = await prisma.userFile.aggregate({ + where: { + bucket, + providerName, + status: 1, + }, + _sum: { + size: true, + }, + _count: { + id: true, + }, + }); + + return { + success: true, + data: { + totalSize: storageValueToBytes(result._sum.size || 0), + totalFiles: result._count.id || 0, + }, + }; + } catch (error) { + console.error("Failed to get bucket storage usage:", error); + return { success: false, error: "Failed to get bucket storage usage" }; + } +} diff --git a/lib/r2.ts b/lib/r2.ts index 3aa3b98..dbf54ce 100644 --- a/lib/r2.ts +++ b/lib/r2.ts @@ -34,6 +34,7 @@ export interface BucketItem { prefix?: string; file_types?: string; file_size?: string; + max_storage?: string; // 存储桶最大存储容量(字节) region?: string; public: boolean; } diff --git a/locales/en.json b/locales/en.json index 73ec486..4bbf049 100644 --- a/locales/en.json +++ b/locales/en.json @@ -214,6 +214,11 @@ "storageFull": "Storage space is almost full", "storageHigh": "Storage space usage is high", "storageGood": "Storage space is sufficient", + "planQuota": "Plan Quota", + "bucketQuota": "Bucket Quota", + "bucketCapacity": "Bucket Capacity", + "bucketStorageFull": "Bucket storage is almost full", + "bucketStorageHigh": "Bucket storage usage is high", "items": "items", "Total": "Total", "Configuration Error": "Configuration Error" @@ -360,6 +365,7 @@ "Uploading": "Uploading", "Completed": "Completed", "Aborted": "Aborted", + "Failed": "Failed", "Drop files to upload them to": "Drop files to upload them to", "Drag and drop file(s) here": "Drag and drop file(s) here", "or": "or", @@ -623,6 +629,8 @@ "Region": "Region", "Prefix": "Prefix", "Optional": "Optional", + "Max Storage": "Max Storage", + "maxStorageTooltip": "Set maximum storage capacity for this bucket (in bytes). If not set, uses plan quota global limit.", "Allowed File Types": "Allowed File Types", "Public": "Public", "Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket": "Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket", diff --git a/locales/zh.json b/locales/zh.json index 237aa1e..5c87543 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -214,6 +214,11 @@ "storageFull": "存储空间即将用完", "storageHigh": "存储空间使用较多", "storageGood": "存储空间充足", + "planQuota": "计划配额", + "bucketQuota": "存储桶配额", + "bucketCapacity": "存储桶容量", + "bucketStorageFull": "存储桶空间即将用完", + "bucketStorageHigh": "存储桶空间使用较多", "items": "条", "Total": "共", "Configuration Error": "配置错误" @@ -360,6 +365,7 @@ "Uploading": "上传中", "Completed": "已完成", "Aborted": "已中止", + "Failed": "失败", "Drop files to upload them to": "将文件上传到", "Drag and drop file(s) here": "将文件拖到此处上传", "or": "或", @@ -623,6 +629,8 @@ "Region": "存储桶区域", "Prefix": "前缀", "Optional": "可选", + "Max Storage": "最大存储容量", + "maxStorageTooltip": "设置此存储桶的最大存储容量(字节)。如果不设置,默认使用 Plan 配额的全局限制。", "Allowed File Types": "允许的文件类型", "Public": "公开", "Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶",