refact: bucket storage limit rules
This commit is contained in:
@@ -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) => (
|
||||
<motion.div
|
||||
className="relative grid grid-cols-1 gap-4 rounded-lg border border-dashed border-muted-foreground px-3 pb-3 pt-10 text-neutral-600 dark:text-neutral-400 sm:grid-cols-3"
|
||||
className="relative grid grid-cols-1 gap-4 rounded-lg border border-dashed border-muted-foreground px-3 pb-3 pt-10 text-neutral-600 dark:text-neutral-400 sm:grid-cols-4"
|
||||
key={`bucket-${index2}`}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
@@ -446,7 +446,8 @@ export default function S3Configs({}: {}) {
|
||||
region: "auto",
|
||||
custom_domain: "",
|
||||
file_size: "26214400",
|
||||
max_storage: "",
|
||||
max_storage: "1073741824",
|
||||
max_files: "1000",
|
||||
public: true,
|
||||
});
|
||||
setS3Configs(
|
||||
@@ -539,11 +540,9 @@ export default function S3Configs({}: {}) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label>
|
||||
{t("Max Storage")} ({t("Optional")})
|
||||
</Label>
|
||||
<Label>{t("Max File Size")}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
@@ -557,15 +556,60 @@ export default function S3Configs({}: {}) {
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={bucket.max_storage || ""}
|
||||
value={bucket.file_size}
|
||||
placeholder="26214400"
|
||||
onChange={(e) =>
|
||||
updateBucket(index2, {
|
||||
file_size: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{bucket.file_size && (
|
||||
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
|
||||
≈{formatFileSize(Number(bucket.file_size))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Max File Count")}</Label>
|
||||
<Input
|
||||
value={bucket.max_files}
|
||||
placeholder="1000"
|
||||
onChange={(e) =>
|
||||
updateBucket(index2, {
|
||||
max_files: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label>{t("Max Storage")}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<Icons.help className="size-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64 text-wrap">
|
||||
{t("maxStorageTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={bucket.max_storage}
|
||||
placeholder="10737418240"
|
||||
onChange={(e) =>
|
||||
updateBucket(index2, { max_storage: e.target.value })
|
||||
updateBucket(index2, {
|
||||
max_storage: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{bucket.max_storage && (
|
||||
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
|
||||
≈{(Number(bucket.max_storage) / (1024 * 1024 * 1024)).toFixed(1)}GB
|
||||
≈{formatFileSize(Number(bucket.max_storage))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -600,7 +644,7 @@ export default function S3Configs({}: {}) {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link
|
||||
className="text-sm text-blue-500 hover:underline"
|
||||
href="/docs/developer/cloud-storage#cloudflare-r2"
|
||||
href="/docs/developer/cloud-storage"
|
||||
target="_blank"
|
||||
>
|
||||
{t("How to get the S3 credentials?")}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BucketUsage | null>(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) {
|
||||
/>
|
||||
</div>
|
||||
{/* Storage */}
|
||||
{files && files.totalSize > 0 && plan && (
|
||||
{bucketUsage?.bucket && (
|
||||
<ClickableTooltip
|
||||
content={
|
||||
<div className="w-80">
|
||||
<FileSizeDisplay
|
||||
files={files}
|
||||
plan={plan}
|
||||
bucketInfo={currentBucketInfo}
|
||||
bucketUsage={bucketUsage}
|
||||
t={t}
|
||||
/>
|
||||
<FileSizeDisplay bucketUsage={bucketUsage} t={t} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CircularStorageIndicator
|
||||
files={files}
|
||||
plan={plan}
|
||||
bucketUsage={bucketUsage}
|
||||
size={36}
|
||||
/>
|
||||
<CircularStorageIndicator bucketUsage={bucketUsage} size={36} />
|
||||
</ClickableTooltip>
|
||||
)}
|
||||
{/* Bucket Select */}
|
||||
@@ -332,9 +363,11 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
||||
{!isLoading &&
|
||||
s3Configs &&
|
||||
s3Configs.length > 0 &&
|
||||
currentBucketInfo && (
|
||||
currentBucketInfo &&
|
||||
bucketUsage && (
|
||||
<FileUploader
|
||||
bucketInfo={currentBucketInfo}
|
||||
bucketUsage={bucketUsage}
|
||||
action="/api/storage"
|
||||
plan={plan}
|
||||
userId={user.id}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import React from "react";
|
||||
import { AlertTriangle, CheckCircle, HardDrive, Folder } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle, HardDrive } from "lucide-react";
|
||||
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { formatFileSize, nFormatter } from "@/lib/utils";
|
||||
|
||||
export function FileSizeDisplay({ files, plan, bucketInfo, bucketUsage, t }) {
|
||||
const totalSize = files?.totalSize || 0;
|
||||
const maxSize = Number(plan?.stMaxTotalSize || 0);
|
||||
export function FileSizeDisplay({ bucketUsage, t }) {
|
||||
// 从统一的 bucketUsage 中获取数据
|
||||
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 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 <CheckCircle className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const getStatusText = (percentage) => {
|
||||
if (percentage >= 90) return t("storageFull");
|
||||
if (percentage >= 70) return t("storageHigh");
|
||||
return t("storageGood");
|
||||
};
|
||||
|
||||
// 处理无数据或异常情况
|
||||
if (!bucketUsage || maxSize <= 0) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-neutral-600 dark:text-neutral-200" />
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
{t("storageUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-md bg-neutral-50 p-3 dark:bg-neutral-800">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t("storageDataUnavailable")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md p-4">
|
||||
{/* 标题 */}
|
||||
@@ -48,7 +68,7 @@ export function FileSizeDisplay({ files, plan, bucketInfo, bucketUsage, t }) {
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-300">
|
||||
{t("used")}
|
||||
{bucketName ? `${bucketName}` : t("storageQuota")}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-300">
|
||||
{usagePercentage.toFixed(1)}%
|
||||
@@ -62,134 +82,67 @@ export function FileSizeDisplay({ files, plan, bucketInfo, bucketUsage, t }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan级别详细信息 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
<span>{t("planQuota")}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{/* 详细信息 */}
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-neutral-600 dark:text-neutral-300">
|
||||
{t("usedSpace")}:
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
<span className="font-medium">
|
||||
{formatFileSize(totalSize, { precision: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-neutral-600 dark:text-neutral-300">
|
||||
{t("totalCapacity")}:
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
<span className="font-medium">
|
||||
{formatFileSize(maxSize, { precision: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-neutral-600 dark:text-neutral-300">
|
||||
{t("availableSpace")}:
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
<span className="font-medium">
|
||||
{formatFileSize(maxSize - totalSize, { precision: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 存储桶级别信息 */}
|
||||
{hasBucketLimit && (
|
||||
<>
|
||||
<div className="my-3 border-t pt-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Folder className="h-4 w-4 text-neutral-600 dark:text-neutral-200" />
|
||||
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{t("bucketQuota")} - {bucketInfo?.bucket}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-300">
|
||||
{t("used")}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-300">
|
||||
{bucketUsagePercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2 h-1.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-600">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${getProgressColor(bucketUsagePercentage)}`}
|
||||
style={{ width: `${bucketUsagePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{t("usedSpace")}:
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{formatFileSize(bucketTotalSize, { precision: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{t("bucketCapacity")}:
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{formatFileSize(bucketMaxSize, { precision: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{t("availableSpace")}:
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{formatFileSize(bucketMaxSize - bucketTotalSize, { precision: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{bucketUsage?.usage?.totalFiles !== undefined && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-neutral-600 dark:text-neutral-300">
|
||||
{t("totalFiles")}:
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{bucketUsage.usage.totalFiles.toLocaleString()} /{" "}
|
||||
{nFormatter(bucketUsage.limits.maxFiles)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态提示 */}
|
||||
<div
|
||||
className={`mt-3 flex items-center gap-2 rounded-md bg-neutral-50 p-2 dark:bg-neutral-800 ${getStatusColor(hasBucketLimit && bucketUsagePercentage > 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)}
|
||||
<span className="text-xs">
|
||||
{(() => {
|
||||
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");
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
{getStatusIcon(usagePercentage)}
|
||||
<span className="text-xs">{getStatusText(usagePercentage)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="relative flex items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-700"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
-
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex cursor-pointer items-center"
|
||||
@@ -218,7 +185,7 @@ export function CircularStorageIndicator({ files, plan, bucketUsage, size = 32 }
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={getColor(displayPercentage)}
|
||||
stroke={getColor(usagePercentage)}
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
@@ -229,9 +196,9 @@ export function CircularStorageIndicator({ files, plan, bucketUsage, size = 32 }
|
||||
</svg>
|
||||
<div
|
||||
className="absolute inset-0 flex scale-[.85] items-center justify-center text-xs font-medium"
|
||||
style={{ color: getColor(displayPercentage) }}
|
||||
style={{ color: getColor(usagePercentage) }}
|
||||
>
|
||||
{Math.round(displayPercentage)}%
|
||||
{Math.round(usagePercentage)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 = ({
|
||||
</div>
|
||||
<Badge className="text-xs">
|
||||
{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,
|
||||
})}
|
||||
</Badge>
|
||||
@@ -261,7 +262,9 @@ export const FileUploader = ({
|
||||
<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")}
|
||||
{file.status === "cancelled"
|
||||
? t("Aborted")
|
||||
: t("Failed")}
|
||||
</div>
|
||||
<Button
|
||||
className="size-6"
|
||||
@@ -281,7 +284,7 @@ export const FileUploader = ({
|
||||
</Button>
|
||||
</div>
|
||||
{file.status === "error" && file.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 text-right">
|
||||
<div className="text-right text-xs text-red-600 dark:text-red-400">
|
||||
{file.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -407,11 +407,10 @@ export function PlanForm({
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
|
||||
<div className="relative grid-cols-1 gap-2 rounded-md border bg-neutral-50 px-3 pb-3 pt-8 dark:bg-neutral-900 md:grid md:grid-cols-2">
|
||||
{/* <div className="relative grid-cols-1 gap-2 rounded-md border bg-neutral-50 px-3 pb-3 pt-8 dark:bg-neutral-900 md:grid md:grid-cols-2">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
{t("Storage Service")}
|
||||
</h2>
|
||||
{/* Max File Size - stMaxFileSize */}
|
||||
<FormSectionColumns title={t("Max File Size")} required>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Record-Limit">
|
||||
@@ -445,7 +444,6 @@ export function PlanForm({
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
{/* Max File Size - stMaxTotalSize */}
|
||||
<FormSectionColumns title={t("Max Total Size")} required>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Record-Limit">
|
||||
@@ -480,7 +478,7 @@ export function PlanForm({
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex justify-end gap-3">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "可选",
|
||||
|
||||
@@ -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://<account_id>.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://<account_id>.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 存储桶配置'
|
||||
);
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user