From d25de8d0f7d6d36453c6d7ae9dcb11a737317279 Mon Sep 17 00:00:00 2001 From: weiruchenai1 <1627333583@qq.com> Date: Mon, 21 Jul 2025 00:24:44 +0800 Subject: [PATCH 1/3] feat: improve file upload error handling and add Chinese localization --- app/(protected)/admin/system/s3-list.tsx | 22 +++++++++++ app/api/storage/s3/upload/route.ts | 21 +++++++++++ components/file/upload.tsx | 47 ++++++++++++++---------- hooks/use-file-upload.ts | 25 +++++++++++-- lib/dto/files.ts | 38 ++++++++++++++++++- lib/r2.ts | 1 + locales/en.json | 2 + locales/zh.json | 2 + 8 files changed, 134 insertions(+), 24 deletions(-) diff --git a/app/(protected)/admin/system/s3-list.tsx b/app/(protected)/admin/system/s3-list.tsx index 2e9dee0..49fdd4c 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,26 @@ export default function S3Configs({}: {}) { /> +
+ +
+ + updateBucket(index2, { max_storage: e.target.value }) + } + /> + {bucket.max_storage && ( + + ≈{(Number(bucket.max_storage) / (1024 * 1024 * 1024)).toFixed(1)}GB + + )} +
+
+
@@ -592,6 +613,7 @@ export default function S3Configs({}: {}) { region: "auto", custom_domain: "", file_size: "26214400", + max_storage: "", public: true, }, ], diff --git a/app/api/storage/s3/upload/route.ts b/app/api/storage/s3/upload/route.ts index 7846743..fb86fb2 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,26 @@ export async function POST(request: NextRequest) { }); if (limit) return Response.json(limit.statusText, { status: limit.status }); + // 检查存储桶容量限制 + const bucketConfig = buckets.find((b) => b.bucket === bucket); + 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); + const totalUploadSize = files.reduce((sum, file) => sum + Number(file.size), 0); + + if (currentUsage + totalUploadSize > maxStorage) { + const remainingSpace = Math.max(0, 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/components/file/upload.tsx b/components/file/upload.tsx index ac977a6..6c0bf65 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 a093ad7..0941ac7 100644 --- a/lib/dto/files.ts +++ b/lib/dto/files.ts @@ -274,7 +274,7 @@ export async function getUserFileStats(userId: string) { success: true, data: { totalFiles, - totalSize: totalSize._sum.size || 0, + totalSize: storageValueToBytes(totalSize._sum.size || 0), filesByProvider, }, }; @@ -357,3 +357,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..efea390 100644 --- a/locales/en.json +++ b/locales/en.json @@ -360,6 +360,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 +624,7 @@ "Region": "Region", "Prefix": "Prefix", "Optional": "Optional", + "Max Storage": "Max Storage", "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..0a79d0a 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -360,6 +360,7 @@ "Uploading": "上传中", "Completed": "已完成", "Aborted": "已中止", + "Failed": "失败", "Drop files to upload them to": "将文件上传到", "Drag and drop file(s) here": "将文件拖到此处上传", "or": "或", @@ -623,6 +624,7 @@ "Region": "存储桶区域", "Prefix": "前缀", "Optional": "可选", + "Max Storage": "最大存储容量", "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": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶", From 509ad15652e6489bf2504a709069495b26229f6c Mon Sep 17 00:00:00 2001 From: weiruchenai1 <1627333583@qq.com> Date: Tue, 22 Jul 2025 12:47:23 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=88=86?= =?UTF-8?q?=E5=B1=82=E5=AD=98=E5=82=A8=E9=85=8D=E9=A2=9D=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=92=8CUI=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🆕 新增功能: - 添加存储桶容量限制配置功能,支持按存储桶设置最大容量 - 新增存储桶使用情况API端点 (/api/storage/bucket-usage) - 实现Plan配额和存储桶配额的分层显示 🎨 界面优化: - 存储使用情况面板支持双层配额显示 - 上传错误提示改为右对齐显示 - Toast通知位置改为右下角 - 为存储桶配置添加帮助提示 🔧 功能改进: - 优化上传前容量检查逻辑 - 改进错误信息显示,使用中文提示 - 智能显示更严格的配额限制 - 完善国际化支持 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/(protected)/admin/system/s3-list.tsx | 18 +++- app/api/storage/bucket-usage/route.ts | 72 +++++++++++++++ app/api/storage/s3/upload/route.ts | 7 +- app/layout.tsx | 2 +- components/file/index.tsx | 26 +++++- components/file/storage-size.tsx | 109 +++++++++++++++++++---- components/file/upload.tsx | 4 +- locales/en.json | 6 ++ locales/zh.json | 6 ++ 9 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 app/api/storage/bucket-usage/route.ts diff --git a/app/(protected)/admin/system/s3-list.tsx b/app/(protected)/admin/system/s3-list.tsx index 49fdd4c..3d14090 100644 --- a/app/(protected)/admin/system/s3-list.tsx +++ b/app/(protected)/admin/system/s3-list.tsx @@ -540,9 +540,21 @@ export default function S3Configs({}: {}) {
- +
+ + + + + + + + {t("maxStorageTooltip")} + + + +
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 fb86fb2..6525479 100644 --- a/app/api/storage/s3/upload/route.ts +++ b/app/api/storage/s3/upload/route.ts @@ -63,18 +63,19 @@ export async function POST(request: NextRequest) { // 检查存储桶容量限制 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); - const totalUploadSize = files.reduce((sum, file) => sum + Number(file.size), 0); if (currentUsage + totalUploadSize > maxStorage) { - const remainingSpace = Math.max(0, maxStorage - currentUsage); + const remainingSpace = maxStorage - currentUsage; const remainingSpaceGB = (remainingSpace / (1024 * 1024 * 1024)).toFixed(2); return Response.json( - `上传容量超限,剩余空间:${remainingSpaceGB}GB,请更换存储桶或联系管理员`, + `存储桶容量不足!剩余 ${remainingSpaceGB}GB,请更换存储桶`, { status: 403 } ); } 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 6c0bf65..77f355f 100644 --- a/components/file/upload.tsx +++ b/components/file/upload.tsx @@ -258,7 +258,7 @@ export const FileUploader = ({ (file.status === "error" || file.status === "cancelled") && (
-
+
{file.status === "cancelled" ? t("Aborted") : t("Failed")} @@ -281,7 +281,7 @@ export const FileUploader = ({
{file.status === "error" && file.error && ( -
+
{file.error}
)} diff --git a/locales/en.json b/locales/en.json index efea390..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" @@ -625,6 +630,7 @@ "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 0a79d0a..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": "配置错误" @@ -625,6 +630,7 @@ "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": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶", From 28367890a13ae9e857ab4dc49e1cca3a227c5a7e Mon Sep 17 00:00:00 2001 From: oiov Date: Tue, 22 Jul 2025 16:58:24 +0800 Subject: [PATCH 3/3] refact: bucket storage limit rules --- app/(protected)/admin/system/s3-list.tsx | 68 +++++-- app/api/storage/admin/s3/files/route.ts | 2 +- app/api/storage/bucket-usage/route.ts | 72 ------- app/api/storage/s3/files/route.ts | 2 +- app/api/storage/s3/upload/route.ts | 60 +++--- components/file/index.tsx | 111 ++++++---- components/file/storage-size.tsx | 191 ++++++++---------- components/file/upload.tsx | 19 +- components/forms/plan-form.tsx | 6 +- lib/dto/files.ts | 10 +- lib/{r2.ts => s3.ts} | 5 +- lib/validations/record.ts | 4 +- locales/en.json | 6 +- locales/zh.json | 4 + .../migrations/20250714192022/migration.sql | 2 +- public/sw.js.map | 2 +- 16 files changed, 282 insertions(+), 282 deletions(-) delete mode 100644 app/api/storage/bucket-usage/route.ts rename lib/{r2.ts => s3.ts} (95%) diff --git a/app/(protected)/admin/system/s3-list.tsx b/app/(protected)/admin/system/s3-list.tsx index 6b29ee5..5da6a49 100644 --- a/app/(protected)/admin/system/s3-list.tsx +++ b/app/(protected)/admin/system/s3-list.tsx @@ -7,8 +7,8 @@ import { useTranslations } from "next-intl"; import { toast } from "sonner"; import useSWR from "swr"; -import { BucketItem, CloudStorageCredentials } from "@/lib/r2"; -import { cn, fetcher } from "@/lib/utils"; +import { BucketItem, CloudStorageCredentials } from "@/lib/s3"; +import { cn, fetcher, formatFileSize } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -365,7 +365,7 @@ export default function S3Configs({}: {}) { {/* buckets */} {config.buckets.map((bucket, index2) => (
-
+
- + @@ -557,15 +556,60 @@ export default function S3Configs({}: {}) {
+ updateBucket(index2, { + file_size: e.target.value, + }) + } + /> + {bucket.file_size && ( + + ≈{formatFileSize(Number(bucket.file_size))} + + )} +
+
+
+ + + updateBucket(index2, { + max_files: e.target.value, + }) + } + /> +
+
+
+ + + + + + + + {t("maxStorageTooltip")} + + + +
+
+ - updateBucket(index2, { max_storage: e.target.value }) + updateBucket(index2, { + max_storage: e.target.value, + }) } /> {bucket.max_storage && ( - ≈{(Number(bucket.max_storage) / (1024 * 1024 * 1024)).toFixed(1)}GB + ≈{formatFileSize(Number(bucket.max_storage))} )}
@@ -600,7 +644,7 @@ export default function S3Configs({}: {}) {
{t("How to get the S3 credentials?")} diff --git a/app/api/storage/admin/s3/files/route.ts b/app/api/storage/admin/s3/files/route.ts index 003bafb..f59ea22 100644 --- a/app/api/storage/admin/s3/files/route.ts +++ b/app/api/storage/admin/s3/files/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files"; import { getMultipleConfigs } from "@/lib/dto/system-config"; import { checkUserStatus } from "@/lib/dto/user"; -import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/r2"; +import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/s3"; import { getCurrentUser } from "@/lib/session"; export async function GET(req: NextRequest) { diff --git a/app/api/storage/bucket-usage/route.ts b/app/api/storage/bucket-usage/route.ts deleted file mode 100644 index 2996f6c..0000000 --- a/app/api/storage/bucket-usage/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -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/files/route.ts b/app/api/storage/s3/files/route.ts index 0c4022d..6387771 100644 --- a/app/api/storage/s3/files/route.ts +++ b/app/api/storage/s3/files/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files"; import { getMultipleConfigs } from "@/lib/dto/system-config"; import { checkUserStatus } from "@/lib/dto/user"; -import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/r2"; +import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/s3"; import { getCurrentUser } from "@/lib/session"; export async function GET(req: NextRequest) { diff --git a/app/api/storage/s3/upload/route.ts b/app/api/storage/s3/upload/route.ts index c4258fc..0977017 100644 --- a/app/api/storage/s3/upload/route.ts +++ b/app/api/storage/s3/upload/route.ts @@ -3,12 +3,10 @@ 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"; -import { createS3Client } from "@/lib/r2"; +import { createS3Client } from "@/lib/s3"; import { getCurrentUser } from "@/lib/session"; -import { restrictByTimeRange } from "@/lib/team"; import { generateFileKey } from "@/lib/utils"; export async function POST(request: NextRequest) { @@ -45,38 +43,52 @@ export async function POST(request: NextRequest) { }); } - const plan = await getPlanQuota(user.team!); - for (const file of files) { - if (Number(file.size) > Number(plan.stMaxFileSize)) { - return Response.json(`File (${file.name}) size limit exceeded`, { - status: 400, - }); + const bucketConfig = buckets.find((b) => b.bucket === bucket); + if (bucketConfig?.file_size) { + for (const file of files) { + if (Number(file.size) > Number(bucketConfig?.file_size)) { + return Response.json(`File size limit exceeded`, { + status: 400, + }); + } } } - // const limit = await restrictByTimeRange({ - // model: "userFile", - // userId: user.id, - // limit: Number(plan.stMaxFileCount), - // rangeType: "month", - // }); - // if (limit) return Response.json(limit.statusText, { status: limit.status }); + // else { + // const plan = await getPlanQuota(user.team!); + // for (const file of files) { + // if (Number(file.size) > Number(plan.stMaxFileSize)) { + // return Response.json(`File (${file.name}) size limit exceeded`, { + // status: 400, + // }); + // } + // } + // } // 检查存储桶容量限制 - const bucketConfig = buckets.find((b) => b.bucket === bucket); - const totalUploadSize = files.reduce((sum, file) => sum + Number(file.size), 0); - + const totalUploadSize = files.reduce( + (sum, file) => sum + Number(file.size), + 0, + ); + if (bucketConfig?.max_storage) { - const bucketUsage = await getBucketStorageUsage(bucket, provider); + const bucketUsage = await getBucketStorageUsage( + bucket, + provider, + user.id, + ); 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); + const remainingSpaceGB = ( + remainingSpace / + (1024 * 1024 * 1024) + ).toFixed(2); return Response.json( - `存储桶容量不足!剩余 ${remainingSpaceGB}GB,请更换存储桶`, - { status: 403 } + `Bucket storage limit exceeded. Remaining space: ${remainingSpaceGB} GB.`, + { status: 403 }, ); } } diff --git a/components/file/index.tsx b/components/file/index.tsx index 8702f39..71ff404 100644 --- a/components/file/index.tsx +++ b/components/file/index.tsx @@ -7,7 +7,7 @@ import { toast } from "sonner"; import useSWR, { useSWRConfig } from "swr"; import { UserFileData } from "@/lib/dto/files"; -import { BucketItem, ClientStorageCredentials } from "@/lib/r2"; +import { BucketItem, ClientStorageCredentials } from "@/lib/s3"; import { cn, fetcher } from "@/lib/utils"; import { useMediaQuery } from "@/hooks/use-media-query"; import { @@ -56,12 +56,28 @@ export type DisplayType = "List" | "Grid"; export interface FileListData { total: number; totalSize: number; + totalFiles: number; list: UserFileData[]; } +export interface BucketUsage { + bucket: string; + provider: string; + usage: { + totalSize: number; + totalFiles: number; + }; + limits: { + maxStorage: number; + maxFiles: number; + maxSingleFileSize: number; + }; +} + export interface StorageUserPlan { stMaxTotalSize: string; stMaxFileSize: string; + stMaxFileCount: number; } export default function UserFileManager({ user, action }: FileListProps) { @@ -94,6 +110,8 @@ export default function UserFileManager({ user, action }: FileListProps) { status: "1", }); + const [bucketUsage, setBucketUsage] = useState(null); + // const isAdmin = action.includes("/admin"); const { mutate } = useSWRConfig(); @@ -124,17 +142,6 @@ 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]); @@ -146,10 +153,42 @@ export default function UserFileManager({ user, action }: FileListProps) { channel: s3Configs[0].channel, provider_name: s3Configs[0].provider_name, public: s3Configs[0].buckets[0].public, + file_size: s3Configs[0].buckets[0].file_size, + max_files: s3Configs[0].buckets[0].max_files, + max_storage: s3Configs[0].buckets[0].max_storage, }); } }, [s3Configs]); + useEffect(() => { + if ( + files && + currentBucketInfo.bucket && + currentBucketInfo.provider_name && + plan + ) { + setBucketUsage({ + bucket: currentBucketInfo.bucket, + provider: currentBucketInfo.provider_name, + usage: { + totalSize: files.totalSize, + totalFiles: files.totalFiles, + }, + limits: { + maxStorage: currentBucketInfo.max_storage + ? Number(currentBucketInfo.max_storage) + : Number(plan.stMaxTotalSize), + maxFiles: currentBucketInfo.max_files + ? Number(currentBucketInfo.max_files) + : Number(plan.stMaxFileCount), + maxSingleFileSize: currentBucketInfo.file_size + ? Number(currentBucketInfo.file_size) + : Number(plan.stMaxFileSize), + }, + }); + } + }, [files, currentBucketInfo, plan]); + const handleRefresh = () => { setSelectedFiles([]); mutate( @@ -162,18 +201,21 @@ export default function UserFileManager({ user, action }: FileListProps) { provider: ClientStorageCredentials, bucket: string, ) => { - console.log(provider, bucket); - - setCurrentBucketInfo({ - bucket: bucket, - custom_domain: provider.buckets.find((b) => b.bucket === bucket) - ?.custom_domain, - prefix: provider.buckets.find((b) => b.bucket === bucket)?.prefix, - platform: provider.platform, - channel: provider.channel, - provider_name: provider.provider_name, - public: true, - }); + const new_bucket = provider.buckets.find((b) => b.bucket === bucket); + if (new_bucket) { + setCurrentBucketInfo({ + bucket: bucket, + custom_domain: new_bucket.custom_domain, + prefix: new_bucket.prefix, + platform: provider.platform, + channel: provider.channel, + provider_name: provider.provider_name, + public: true, + file_size: new_bucket.file_size, + max_files: new_bucket.max_files, + max_storage: new_bucket.max_storage, + }); + } }; const handleSelectAllFiles = () => { @@ -268,26 +310,15 @@ export default function UserFileManager({ user, action }: FileListProps) { />
{/* Storage */} - {files && files.totalSize > 0 && plan && ( + {bucketUsage?.bucket && ( - +
} > - + )} {/* Bucket Select */} @@ -332,9 +363,11 @@ export default function UserFileManager({ user, action }: FileListProps) { {!isLoading && s3Configs && s3Configs.length > 0 && - currentBucketInfo && ( + currentBucketInfo && + bucketUsage && ( 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 bucketName = bucketUsage?.bucket || ""; const getStatusColor = (percentage) => { if (percentage >= 90) return "text-red-600"; @@ -34,6 +29,31 @@ export function FileSizeDisplay({ files, plan, bucketInfo, bucketUsage, t }) { return ; }; + const getStatusText = (percentage) => { + if (percentage >= 90) return t("storageFull"); + if (percentage >= 70) return t("storageHigh"); + return t("storageGood"); + }; + + // 处理无数据或异常情况 + if (!bucketUsage || maxSize <= 0) { + return ( +
+
+ +

+ {t("storageUsage")} +

+
+
+ + {t("storageDataUnavailable")} + +
+
+ ); + } + return (
{/* 标题 */} @@ -48,7 +68,7 @@ export function FileSizeDisplay({ files, plan, bucketInfo, bucketUsage, t }) {
- {t("used")} + {bucketName ? `${bucketName}` : t("storageQuota")} {usagePercentage.toFixed(1)}% @@ -62,134 +82,67 @@ export function FileSizeDisplay({ files, plan, bucketInfo, bucketUsage, t }) {
- {/* Plan级别详细信息 */} -
-
- {t("planQuota")} -
-
- + {/* 详细信息 */} +
+
+ {t("usedSpace")}: - + {formatFileSize(totalSize, { precision: 2 })}
-
- +
+ {t("totalCapacity")}: - + {formatFileSize(maxSize, { precision: 2 })}
-
- +
+ {t("availableSpace")}: - + {formatFileSize(maxSize - totalSize, { precision: 2 })}
-
- - {/* 存储桶级别信息 */} - {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 })} - -
-
+ {bucketUsage?.usage?.totalFiles !== undefined && ( +
+ + {t("totalFiles")}: + + + {bucketUsage.usage.totalFiles.toLocaleString()} /{" "} + {nFormatter(bucketUsage.limits.maxFiles)} +
- - )} + )} +
{/* 状态提示 */}
usagePercentage ? bucketUsagePercentage : usagePercentage)}`} + className={`flex items-center gap-2 rounded-md bg-neutral-50 p-2 dark:bg-neutral-800 ${getStatusColor(usagePercentage)}`} > - {getStatusIcon(hasBucketLimit && bucketUsagePercentage > usagePercentage ? bucketUsagePercentage : usagePercentage)} - - {(() => { - 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"); - } - })()} - + {getStatusIcon(usagePercentage)} + {getStatusText(usagePercentage)}
); } -export function CircularStorageIndicator({ files, plan, bucketUsage, size = 32 }) { - const totalSize = files?.totalSize || 0; - const maxSize = Number(plan?.stMaxTotalSize || 0); +export function CircularStorageIndicator({ bucketUsage, size = 32 }) { + const totalSize = bucketUsage?.usage?.totalSize || 0; + const maxSize = bucketUsage?.limits?.maxStorage || 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 - (displayPercentage / 100) * circumference; + circumference - (usagePercentage / 100) * circumference; // 根据使用率确定颜色 const getColor = (percentage) => { @@ -198,6 +151,20 @@ export function CircularStorageIndicator({ files, plan, bucketUsage, size = 32 } return "#3b82f6"; // blue-500 }; + // 处理无数据情况 + if (!bucketUsage || maxSize <= 0) { + return ( +
+ + - + +
+ ); + } + return (
- {Math.round(displayPercentage)}% + {Math.round(usagePercentage)}%
); diff --git a/components/file/upload.tsx b/components/file/upload.tsx index 77f355f..a54b411 100644 --- a/components/file/upload.tsx +++ b/components/file/upload.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Play, RotateCcw, X } from "lucide-react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; @@ -17,7 +17,7 @@ import { import { CopyButton } from "@/components/shared/copy-button"; import { Icons } from "@/components/shared/icons"; -import { BucketInfo, StorageUserPlan } from "."; +import { BucketInfo, BucketUsage, StorageUserPlan } from "."; import DragAndDrop from "./drag-and-drop"; export const FileUploader = ({ @@ -25,12 +25,13 @@ export const FileUploader = ({ action, plan, userId, - onRefresh, + bucketUsage, }: { bucketInfo: BucketInfo; action: string; userId: string; plan?: StorageUserPlan; + bucketUsage: BucketUsage; onRefresh: () => void; }) => { const t = useTranslations("Components"); @@ -98,11 +99,11 @@ export const FileUploader = ({
{t("Limit")}:{" "} - {formatFileSize(Number(plan?.stMaxFileSize || "0"), { - precision: 0, + {formatFileSize(bucketUsage.limits.maxSingleFileSize, { + precision: 1, })}{" "} /{" "} - {formatFileSize(Number(plan?.stMaxTotalSize || "0"), { + {formatFileSize(bucketUsage.limits.maxStorage, { precision: 0, })} @@ -261,7 +262,9 @@ export const FileUploader = ({
- {file.status === "cancelled" ? t("Aborted") : t("Failed")} + {file.status === "cancelled" + ? t("Aborted") + : t("Failed")}
{file.status === "error" && file.error && ( -
+
{file.error}
)} diff --git a/components/forms/plan-form.tsx b/components/forms/plan-form.tsx index 305d724..81a995c 100644 --- a/components/forms/plan-form.tsx +++ b/components/forms/plan-form.tsx @@ -407,11 +407,10 @@ export function PlanForm({
-
+ {/*

{t("Storage Service")}

- {/* Max File Size - stMaxFileSize */}
- {/* Max File Size - stMaxTotalSize */}
-
+
*/} {/* Action buttons */}
diff --git a/lib/dto/files.ts b/lib/dto/files.ts index 4b2a44c..df3fddd 100644 --- a/lib/dto/files.ts +++ b/lib/dto/files.ts @@ -159,18 +159,22 @@ export async function getUserFiles(options: QueryUserFileOptions = {}) { prisma.userFile.count({ where }), prisma.userFile.aggregate({ where: { - // bucket, - // providerName, + bucket, + providerName, status: 1, ...(userId && { userId }), }, _sum: { size: true }, + _count: { + id: true, + }, }), ]); return { total, totalSize: storageValueToBytes(totalSize._sum.size || 0), + totalFiles: totalSize._count.id || 0, list: files, }; } catch (error) { @@ -364,6 +368,7 @@ export async function cleanupExpiredFiles(days: number = 30) { export async function getBucketStorageUsage( bucket: string, providerName: string, + userId?: string, ): Promise< | { success: true; data: { totalSize: number; totalFiles: number } } | { success: false; error: string } @@ -371,6 +376,7 @@ export async function getBucketStorageUsage( try { const result = await prisma.userFile.aggregate({ where: { + ...(userId && { userId }), bucket, providerName, status: 1, diff --git a/lib/r2.ts b/lib/s3.ts similarity index 95% rename from lib/r2.ts rename to lib/s3.ts index dbf54ce..adda389 100644 --- a/lib/r2.ts +++ b/lib/s3.ts @@ -32,8 +32,9 @@ export interface BucketItem { bucket: string; custom_domain?: string; prefix?: string; - file_types?: string; - file_size?: string; + file_types?: string; // 允许上传的文件类型 + file_size?: string; // 单个文件限制(字节) + max_files?: string; // 存储桶最大文件数量 max_storage?: string; // 存储桶最大存储容量(字节) region?: string; public: boolean; diff --git a/lib/validations/record.ts b/lib/validations/record.ts index 757f75d..e1e7f0c 100644 --- a/lib/validations/record.ts +++ b/lib/validations/record.ts @@ -12,8 +12,8 @@ export const createRecordSchema = z.object({ .string() .regex(/^[a-zA-Z0-9-_]+$/, "Invalid characters") .min(1) - .max(32), - content: z.string().min(1).max(32), + .max(64), + content: z.string().min(1).max(1024), ttl: z.number().min(1).max(36000).default(1), proxied: z.boolean().default(false), comment: z.string().optional(), diff --git a/locales/en.json b/locales/en.json index 4bbf049..6fecc0e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -211,10 +211,13 @@ "usedSpace": "Used Space", "totalCapacity": "Total Capacity", "availableSpace": "Available Space", + "totalFiles": "Total Files", + "storageDataUnavailable": "Storage data is unavailable", "storageFull": "Storage space is almost full", "storageHigh": "Storage space usage is high", "storageGood": "Storage space is sufficient", "planQuota": "Plan Quota", + "planUsagePercentage": "Usage Percentage", "bucketQuota": "Bucket Quota", "bucketCapacity": "Bucket Capacity", "bucketStorageFull": "Bucket storage is almost full", @@ -640,6 +643,7 @@ "Unique": "Unique", "Add Provider": "Add Provider", "{length} Buckets": "{length} Buckets", - "Save Modifications": "Save Modifications" + "Save Modifications": "Save Modifications", + "Max File Count": "Max File Count" } } diff --git a/locales/zh.json b/locales/zh.json index 5c87543..9bd9c9d 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -211,10 +211,13 @@ "usedSpace": "已使用空间", "totalCapacity": "总容量", "availableSpace": "剩余空间", + "totalFiles": "总文件数", + "storageDataUnavailable": "无数据", "storageFull": "存储空间即将用完", "storageHigh": "存储空间使用较多", "storageGood": "存储空间充足", "planQuota": "计划配额", + "planUsagePercentage": "使用率", "bucketQuota": "存储桶配额", "bucketCapacity": "存储桶容量", "bucketStorageFull": "存储桶空间即将用完", @@ -626,6 +629,7 @@ "Bucket Name": "存储桶名称", "Public Domain": "公开域名或自定义域名", "Max File Size": "上传文件大小限制", + "Max File Count": "文件数量限制", "Region": "存储桶区域", "Prefix": "前缀", "Optional": "可选", diff --git a/prisma/migrations/20250714192022/migration.sql b/prisma/migrations/20250714192022/migration.sql index a4e29db..a3ef8c5 100644 --- a/prisma/migrations/20250714192022/migration.sql +++ b/prisma/migrations/20250714192022/migration.sql @@ -9,7 +9,7 @@ INSERT INTO "system_configs" VALUES ( 's3_config_list', - '[{"enabled":true,"platform":"cloudflare","channel":"r2","provider_name":"Cloudflare R2","account_id":"","access_key_id":"","secret_access_key":"","endpoint":"https://.r2.cloudflarestorage.com","buckets":[{"bucket":"","prefix":"","file_types":"","region":"auto","custom_domain":"","file_size":"26214400","public":true}]},{"enabled":false,"platform":"tencent","channel":"cos","provider_name":"腾讯云 COS","endpoint":"","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"","public":true}]}]', + '[{"enabled":true,"platform":"cloudflare","channel":"r2","provider_name":"Cloudflare R2","account_id":"","access_key_id":"","secret_access_key":"","endpoint":"https://.r2.cloudflarestorage.com","buckets":[{"bucket":"","prefix":"","file_types":"","region":"auto","custom_domain":"","file_size":"26214400","max_storage":"1073741824","max_files":"1000","public":true}]},{"enabled":false,"platform":"tencent","channel":"cos","provider_name":"腾讯云 COS","endpoint":"","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","max_storage":"1073741824","max_files":"1000","region":"","public":true}]}]', 'OBJECT', 'R2 存储桶配置' ); \ No newline at end of file diff --git a/public/sw.js.map b/public/sw.js.map index 5e7ac94..88022f8 100644 --- a/public/sw.js.map +++ b/public/sw.js.map @@ -1 +1 @@ -{"version":3,"file":"sw.js","sources":["../../../../../../private/var/folders/9b/3qmyp8zd2xvdspdrp149fyg00000gn/T/9ab3eac97b468a12fb965afe827f5191/sw.js"],"sourcesContent":["import {registerRoute as workbox_routing_registerRoute} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-routing@6.6.0/node_modules/workbox-routing/registerRoute.mjs';\nimport {NetworkFirst as workbox_strategies_NetworkFirst} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkFirst.mjs';\nimport {NetworkOnly as workbox_strategies_NetworkOnly} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkOnly.mjs';\nimport {clientsClaim as workbox_core_clientsClaim} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-core@6.6.0/node_modules/workbox-core/clientsClaim.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\nimportScripts(\n \n);\n\n\n\n\n\n\n\nself.skipWaiting();\n\nworkbox_core_clientsClaim();\n\n\n\nworkbox_routing_registerRoute(\"/\", new workbox_strategies_NetworkFirst({ \"cacheName\":\"start-url\", plugins: [{ cacheWillUpdate: async ({ request, response, event, state }) => { if (response && response.type === 'opaqueredirect') { return new Response(response.body, { status: 200, statusText: 'OK', headers: response.headers }) } return response } }] }), 'GET');\nworkbox_routing_registerRoute(/.*/i, new workbox_strategies_NetworkOnly({ \"cacheName\":\"dev\", plugins: [] }), 'GET');\n\n\n\n\n"],"names":["importScripts","self","skipWaiting","workbox_core_clientsClaim","workbox_routing_registerRoute","workbox_strategies_NetworkFirst","plugins","cacheWillUpdate","request","response","event","state","type","Response","body","status","statusText","headers","workbox_strategies_NetworkOnly"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"sw.js","sources":["../../../../../../private/var/folders/9b/3qmyp8zd2xvdspdrp149fyg00000gn/T/3660dc403c2d3ab798169bbe82eb20c0/sw.js"],"sourcesContent":["import {registerRoute as workbox_routing_registerRoute} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-routing@6.6.0/node_modules/workbox-routing/registerRoute.mjs';\nimport {NetworkFirst as workbox_strategies_NetworkFirst} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkFirst.mjs';\nimport {NetworkOnly as workbox_strategies_NetworkOnly} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkOnly.mjs';\nimport {clientsClaim as workbox_core_clientsClaim} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-core@6.6.0/node_modules/workbox-core/clientsClaim.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\nimportScripts(\n \n);\n\n\n\n\n\n\n\nself.skipWaiting();\n\nworkbox_core_clientsClaim();\n\n\n\nworkbox_routing_registerRoute(\"/\", new workbox_strategies_NetworkFirst({ \"cacheName\":\"start-url\", plugins: [{ cacheWillUpdate: async ({ request, response, event, state }) => { if (response && response.type === 'opaqueredirect') { return new Response(response.body, { status: 200, statusText: 'OK', headers: response.headers }) } return response } }] }), 'GET');\nworkbox_routing_registerRoute(/.*/i, new workbox_strategies_NetworkOnly({ \"cacheName\":\"dev\", plugins: [] }), 'GET');\n\n\n\n\n"],"names":["importScripts","self","skipWaiting","workbox_core_clientsClaim","workbox_routing_registerRoute","workbox_strategies_NetworkFirst","plugins","cacheWillUpdate","request","response","event","state","type","Response","body","status","statusText","headers","workbox_strategies_NetworkOnly"],"mappings":"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"} \ No newline at end of file