Merge branch 'dev' of https://github.com/oiov/wr.do into dev
This commit is contained in:
@@ -446,6 +446,7 @@ export default function S3Configs({}: {}) {
|
|||||||
region: "auto",
|
region: "auto",
|
||||||
custom_domain: "",
|
custom_domain: "",
|
||||||
file_size: "26214400",
|
file_size: "26214400",
|
||||||
|
max_storage: "",
|
||||||
public: true,
|
public: true,
|
||||||
});
|
});
|
||||||
setS3Configs(
|
setS3Configs(
|
||||||
@@ -538,6 +539,38 @@ export default function S3Configs({}: {}) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Label>
|
||||||
|
{t("Max Storage")} ({t("Optional")})
|
||||||
|
</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 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{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
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col justify-center space-y-3">
|
<div className="flex flex-col justify-center space-y-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Label>{t("Public")}</Label>
|
<Label>{t("Public")}</Label>
|
||||||
@@ -592,6 +625,7 @@ export default function S3Configs({}: {}) {
|
|||||||
region: "auto",
|
region: "auto",
|
||||||
custom_domain: "",
|
custom_domain: "",
|
||||||
file_size: "26214400",
|
file_size: "26214400",
|
||||||
|
max_storage: "",
|
||||||
public: true,
|
public: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
72
app/api/storage/bucket-usage/route.ts
Normal file
72
app/api/storage/bucket-usage/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
|
||||||
|
import { getBucketStorageUsage } from "@/lib/dto/files";
|
||||||
import { getPlanQuota } from "@/lib/dto/plan";
|
import { getPlanQuota } from "@/lib/dto/plan";
|
||||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||||
import { checkUserStatus } from "@/lib/dto/user";
|
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 });
|
// 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(
|
const R2 = createS3Client(
|
||||||
providerChannel.endpoint,
|
providerChannel.endpoint,
|
||||||
providerChannel.access_key_id,
|
providerChannel.access_key_id,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default async function RootLayout({ children }: RootLayoutProps) {
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<ModalProvider>{children}</ModalProvider>
|
<ModalProvider>{children}</ModalProvider>
|
||||||
<Toaster richColors closeButton />
|
<Toaster richColors closeButton position="bottom-right" />
|
||||||
<TailwindIndicator />
|
<TailwindIndicator />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
@@ -124,6 +124,17 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
|||||||
fetcher,
|
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(() => {
|
useEffect(() => {
|
||||||
if (s3Configs && s3Configs.length > 0) {
|
if (s3Configs && s3Configs.length > 0) {
|
||||||
setCurrentProvider(s3Configs[0]);
|
setCurrentProvider(s3Configs[0]);
|
||||||
@@ -261,11 +272,22 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
|||||||
<ClickableTooltip
|
<ClickableTooltip
|
||||||
content={
|
content={
|
||||||
<div className="w-80">
|
<div className="w-80">
|
||||||
<FileSizeDisplay files={files} plan={plan} t={t} />
|
<FileSizeDisplay
|
||||||
|
files={files}
|
||||||
|
plan={plan}
|
||||||
|
bucketInfo={currentBucketInfo}
|
||||||
|
bucketUsage={bucketUsage}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CircularStorageIndicator files={files} plan={plan} size={36} />
|
<CircularStorageIndicator
|
||||||
|
files={files}
|
||||||
|
plan={plan}
|
||||||
|
bucketUsage={bucketUsage}
|
||||||
|
size={36}
|
||||||
|
/>
|
||||||
</ClickableTooltip>
|
</ClickableTooltip>
|
||||||
)}
|
)}
|
||||||
{/* Bucket Select */}
|
{/* Bucket Select */}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AlertTriangle, CheckCircle, HardDrive } from "lucide-react";
|
import { AlertTriangle, CheckCircle, HardDrive, Folder } from "lucide-react";
|
||||||
|
|
||||||
import { formatFileSize } from "@/lib/utils";
|
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 totalSize = files?.totalSize || 0;
|
||||||
const maxSize = Number(plan?.stMaxTotalSize || 0);
|
const maxSize = Number(plan?.stMaxTotalSize || 0);
|
||||||
const usagePercentage =
|
const usagePercentage =
|
||||||
maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
|
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) => {
|
const getStatusColor = (percentage) => {
|
||||||
if (percentage >= 90) return "text-red-600";
|
if (percentage >= 90) return "text-red-600";
|
||||||
if (percentage >= 70) return "text-yellow-600";
|
if (percentage >= 70) return "text-yellow-600";
|
||||||
@@ -55,8 +62,11 @@ export function FileSizeDisplay({ files, plan, t }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 详细信息 */}
|
{/* Plan级别详细信息 */}
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
{t("usedSpace")}:
|
{t("usedSpace")}:
|
||||||
@@ -83,34 +93,103 @@ export function FileSizeDisplay({ files, plan, t }) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 状态提示 */}
|
{/* 状态提示 */}
|
||||||
<div
|
<div
|
||||||
className={`mt-3 flex items-center gap-2 rounded-md bg-neutral-50 p-2 dark:bg-neutral-800 ${getStatusColor(usagePercentage)}`}
|
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)}`}
|
||||||
>
|
>
|
||||||
{getStatusIcon(usagePercentage)}
|
{getStatusIcon(hasBucketLimit && bucketUsagePercentage > usagePercentage ? bucketUsagePercentage : usagePercentage)}
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{usagePercentage >= 90
|
{(() => {
|
||||||
? t("storageFull")
|
const criticalPercentage = hasBucketLimit && bucketUsagePercentage > usagePercentage ? bucketUsagePercentage : usagePercentage;
|
||||||
: usagePercentage >= 70
|
if (criticalPercentage >= 90) {
|
||||||
? t("storageHigh")
|
return hasBucketLimit && bucketUsagePercentage >= 90 ? t("bucketStorageFull") : t("storageFull");
|
||||||
: t("storageGood")}
|
} else if (criticalPercentage >= 70) {
|
||||||
|
return hasBucketLimit && bucketUsagePercentage >= 70 ? t("bucketStorageHigh") : t("storageHigh");
|
||||||
|
} else {
|
||||||
|
return t("storageGood");
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CircularStorageIndicator({ files, plan, size = 32 }) {
|
export function CircularStorageIndicator({ files, plan, bucketUsage, size = 32 }) {
|
||||||
const totalSize = files?.totalSize || 0;
|
const totalSize = files?.totalSize || 0;
|
||||||
const maxSize = Number(plan?.stMaxTotalSize || 0);
|
const maxSize = Number(plan?.stMaxTotalSize || 0);
|
||||||
const usagePercentage =
|
const usagePercentage =
|
||||||
maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
|
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 radius = (size - 6) / 2;
|
||||||
const circumference = 2 * Math.PI * radius;
|
const circumference = 2 * Math.PI * radius;
|
||||||
const strokeDashoffset =
|
const strokeDashoffset =
|
||||||
circumference - (usagePercentage / 100) * circumference;
|
circumference - (displayPercentage / 100) * circumference;
|
||||||
|
|
||||||
// 根据使用率确定颜色
|
// 根据使用率确定颜色
|
||||||
const getColor = (percentage) => {
|
const getColor = (percentage) => {
|
||||||
@@ -139,7 +218,7 @@ export function CircularStorageIndicator({ files, plan, size = 32 }) {
|
|||||||
cx={size / 2}
|
cx={size / 2}
|
||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
r={radius}
|
r={radius}
|
||||||
stroke={getColor(usagePercentage)}
|
stroke={getColor(displayPercentage)}
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -150,9 +229,9 @@ export function CircularStorageIndicator({ files, plan, size = 32 }) {
|
|||||||
</svg>
|
</svg>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex scale-[.85] items-center justify-center text-xs font-medium"
|
className="absolute inset-0 flex scale-[.85] items-center justify-center text-xs font-medium"
|
||||||
style={{ color: getColor(usagePercentage) }}
|
style={{ color: getColor(displayPercentage) }}
|
||||||
>
|
>
|
||||||
{Math.round(usagePercentage)}%
|
{Math.round(displayPercentage)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -257,27 +257,34 @@ export const FileUploader = ({
|
|||||||
) : (
|
) : (
|
||||||
(file.status === "error" ||
|
(file.status === "error" ||
|
||||||
file.status === "cancelled") && (
|
file.status === "cancelled") && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col 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="flex items-center justify-end gap-2">
|
||||||
<div className="h-2 w-2 rounded-full bg-red-300 dark:bg-red-400"></div>
|
<div className="flex items-center gap-1 rounded-full bg-red-600 px-3 py-1 text-xs text-white dark:bg-red-700">
|
||||||
{t("Aborted")}
|
<div className="h-2 w-2 rounded-full bg-red-300 dark:bg-red-400"></div>
|
||||||
|
{file.status === "cancelled" ? t("Aborted") : t("Failed")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="size-6"
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => retryUpload(file.id)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="size-6"
|
||||||
|
size="icon"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => removeFile(file.id)}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{file.status === "error" && file.error && (
|
||||||
className="size-6"
|
<div className="text-xs text-red-600 dark:text-red-400 text-right">
|
||||||
size="icon"
|
{file.error}
|
||||||
variant="secondary"
|
</div>
|
||||||
onClick={() => retryUpload(file.id)}
|
)}
|
||||||
>
|
|
||||||
<RotateCcw className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="size-6"
|
|
||||||
size="icon"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => removeFile(file.id)}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -146,7 +146,25 @@ export function useFileUpload({ bucketInfo, userId, api }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const data = await response.json();
|
||||||
@@ -331,14 +349,15 @@ export function useFileUpload({ bucketInfo, userId, api }: Props) {
|
|||||||
await Promise.allSettled(uploadPromises);
|
await Promise.allSettled(uploadPromises);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("上传失败:", error);
|
console.error("上传失败:", error);
|
||||||
// 将所有 pending 状态的文件设置为错误状态
|
// 将所有 pending 状态的文件设置为错误状态,并显示具体错误信息
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "上传失败";
|
||||||
setFiles((prev) =>
|
setFiles((prev) =>
|
||||||
prev.map((file) =>
|
prev.map((file) =>
|
||||||
file.status === "pending"
|
file.status === "pending"
|
||||||
? {
|
? {
|
||||||
...file,
|
...file,
|
||||||
status: "error",
|
status: "error",
|
||||||
error: "上传失败",
|
error: errorMessage,
|
||||||
}
|
}
|
||||||
: file,
|
: file,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export async function getUserFileStats(userId: string) {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
totalFiles,
|
totalFiles,
|
||||||
totalSize: totalSize._sum.size || 0,
|
totalSize: storageValueToBytes(totalSize._sum.size || 0),
|
||||||
filesByProvider,
|
filesByProvider,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -359,3 +359,39 @@ export async function cleanupExpiredFiles(days: number = 30) {
|
|||||||
return { success: false, error: "Failed to clean up expired files" };
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface BucketItem {
|
|||||||
prefix?: string;
|
prefix?: string;
|
||||||
file_types?: string;
|
file_types?: string;
|
||||||
file_size?: string;
|
file_size?: string;
|
||||||
|
max_storage?: string; // 存储桶最大存储容量(字节)
|
||||||
region?: string;
|
region?: string;
|
||||||
public: boolean;
|
public: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,11 @@
|
|||||||
"storageFull": "Storage space is almost full",
|
"storageFull": "Storage space is almost full",
|
||||||
"storageHigh": "Storage space usage is high",
|
"storageHigh": "Storage space usage is high",
|
||||||
"storageGood": "Storage space is sufficient",
|
"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",
|
"items": "items",
|
||||||
"Total": "Total",
|
"Total": "Total",
|
||||||
"Configuration Error": "Configuration Error"
|
"Configuration Error": "Configuration Error"
|
||||||
@@ -360,6 +365,7 @@
|
|||||||
"Uploading": "Uploading",
|
"Uploading": "Uploading",
|
||||||
"Completed": "Completed",
|
"Completed": "Completed",
|
||||||
"Aborted": "Aborted",
|
"Aborted": "Aborted",
|
||||||
|
"Failed": "Failed",
|
||||||
"Drop files to upload them to": "Drop files to upload them to",
|
"Drop files to upload them to": "Drop files to upload them to",
|
||||||
"Drag and drop file(s) here": "Drag and drop file(s) here",
|
"Drag and drop file(s) here": "Drag and drop file(s) here",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
@@ -623,6 +629,8 @@
|
|||||||
"Region": "Region",
|
"Region": "Region",
|
||||||
"Prefix": "Prefix",
|
"Prefix": "Prefix",
|
||||||
"Optional": "Optional",
|
"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",
|
"Allowed File Types": "Allowed File Types",
|
||||||
"Public": "Public",
|
"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",
|
"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",
|
||||||
|
|||||||
@@ -214,6 +214,11 @@
|
|||||||
"storageFull": "存储空间即将用完",
|
"storageFull": "存储空间即将用完",
|
||||||
"storageHigh": "存储空间使用较多",
|
"storageHigh": "存储空间使用较多",
|
||||||
"storageGood": "存储空间充足",
|
"storageGood": "存储空间充足",
|
||||||
|
"planQuota": "计划配额",
|
||||||
|
"bucketQuota": "存储桶配额",
|
||||||
|
"bucketCapacity": "存储桶容量",
|
||||||
|
"bucketStorageFull": "存储桶空间即将用完",
|
||||||
|
"bucketStorageHigh": "存储桶空间使用较多",
|
||||||
"items": "条",
|
"items": "条",
|
||||||
"Total": "共",
|
"Total": "共",
|
||||||
"Configuration Error": "配置错误"
|
"Configuration Error": "配置错误"
|
||||||
@@ -360,6 +365,7 @@
|
|||||||
"Uploading": "上传中",
|
"Uploading": "上传中",
|
||||||
"Completed": "已完成",
|
"Completed": "已完成",
|
||||||
"Aborted": "已中止",
|
"Aborted": "已中止",
|
||||||
|
"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": "最大存储容量",
|
||||||
|
"maxStorageTooltip": "设置此存储桶的最大存储容量(字节)。如果不设置,默认使用 Plan 配额的全局限制。",
|
||||||
"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": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶",
|
||||||
|
|||||||
Reference in New Issue
Block a user