@@ -5,8 +5,8 @@ export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records"
|
||||
heading="Cloud Storage"
|
||||
text="List and manage cloud storage"
|
||||
/>
|
||||
<Skeleton className="h-[58px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { CloudCog } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
@@ -63,7 +62,12 @@ export default function S3Configs({}: {}) {
|
||||
channel: "cos",
|
||||
},
|
||||
{ label: "Ali OSS", value: "Ali OSS", platform: "ali", channel: "oss" },
|
||||
{ label: "Minio", value: "Minio", platform: "minio", channel: "s3" },
|
||||
{
|
||||
label: "Custom Provider",
|
||||
value: "Custom Provider",
|
||||
platform: "custom",
|
||||
channel: "cp",
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -442,6 +446,7 @@ export default function S3Configs({}: {}) {
|
||||
region: "auto",
|
||||
custom_domain: "",
|
||||
file_size: "26214400",
|
||||
max_storage: "",
|
||||
public: true,
|
||||
});
|
||||
setS3Configs(
|
||||
@@ -534,6 +539,38 @@ export default function S3Configs({}: {}) {
|
||||
/>
|
||||
</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 items-center gap-1">
|
||||
<Label>{t("Public")}</Label>
|
||||
@@ -588,6 +625,7 @@ export default function S3Configs({}: {}) {
|
||||
region: "auto",
|
||||
custom_domain: "",
|
||||
file_size: "26214400",
|
||||
max_storage: "",
|
||||
public: true,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,8 +5,8 @@ export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records"
|
||||
heading="Cloud Storage"
|
||||
text="List and manage cloud storage"
|
||||
/>
|
||||
<Skeleton className="h-[58px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
|
||||
@@ -33,12 +33,6 @@ export async function GET(req: NextRequest) {
|
||||
}))
|
||||
.filter((c) => c.buckets.length > 0);
|
||||
|
||||
if (processedList.length === 0) {
|
||||
return NextResponse.json("No buckets found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(processedList);
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ export async function DELETE(request: NextRequest) {
|
||||
await softDeleteUserFiles(ids);
|
||||
return NextResponse.json({ message: "File deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
return NextResponse.json("Error deleting file", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
import { getBucketStorageUsage } from "@/lib/dto/files";
|
||||
import { getPlanQuota } from "@/lib/dto/plan";
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
@@ -60,6 +61,27 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
if (limit) return Response.json(limit.statusText, { status: limit.status });
|
||||
|
||||
// 检查存储桶容量限制
|
||||
const bucketConfig = buckets.find((b) => b.bucket === bucket);
|
||||
const totalUploadSize = files.reduce((sum, file) => sum + Number(file.size), 0);
|
||||
|
||||
if (bucketConfig?.max_storage) {
|
||||
const bucketUsage = await getBucketStorageUsage(bucket, provider);
|
||||
if (bucketUsage.success && bucketUsage.data) {
|
||||
const currentUsage = bucketUsage.data.totalSize;
|
||||
const maxStorage = Number(bucketConfig.max_storage);
|
||||
|
||||
if (currentUsage + totalUploadSize > maxStorage) {
|
||||
const remainingSpace = maxStorage - currentUsage;
|
||||
const remainingSpaceGB = (remainingSpace / (1024 * 1024 * 1024)).toFixed(2);
|
||||
return Response.json(
|
||||
`存储桶容量不足!剩余 ${remainingSpaceGB}GB,请更换存储桶`,
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const R2 = createS3Client(
|
||||
providerChannel.endpoint,
|
||||
providerChannel.access_key_id,
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export default async function RootLayout({ children }: RootLayoutProps) {
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ModalProvider>{children}</ModalProvider>
|
||||
<Toaster richColors closeButton />
|
||||
<Toaster richColors closeButton position="bottom-right" />
|
||||
<TailwindIndicator />
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
|
||||
@@ -240,26 +240,28 @@ export default function UserFileList({
|
||||
</Link>
|
||||
<CopyButton className="size-6" value={getFileUrl(file.path)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.code className="size-3 flex-shrink-0" />
|
||||
<p className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800">
|
||||
{`<img src="${getFileUrl(file.path)}" alt="${file.name}">${getFileUrl(file.path)}</img>`}
|
||||
</p>
|
||||
<CopyButton
|
||||
className="size-6"
|
||||
value={`<img src="${getFileUrl(file.path)}" alt="${file.name}">${getFileUrl(file.path)}</img>`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.type className="size-3 flex-shrink-0" />
|
||||
<p className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800">
|
||||
{`})`}
|
||||
{`[${file.name}](${getFileUrl(file.path)})`}
|
||||
</p>
|
||||
<CopyButton
|
||||
className="size-6"
|
||||
value={`})`}
|
||||
value={`[${file.name}](${getFileUrl(file.path)})`}
|
||||
/>
|
||||
</div>
|
||||
{file.mimeType.startsWith("image/") && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.code className="size-3 flex-shrink-0" />
|
||||
<p className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800">
|
||||
{`<img src="${getFileUrl(file.path)}" alt="${file.name}">${getFileUrl(file.path)}</img>`}
|
||||
</p>
|
||||
<CopyButton
|
||||
className="size-6"
|
||||
value={`<img src="${getFileUrl(file.path)}" alt="${file.name}">${getFileUrl(file.path)}</img>`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -355,10 +357,10 @@ export default function UserFileList({
|
||||
<ClickableTooltip
|
||||
className="cursor-pointer truncate"
|
||||
content={
|
||||
<>
|
||||
<div className="p-2">
|
||||
<p>{file.user.name}</p>
|
||||
<p>{file.user.email}</p>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{file.user.name ?? file.user.email}
|
||||
|
||||
+43
-18
@@ -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) {
|
||||
<ClickableTooltip
|
||||
content={
|
||||
<div className="w-80">
|
||||
<FileSizeDisplay files={files} plan={plan} t={t} />
|
||||
<FileSizeDisplay
|
||||
files={files}
|
||||
plan={plan}
|
||||
bucketInfo={currentBucketInfo}
|
||||
bucketUsage={bucketUsage}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CircularStorageIndicator files={files} plan={plan} size={36} />
|
||||
<CircularStorageIndicator
|
||||
files={files}
|
||||
plan={plan}
|
||||
bucketUsage={bucketUsage}
|
||||
size={36}
|
||||
/>
|
||||
</ClickableTooltip>
|
||||
)}
|
||||
{/* Bucket Select */}
|
||||
@@ -288,7 +310,7 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
||||
<SelectValue placeholder="Select a bucket" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{s3Configs.map((provider) => (
|
||||
{s3Configs.map((provider, index) => (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{provider.provider_name}</SelectLabel>
|
||||
{provider.buckets?.map((item) => (
|
||||
@@ -299,7 +321,7 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
||||
{item.bucket}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
{index !== s3Configs.length - 1 && <SelectSeparator />}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -415,25 +437,28 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && !s3Configs && !currentBucketInfo && (
|
||||
<EmptyPlaceholder className="col-span-full mt-8 shadow-none">
|
||||
<EmptyPlaceholder.Icon name="storage" />
|
||||
<EmptyPlaceholder.Title>
|
||||
{t("No buckets found")}
|
||||
</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
{t(
|
||||
"The administrator has not configured the storage bucket, no file can be uploaded",
|
||||
)}
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
!s3Configs?.length &&
|
||||
!currentBucketInfo.bucket && (
|
||||
<EmptyPlaceholder className="col-span-full mt-8 shadow-none">
|
||||
<EmptyPlaceholder.Icon name="storage" />
|
||||
<EmptyPlaceholder.Title>
|
||||
{t("No buckets found")}
|
||||
</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
{t(
|
||||
"The administrator has not configured the storage bucket, no file can be uploaded",
|
||||
)}
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
s3Configs &&
|
||||
s3Configs.length > 0 &&
|
||||
currentBucketInfo && (
|
||||
currentBucketInfo.bucket && (
|
||||
<UserFileList
|
||||
user={user}
|
||||
files={files}
|
||||
|
||||
@@ -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 }) {
|
||||
</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">
|
||||
{t("usedSpace")}:
|
||||
@@ -83,34 +93,103 @@ export function FileSizeDisplay({ files, plan, t }) {
|
||||
</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
|
||||
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">
|
||||
{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");
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
</svg>
|
||||
<div
|
||||
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>
|
||||
);
|
||||
|
||||
+27
-20
@@ -257,27 +257,34 @@ export const FileUploader = ({
|
||||
) : (
|
||||
(file.status === "error" ||
|
||||
file.status === "cancelled") && (
|
||||
<div className="flex items-center 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>
|
||||
{t("Aborted")}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="flex items-center gap-1 rounded-full bg-red-600 px-3 py-1 text-xs text-white dark:bg-red-700">
|
||||
<div className="h-2 w-2 rounded-full bg-red-300 dark:bg-red-400"></div>
|
||||
{file.status === "cancelled" ? t("Aborted") : t("Failed")}
|
||||
</div>
|
||||
<Button
|
||||
className="size-6"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={() => retryUpload(file.id)}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="size-6"
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={() => removeFile(file.id)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
{file.status === "error" && file.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 text-right">
|
||||
{file.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
---
|
||||
title: 云存储配置
|
||||
description: 如何配置多平台云存储
|
||||
---
|
||||
|
||||
<DocsLang en="/docs/developer/cloud-storage" zh="/docs/developer/cloud-storag-zh" />
|
||||
|
||||
## 概览
|
||||
|
||||
管理员可以在 `/admin/system` 管理 S3 配置,包括添加、删除和修改云存储的 S3 配置。
|
||||
|
||||
WR.DO 目前支持多种云存储提供商:
|
||||
|
||||
- Cloudflare R2
|
||||
- AWS S3
|
||||
- 腾讯云 COS
|
||||
- 阿里云 OSS
|
||||
- 自定义提供商 (兼容 S3 API)
|
||||
|
||||
> 一个提供商可以添加多个存储桶
|
||||
|
||||
## Cloudflare R2
|
||||
|
||||
### 1. 创建 R2 存储桶
|
||||
|
||||
1. 登录您的 [Cloudflare 仪表板](https://dash.cloudflare.com/)
|
||||
2. 从左侧边栏导航到 **R2 对象存储**
|
||||
3. 点击 **创建存储桶**
|
||||
4. 输入您的存储桶名称(例如:`wrdo`)
|
||||
5. 选择位置(建议选择自动)
|
||||
6. 点击 **创建存储桶**
|
||||
|
||||
### 2. 获取 API 凭证
|
||||
|
||||
1. 在您的 Cloudflare 仪表板中,前往 **我的个人资料** > **API 令牌**
|
||||
2. 点击 **创建令牌**
|
||||
3. 使用 **R2 令牌** 模板或创建自定义令牌,包含:
|
||||
- **权限**: `R2:Edit`
|
||||
- **账户资源**: 包含您的账户
|
||||
- **区域资源**: 包含所有区域(如果需要)
|
||||
4. 点击 **继续摘要**,然后 **创建令牌**
|
||||
5. 复制并保存令牌(这是您的 **访问密钥 ID** 和 **秘密访问密钥**)
|
||||
|
||||
### 3. 获取账户 ID
|
||||
|
||||
1. 在您的 Cloudflare 仪表板中,前往右侧边栏
|
||||
2. 复制您的 **账户 ID**
|
||||
|
||||
### 4. 获取公共 URL
|
||||
|
||||
1. 在您的 Cloudflare 仪表板中,前往 **R2 对象存储** > **存储桶详情** > **公共开发 URL**
|
||||
|
||||
如果您已配置自定义域名,请使用该域名。
|
||||
|
||||
### 5. 配置 CORS
|
||||
|
||||
1. 在您的 Cloudflare 仪表板中,前往 **R2 对象存储** > **存储桶设置** -> **CORS 策略**
|
||||
|
||||
填入以下内容:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:3000",
|
||||
"https://wr.do" // 替换为您的域名
|
||||
],
|
||||
"AllowedMethods": [
|
||||
"GET",
|
||||
"PUT",
|
||||
"POST",
|
||||
"DELETE",
|
||||
"HEAD"
|
||||
],
|
||||
"AllowedHeaders": [
|
||||
"*"
|
||||
],
|
||||
"ExposeHeaders": [
|
||||
"ETag"
|
||||
],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 6. 在 WR.DO 中配置
|
||||
|
||||
访问 `localhost:3000/admin/system`,填写配置表单:
|
||||
|
||||
- **提供渠道**: cloudflare (r2)
|
||||
- **渠道名称**: Cloudflare R2(或任何自定义名称)
|
||||
- **S3 端点**: `https://<账户ID>.r2.cloudflarestorage.com`(替换为您的账户端点)
|
||||
- **访问密钥 ID**: 第 2 步中的 API 令牌
|
||||
- **机密访问密钥**: 第 2 步中的 API 令牌
|
||||
- **启用**: 开启
|
||||
- **存储桶名称**: 您的存储桶名称
|
||||
- **公开域名或自定义域名**: 参考第 4 步
|
||||
- **存储桶区域**: auto
|
||||
- **前缀**: 可选
|
||||
- **公开**: 如果您想要公共访问,请启用
|
||||
|
||||
## 腾讯云 COS
|
||||
|
||||
### 1. 创建 COS 存储桶
|
||||
|
||||
1. 登录 [腾讯云控制台](https://console.cloud.tencent.com/cos)
|
||||
2. 点击 **存储桶列表** > **创建存储桶**
|
||||
3. 输入存储桶名称(例如:`wrdo-1303456836`)
|
||||
4. 选择地域(例如:`ap-chengdu`)
|
||||
5. 配置访问权限
|
||||
6. 点击 **创建**
|
||||
|
||||
### 2. 获取 API 密钥
|
||||
|
||||
1. 前往 [CAM 控制台](https://console.cloud.tencent.com/cam/capi)
|
||||
2. 点击 **新建密钥** 或使用现有密钥
|
||||
3. 保存您的 **SecretId** 和 **SecretKey**
|
||||
|
||||
### 3. CORS 设置
|
||||
|
||||
1. 前往 [COS 控制台](https://console.cloud.tencent.com/cos)
|
||||
2. 点击 **存储桶列表** > **选择存储桶** -> 安全管理 -> 跨域访问 CORS 设置
|
||||
3. 填入下列规则:
|
||||
|
||||

|
||||
|
||||
### 4. 在 WR.DO 中配置
|
||||
|
||||
填写配置表单:
|
||||
|
||||
- **提供渠道**: tencent (cos)
|
||||
- **渠道名称**: 腾讯云 COS(或任何自定义名称)
|
||||
- **S3 端点**: `https://cos.ap-chengdu.myqcloud.com`(替换为您的地域)
|
||||
- **访问密钥 ID**: 您的 SecretId
|
||||
- **机密访问密钥**: 您的 SecretKey
|
||||
- **启用**: 开启
|
||||
- **存储桶名称**: 您的存储桶名称(例如:`wrdo-1303456836`)
|
||||
- **公开域名或自定义域名**: `https://wrdo-1303456836.cos.ap-chengdu.myqcloud.com`(您的存储桶公共 URL)
|
||||
- **存储桶区域**: 您的 COS 地域(例如:`ap-chengdu`)
|
||||
- **前缀**: 可选日期前缀
|
||||
- **公开**: 如果您想要公共访问,请启用
|
||||
|
||||
## 阿里云 OSS
|
||||
|
||||
### 1. 创建 OSS 存储桶
|
||||
|
||||
1. 登录 [阿里云控制台](https://oss.console.aliyun.com/)
|
||||
2. 点击 **创建 Bucket**
|
||||
3. 输入存储桶名称
|
||||
4. 选择地域(例如:`oss-cn-hangzhou`)
|
||||
5. 配置 ACL 和其他设置
|
||||
6. 点击 **确定**
|
||||
|
||||
### 2. 获取 AccessKey
|
||||
|
||||
1. 前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak)
|
||||
2. 点击 **创建 AccessKey** 或使用现有密钥
|
||||
3. 保存您的 **AccessKeyId** 和 **AccessKeySecret**
|
||||
|
||||
### 3. 在 WR.DO 中配置
|
||||
|
||||
填写配置表单:
|
||||
|
||||
- **提供渠道**: ali (oss)
|
||||
- **渠道名称**: 阿里云 OSS(或任何自定义名称)
|
||||
- **S3 端点**: `https://oss-cn-hangzhou.aliyuncs.com`(替换为您的地域)
|
||||
- **访问密钥 ID**: 您的 AccessKeyId
|
||||
- **机密访问密钥**: 您的 AccessKeySecret
|
||||
- **启用**: 开启
|
||||
- **存储桶名称**: 您的存储桶名称
|
||||
- **公开域名或自定义域名**: `https://your-bucket.oss-cn-hangzhou.aliyuncs.com`(您的存储桶公共 URL)
|
||||
- **存储桶区域**: 您的 OSS 地域(例如:`oss-cn-hangzhou`)
|
||||
- **前缀**: 可选日期前缀
|
||||
- **公开**: 如果您想要公共访问,请启用
|
||||
|
||||
## AWS S3
|
||||
|
||||
### 1. 创建 S3 存储桶
|
||||
|
||||
1. 登录 [AWS 控制台](https://console.aws.amazon.com/s3/)
|
||||
2. 点击 **创建存储桶**
|
||||
3. 输入存储桶名称(全球唯一)
|
||||
4. 选择 AWS 区域
|
||||
5. 根据需要配置存储桶设置
|
||||
6. 点击 **创建存储桶**
|
||||
|
||||
### 2. 创建 IAM 用户
|
||||
|
||||
1. 前往 [IAM 控制台](https://console.aws.amazon.com/iam/)
|
||||
2. 点击 **用户** > **添加用户**
|
||||
3. 输入用户名并选择 **编程访问**
|
||||
4. 附加现有策略或创建具有 S3 权限的自定义策略:
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:DeleteObject",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::your-bucket-name",
|
||||
"arn:aws:s3:::your-bucket-name/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
5. 完成用户创建并保存 **访问密钥 ID** 和 **秘密访问密钥**
|
||||
|
||||
### 3. 在 WR.DO 中配置
|
||||
|
||||
填写配置表单:
|
||||
|
||||
- **提供渠道**: aws (s3)
|
||||
- **渠道名称**: AWS S3(或任何自定义名称)
|
||||
- **S3 端点**: `https://s3.amazonaws.com`(或特定区域端点)
|
||||
- **访问密钥 ID**: 您的 IAM 用户访问密钥 ID
|
||||
- **机密访问密钥**: 您的 IAM 用户秘密访问密钥
|
||||
- **启用**: 开启
|
||||
- **存储桶名称**: 您的 S3 存储桶名称
|
||||
- **公开域名或自定义域名**: 您的存储桶公共 URL 或 CloudFront 分发
|
||||
- **存储桶区域**: 您的 S3 区域(例如:`us-east-1`)
|
||||
- **前缀**: 可选日期前缀
|
||||
- **公开**: 如果您想要公共访问,请启用
|
||||
|
||||
## 通用配置选项
|
||||
|
||||
### 前缀设置
|
||||
- 使用基于日期的前缀(例如:`2025/08/08`)按日期组织文件
|
||||
- 如果您喜欢扁平文件结构,请留空
|
||||
|
||||
### 公共访问
|
||||
- 如果您希望文件可以通过直接 URL 访问,请启用 **公开**
|
||||
- 对于私有文件存储,请禁用
|
||||
|
||||
### 自定义域名
|
||||
- 配置自定义域名以获得更好的品牌体验
|
||||
- 确保为您的域名进行正确的 DNS 配置
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **访问被拒绝**: 检查您的 API 凭证和权限
|
||||
2. **存储桶未找到**: 验证存储桶名称和区域设置
|
||||
3. **CORS 问题**: 如果需要,在您的存储桶中配置 CORS 设置
|
||||
4. **端点错误**: 确保您的提供商端点格式正确
|
||||
|
||||
### 测试配置
|
||||
|
||||
保存配置后,您可以通过以下方式测试:
|
||||
1. 通过管理界面上传测试文件
|
||||
2. 检查文件是否出现在您的云存储桶中
|
||||
3. 验证公共访问(如果启用)是否可以通过文件 URL 访问
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
title: Cloud Storage
|
||||
description: How to config the cloud storage api
|
||||
---
|
||||
|
||||
<DocsLang en="/docs/developer/cloud-storage" zh="/docs/developer/cloud-storag-zh" />
|
||||
|
||||
## Overview
|
||||
|
||||
Administrators can manage s3 configurations at `/admin/system`,
|
||||
including adding, deleting, and modifying s3 configurations for cloud storage.
|
||||
|
||||
WR.DO now supports multiple cloud storage providers:
|
||||
|
||||
- Cloudflare R2
|
||||
- AWS S3
|
||||
- Tencent COS
|
||||
- Ali OSS
|
||||
- Custom Provider (Support any S3 compatible provider)
|
||||
|
||||
> One provider can configure multiple buckets.
|
||||
|
||||
## Cloudflare R2
|
||||
|
||||
### 1. Create R2 Bucket
|
||||
|
||||
1. Log in to your [Cloudflare dashboard](https://dash.cloudflare.com/)
|
||||
2. Navigate to **R2 Object Storage** from the left sidebar
|
||||
3. Click **Create bucket**
|
||||
4. Enter your bucket name (e.g., `wrdo`)
|
||||
5. Select the location (auto is recommended)
|
||||
6. Click **Create bucket**
|
||||
|
||||
### 2. Get API Credentials
|
||||
|
||||
1. In your Cloudflare dashboard, go to **My Profile** > **API Tokens**
|
||||
2. Click **Create Token**
|
||||
3. Use the **R2 Token** template or create a custom token with:
|
||||
- **Permissions**: `R2:Edit`
|
||||
- **Account Resources**: Include your account
|
||||
- **Zone Resources**: Include all zones (if needed)
|
||||
4. Click **Continue to summary** and then **Create Token**
|
||||
5. Copy and save the token (this is your **Access Key ID** and **Secret Access Key**)
|
||||
|
||||
### 3. Get Account ID
|
||||
|
||||
1. In your Cloudflare dashboard, go to the right sidebar
|
||||
2. Copy your **Account ID**
|
||||
|
||||
### 4. Get Public URL
|
||||
|
||||
1. In your Cloudflare dashboard, go to **R2 Object Storage** > **Bucket Details** > **Public Development URL**
|
||||
|
||||
if you have configured a custom domain, use that instead.
|
||||
|
||||
### 5. Config CORS
|
||||
|
||||
1. In your Cloudflare dashboard, go to **R2 Object Storage** > **Bucket Settings** -> **CORS Policy**
|
||||
|
||||
Fill in the following:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:3000",
|
||||
"https://wr.do" // Replace with your domain
|
||||
],
|
||||
"AllowedMethods": [
|
||||
"GET",
|
||||
"PUT",
|
||||
"POST",
|
||||
"DELETE",
|
||||
"HEAD"
|
||||
],
|
||||
"AllowedHeaders": [
|
||||
"*"
|
||||
],
|
||||
"ExposeHeaders": [
|
||||
"ETag"
|
||||
],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 6. Configuration in WR.DO
|
||||
|
||||
Follow `localhost:3000/admin/system`, fill in the configuration form with:
|
||||
|
||||
- **Provider**: cloudflare (r2)
|
||||
- **Channel Name**: Cloudflare R2 (or any custom name)
|
||||
- **S3 Endpoint**: `https://<account_id>.r2.cloudflarestorage.com` (replace with your account's endpoint)
|
||||
- **Access Key ID**: Your API token from step 2
|
||||
- **Secret Access Key**: Your API token from step 2
|
||||
- **Enable**: Toggle ON
|
||||
- **Bucket Name**: Your bucket name
|
||||
- **Public Domain**: follow step 4
|
||||
- **Storage Region**: auto
|
||||
- **Prefix**: Optional
|
||||
- **Public**: Enable if you want public access
|
||||
|
||||
|
||||
## Tencent COS
|
||||
|
||||
### 1. Create COS Bucket
|
||||
|
||||
1. Log in to [Tencent Cloud Console](https://console.cloud.tencent.com/cos)
|
||||
2. Click **Bucket List** > **Create Bucket**
|
||||
3. Enter bucket name (e.g., `wrdo-1303456836`)
|
||||
4. Select region (e.g., `ap-chengdu`)
|
||||
5. Configure access permissions
|
||||
6. Click **Create**
|
||||
|
||||
### 2. Get API Keys
|
||||
|
||||
1. Go to [CAM Console](https://console.cloud.tencent.com/cam/capi)
|
||||
2. Click **Create Key** or use existing keys
|
||||
3. Save your **SecretId** and **SecretKey**
|
||||
|
||||
### 3. CORS 设置
|
||||
|
||||
1. Follow [COS Console](https://console.cloud.tencent.com/cos)
|
||||
2. Click **Bucket List** > **Select a Bucket** -> 安全管理 -> 跨域访问 CORS 设置
|
||||
3. Fill in the following rules:
|
||||
|
||||

|
||||
|
||||
### 4. Configuration in WR.DO
|
||||
|
||||
Fill in the configuration form with:
|
||||
|
||||
- **Provider**: tencent (cos)
|
||||
- **Channel Name**: 腾讯云 COS (or any custom name)
|
||||
- **S3 Endpoint**: `https://cos.ap-chengdu.myqcloud.com` (replace with your region)
|
||||
- **Access Key ID**: Your SecretId
|
||||
- **Secret Access Key**: Your SecretKey
|
||||
- **Enable**: Toggle ON
|
||||
- **Bucket Name**: Your bucket name (e.g., `wrdo-1303456836`)
|
||||
- **Public Domain**: `https://wrdo-1303456836.cos.ap-chengdu.myqcloud.com` (your bucket's public URL)
|
||||
- **Storage Region**: Your COS region (e.g., `ap-chengdu`)
|
||||
- **Prefix**: Optional date prefix
|
||||
- **Public**: Enable if you want public access
|
||||
|
||||
## Ali OSS
|
||||
|
||||
### 1. Create OSS Bucket
|
||||
|
||||
1. Log in to [Alibaba Cloud Console](https://oss.console.aliyun.com/)
|
||||
2. Click **Create Bucket**
|
||||
3. Enter bucket name
|
||||
4. Select region (e.g., `oss-cn-hangzhou`)
|
||||
5. Configure ACL and other settings
|
||||
6. Click **OK**
|
||||
|
||||
### 2. Get AccessKey
|
||||
|
||||
1. Go to [RAM Console](https://ram.console.aliyun.com/manage/ak)
|
||||
2. Click **Create AccessKey** or use existing keys
|
||||
3. Save your **AccessKeyId** and **AccessKeySecret**
|
||||
|
||||
### 3. Configuration in WR.DO
|
||||
|
||||
Fill in the configuration form with:
|
||||
|
||||
- **Provider**: ali (oss)
|
||||
- **Channel Name**: 阿里云 OSS (or any custom name)
|
||||
- **S3 Endpoint**: `https://oss-cn-hangzhou.aliyuncs.com` (replace with your region)
|
||||
- **Access Key ID**: Your AccessKeyId
|
||||
- **Secret Access Key**: Your AccessKeySecret
|
||||
- **Enable**: Toggle ON
|
||||
- **Bucket Name**: Your bucket name
|
||||
- **Public Domain**: `https://your-bucket.oss-cn-hangzhou.aliyuncs.com` (your bucket's public URL)
|
||||
- **Storage Region**: Your OSS region (e.g., `oss-cn-hangzhou`)
|
||||
- **Prefix**: Optional date prefix
|
||||
- **Public**: Enable if you want public access
|
||||
|
||||
## AWS S3
|
||||
|
||||
### 1. Create S3 Bucket
|
||||
|
||||
1. Log in to [AWS Console](https://console.aws.amazon.com/s3/)
|
||||
2. Click **Create bucket**
|
||||
3. Enter bucket name (globally unique)
|
||||
4. Select AWS region
|
||||
5. Configure bucket settings as needed
|
||||
6. Click **Create bucket**
|
||||
|
||||
### 2. Create IAM User
|
||||
|
||||
1. Go to [IAM Console](https://console.aws.amazon.com/iam/)
|
||||
2. Click **Users** > **Add user**
|
||||
3. Enter username and select **Programmatic access**
|
||||
4. Attach existing policies or create custom policy with S3 permissions:
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:DeleteObject",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::your-bucket-name",
|
||||
"arn:aws:s3:::your-bucket-name/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
5. Complete user creation and save **Access Key ID** and **Secret Access Key**
|
||||
|
||||
### 3. Configuration in WR.DO
|
||||
|
||||
Fill in the configuration form with:
|
||||
|
||||
- **Provider**: aws (s3)
|
||||
- **Channel Name**: AWS S3 (or any custom name)
|
||||
- **S3 Endpoint**: `https://s3.amazonaws.com` (or region-specific endpoint)
|
||||
- **Access Key ID**: Your IAM user's Access Key ID
|
||||
- **Secret Access Key**: Your IAM user's Secret Access Key
|
||||
- **Enable**: Toggle ON
|
||||
- **Bucket Name**: Your S3 bucket name
|
||||
- **Public Domain**: Your bucket's public URL or CloudFront distribution
|
||||
- **Storage Region**: Your S3 region (e.g., `us-east-1`)
|
||||
- **Prefix**: Optional date prefix
|
||||
- **Public**: Enable if you want public access
|
||||
|
||||
|
||||
## Common Configuration Options
|
||||
|
||||
### Prefix Settings
|
||||
- Use date-based prefixes (e.g., `2025/08/08`) to organize files by date
|
||||
- Leave empty if you prefer flat file structure
|
||||
|
||||
### Public Access
|
||||
- Enable **Public** if you want files to be accessible via direct URLs
|
||||
- Disable for private file storage
|
||||
|
||||
### Custom Domains
|
||||
- Configure custom domains for better branding
|
||||
- Ensure proper DNS configuration for your domain
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Access Denied**: Check your API credentials and permissions
|
||||
2. **Bucket Not Found**: Verify bucket name and region settings
|
||||
3. **CORS Issues**: Configure CORS settings in your bucket if needed
|
||||
4. **Endpoint Errors**: Ensure correct endpoint format for your provider
|
||||
|
||||
### Testing Configuration
|
||||
|
||||
After saving your configuration, you can test it by:
|
||||
1. Uploading a test file through the admin interface
|
||||
2. Checking if the file appears in your cloud storage bucket
|
||||
3. Verifying public access (if enabled) by accessing the file URL
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: S3 Configs
|
||||
description: How to config the s3 api.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Administrators can manage s3 configurations at `/admin/system`, including adding, deleting, and modifying s3 configurations.
|
||||
|
||||
## Cloudflare R2
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
+38
-3
@@ -158,9 +158,8 @@ export async function getUserFiles(options: QueryUserFileOptions = {}) {
|
||||
}),
|
||||
prisma.userFile.count({ where }),
|
||||
prisma.userFile.aggregate({
|
||||
// All provider's file size
|
||||
where: {
|
||||
userId,
|
||||
...(userId && { userId }),
|
||||
status: 1,
|
||||
},
|
||||
_sum: { size: true },
|
||||
@@ -275,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,
|
||||
},
|
||||
};
|
||||
@@ -358,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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface BucketItem {
|
||||
prefix?: string;
|
||||
file_types?: string;
|
||||
file_size?: string;
|
||||
max_storage?: string; // 存储桶最大存储容量(字节)
|
||||
region?: string;
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
@@ -214,6 +214,11 @@
|
||||
"storageFull": "Storage space is almost full",
|
||||
"storageHigh": "Storage space usage is high",
|
||||
"storageGood": "Storage space is sufficient",
|
||||
"planQuota": "Plan Quota",
|
||||
"bucketQuota": "Bucket Quota",
|
||||
"bucketCapacity": "Bucket Capacity",
|
||||
"bucketStorageFull": "Bucket storage is almost full",
|
||||
"bucketStorageHigh": "Bucket storage usage is high",
|
||||
"items": "items",
|
||||
"Total": "Total",
|
||||
"Configuration Error": "Configuration Error"
|
||||
@@ -360,6 +365,7 @@
|
||||
"Uploading": "Uploading",
|
||||
"Completed": "Completed",
|
||||
"Aborted": "Aborted",
|
||||
"Failed": "Failed",
|
||||
"Drop files to upload them to": "Drop files to upload them to",
|
||||
"Drag and drop file(s) here": "Drag and drop file(s) here",
|
||||
"or": "or",
|
||||
@@ -623,6 +629,8 @@
|
||||
"Region": "Region",
|
||||
"Prefix": "Prefix",
|
||||
"Optional": "Optional",
|
||||
"Max Storage": "Max Storage",
|
||||
"maxStorageTooltip": "Set maximum storage capacity for this bucket (in bytes). If not set, uses plan quota global limit.",
|
||||
"Allowed File Types": "Allowed File Types",
|
||||
"Public": "Public",
|
||||
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket": "Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket",
|
||||
|
||||
@@ -214,6 +214,11 @@
|
||||
"storageFull": "存储空间即将用完",
|
||||
"storageHigh": "存储空间使用较多",
|
||||
"storageGood": "存储空间充足",
|
||||
"planQuota": "计划配额",
|
||||
"bucketQuota": "存储桶配额",
|
||||
"bucketCapacity": "存储桶容量",
|
||||
"bucketStorageFull": "存储桶空间即将用完",
|
||||
"bucketStorageHigh": "存储桶空间使用较多",
|
||||
"items": "条",
|
||||
"Total": "共",
|
||||
"Configuration Error": "配置错误"
|
||||
@@ -360,6 +365,7 @@
|
||||
"Uploading": "上传中",
|
||||
"Completed": "已完成",
|
||||
"Aborted": "已中止",
|
||||
"Failed": "失败",
|
||||
"Drop files to upload them to": "将文件上传到",
|
||||
"Drag and drop file(s) here": "将文件拖到此处上传",
|
||||
"or": "或",
|
||||
@@ -623,6 +629,8 @@
|
||||
"Region": "存储桶区域",
|
||||
"Prefix": "前缀",
|
||||
"Optional": "可选",
|
||||
"Max Storage": "最大存储容量",
|
||||
"maxStorageTooltip": "设置此存储桶的最大存储容量(字节)。如果不设置,默认使用 Plan 配额的全局限制。",
|
||||
"Allowed File Types": "允许的文件类型",
|
||||
"Public": "公开",
|
||||
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user