diff --git a/app/(protected)/admin/storage/loading.tsx b/app/(protected)/admin/storage/loading.tsx
index 1eab0dd..835c8cf 100644
--- a/app/(protected)/admin/storage/loading.tsx
+++ b/app/(protected)/admin/storage/loading.tsx
@@ -5,8 +5,8 @@ export default function DashboardRecordsLoading() {
return (
<>
diff --git a/app/(protected)/admin/system/s3-list.tsx b/app/(protected)/admin/system/s3-list.tsx
index f78f408..3d14090 100644
--- a/app/(protected)/admin/system/s3-list.tsx
+++ b/app/(protected)/admin/system/s3-list.tsx
@@ -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({}: {}) {
/>
+
+
+
+
+
+
+
+
+
+ {t("maxStorageTooltip")}
+
+
+
+
+
+
+ updateBucket(index2, { max_storage: e.target.value })
+ }
+ />
+ {bucket.max_storage && (
+
+ ≈{(Number(bucket.max_storage) / (1024 * 1024 * 1024)).toFixed(1)}GB
+
+ )}
+
+
+
@@ -588,6 +625,7 @@ export default function S3Configs({}: {}) {
region: "auto",
custom_domain: "",
file_size: "26214400",
+ max_storage: "",
public: true,
},
],
diff --git a/app/(protected)/dashboard/storage/loading.tsx b/app/(protected)/dashboard/storage/loading.tsx
index 1eab0dd..835c8cf 100644
--- a/app/(protected)/dashboard/storage/loading.tsx
+++ b/app/(protected)/dashboard/storage/loading.tsx
@@ -5,8 +5,8 @@ export default function DashboardRecordsLoading() {
return (
<>
diff --git a/app/api/storage/admin/s3/files/configs/route.ts b/app/api/storage/admin/s3/files/configs/route.ts
index 84e1353..5ac2bed 100644
--- a/app/api/storage/admin/s3/files/configs/route.ts
+++ b/app/api/storage/admin/s3/files/configs/route.ts
@@ -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);
diff --git a/app/api/storage/bucket-usage/route.ts b/app/api/storage/bucket-usage/route.ts
new file mode 100644
index 0000000..2996f6c
--- /dev/null
+++ b/app/api/storage/bucket-usage/route.ts
@@ -0,0 +1,72 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { getBucketStorageUsage } from "@/lib/dto/files";
+import { getMultipleConfigs } from "@/lib/dto/system-config";
+import { checkUserStatus } from "@/lib/dto/user";
+import { getCurrentUser } from "@/lib/session";
+
+export async function GET(request: NextRequest) {
+ try {
+ const user = checkUserStatus(await getCurrentUser());
+ if (user instanceof Response) return user;
+
+ const url = new URL(request.url);
+ const bucket = url.searchParams.get("bucket");
+ const provider = url.searchParams.get("provider");
+
+ if (!bucket || !provider) {
+ return NextResponse.json("Missing bucket or provider parameters", {
+ status: 400,
+ });
+ }
+
+ // 获取存储桶配置
+ const configs = await getMultipleConfigs(["s3_config_list"]);
+ if (!configs || !configs.s3_config_list) {
+ return NextResponse.json("Invalid S3 configs", {
+ status: 400,
+ });
+ }
+
+ const providerConfig = configs.s3_config_list.find(
+ (c) => c.provider_name === provider,
+ );
+ if (!providerConfig) {
+ return NextResponse.json("Provider does not exist", {
+ status: 400,
+ });
+ }
+
+ const bucketConfig = providerConfig.buckets?.find(
+ (b) => b.bucket === bucket,
+ );
+ if (!bucketConfig) {
+ return NextResponse.json("Bucket does not exist", {
+ status: 400,
+ });
+ }
+
+ // 获取存储桶使用情况
+ const bucketUsage = await getBucketStorageUsage(bucket, provider);
+ if (!bucketUsage.success) {
+ return NextResponse.json("Failed to get bucket usage", {
+ status: 500,
+ });
+ }
+
+ return NextResponse.json({
+ bucket: bucket,
+ provider: provider,
+ usage: {
+ totalSize: bucketUsage.data.totalSize,
+ totalFiles: bucketUsage.data.totalFiles,
+ },
+ limits: {
+ maxStorage: bucketConfig.max_storage ? Number(bucketConfig.max_storage) : null,
+ },
+ });
+ } catch (error) {
+ console.error("Error getting bucket usage:", error);
+ return NextResponse.json({ error: "Server Error" }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/app/api/storage/s3/files/route.ts b/app/api/storage/s3/files/route.ts
index 6408b67..0c4022d 100644
--- a/app/api/storage/s3/files/route.ts
+++ b/app/api/storage/s3/files/route.ts
@@ -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 });
}
}
diff --git a/app/api/storage/s3/upload/route.ts b/app/api/storage/s3/upload/route.ts
index 7846743..6525479 100644
--- a/app/api/storage/s3/upload/route.ts
+++ b/app/api/storage/s3/upload/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
+import { getBucketStorageUsage } from "@/lib/dto/files";
import { getPlanQuota } from "@/lib/dto/plan";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
@@ -60,6 +61,27 @@ export async function POST(request: NextRequest) {
});
if (limit) return Response.json(limit.statusText, { status: limit.status });
+ // 检查存储桶容量限制
+ const bucketConfig = buckets.find((b) => b.bucket === bucket);
+ const totalUploadSize = files.reduce((sum, file) => sum + Number(file.size), 0);
+
+ if (bucketConfig?.max_storage) {
+ const bucketUsage = await getBucketStorageUsage(bucket, provider);
+ if (bucketUsage.success && bucketUsage.data) {
+ const currentUsage = bucketUsage.data.totalSize;
+ const maxStorage = Number(bucketConfig.max_storage);
+
+ if (currentUsage + totalUploadSize > maxStorage) {
+ const remainingSpace = maxStorage - currentUsage;
+ const remainingSpaceGB = (remainingSpace / (1024 * 1024 * 1024)).toFixed(2);
+ return Response.json(
+ `存储桶容量不足!剩余 ${remainingSpaceGB}GB,请更换存储桶`,
+ { status: 403 }
+ );
+ }
+ }
+ }
+
const R2 = createS3Client(
providerChannel.endpoint,
providerChannel.access_key_id,
diff --git a/app/layout.tsx b/app/layout.tsx
index 5e3e093..d2a2fcc 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -43,7 +43,7 @@ export default async function RootLayout({ children }: RootLayoutProps) {
disableTransitionOnChange
>
{children}
-
+
diff --git a/components/file/file-list.tsx b/components/file/file-list.tsx
index 643ca1d..d537a72 100644
--- a/components/file/file-list.tsx
+++ b/components/file/file-list.tsx
@@ -240,26 +240,28 @@ export default function UserFileList({
-
-
-
- {`
${getFileUrl(file.path)}`}
-
-
${getFileUrl(file.path)}`}
- />
-
- {`})`}
+ {`[${file.name}](${getFileUrl(file.path)})`}
+ {file.mimeType.startsWith("image/") && (
+
+
+
+ {`
${getFileUrl(file.path)}`}
+
+
${getFileUrl(file.path)}`}
+ />
+
+ )}
>
);
@@ -355,10 +357,10 @@ export default function UserFileList({
+
{file.user.name}
{file.user.email}
- >
+
}
>
{file.user.name ?? file.user.email}
diff --git a/components/file/index.tsx b/components/file/index.tsx
index d5e0521..8702f39 100644
--- a/components/file/index.tsx
+++ b/components/file/index.tsx
@@ -124,6 +124,17 @@ export default function UserFileManager({ user, action }: FileListProps) {
fetcher,
);
+ const { data: bucketUsage } = useSWR(
+ currentBucketInfo.bucket && currentBucketInfo.provider_name
+ ? `/api/storage/bucket-usage?bucket=${currentBucketInfo.bucket}&provider=${currentBucketInfo.provider_name}`
+ : null,
+ fetcher,
+ {
+ revalidateOnFocus: false,
+ refreshInterval: 30000, // 每30秒更新一次
+ },
+ );
+
useEffect(() => {
if (s3Configs && s3Configs.length > 0) {
setCurrentProvider(s3Configs[0]);
@@ -261,11 +272,22 @@ export default function UserFileManager({ user, action }: FileListProps) {
-
+
}
>
-
+
)}
{/* Bucket Select */}
@@ -288,7 +310,7 @@ export default function UserFileManager({ user, action }: FileListProps) {
- {s3Configs.map((provider) => (
+ {s3Configs.map((provider, index) => (
{provider.provider_name}
{provider.buckets?.map((item) => (
@@ -299,7 +321,7 @@ export default function UserFileManager({ user, action }: FileListProps) {
{item.bucket}
))}
-
+ {index !== s3Configs.length - 1 && }
))}
@@ -415,25 +437,28 @@ export default function UserFileManager({ user, action }: FileListProps) {
)}
- {!isLoading && !error && !s3Configs && !currentBucketInfo && (
-
-
-
- {t("No buckets found")}
-
-
- {t(
- "The administrator has not configured the storage bucket, no file can be uploaded",
- )}
-
-
- )}
+ {!isLoading &&
+ !error &&
+ !s3Configs?.length &&
+ !currentBucketInfo.bucket && (
+
+
+
+ {t("No buckets found")}
+
+
+ {t(
+ "The administrator has not configured the storage bucket, no file can be uploaded",
+ )}
+
+
+ )}
{!isLoading &&
!error &&
s3Configs &&
s3Configs.length > 0 &&
- currentBucketInfo && (
+ currentBucketInfo.bucket && (
0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
+ // 存储桶级别的配额信息
+ const bucketTotalSize = bucketUsage?.usage?.totalSize || 0;
+ const bucketMaxSize = bucketUsage?.limits?.maxStorage || 0;
+ const bucketUsagePercentage =
+ bucketMaxSize > 0 ? Math.min((bucketTotalSize / bucketMaxSize) * 100, 100) : 0;
+ const hasBucketLimit = bucketMaxSize > 0;
+
const getStatusColor = (percentage) => {
if (percentage >= 90) return "text-red-600";
if (percentage >= 70) return "text-yellow-600";
@@ -55,8 +62,11 @@ export function FileSizeDisplay({ files, plan, t }) {
- {/* 详细信息 */}
+ {/* Plan级别详细信息 */}
+
+ {t("planQuota")}
+
{t("usedSpace")}:
@@ -83,34 +93,103 @@ export function FileSizeDisplay({ files, plan, t }) {
+ {/* 存储桶级别信息 */}
+ {hasBucketLimit && (
+ <>
+
+
+
+
+ {t("bucketQuota")} - {bucketInfo?.bucket}
+
+
+
+
+ {t("used")}
+
+
+ {bucketUsagePercentage.toFixed(1)}%
+
+
+
+
+
+
+ {t("usedSpace")}:
+
+
+ {formatFileSize(bucketTotalSize, { precision: 2 })}
+
+
+
+
+ {t("bucketCapacity")}:
+
+
+ {formatFileSize(bucketMaxSize, { precision: 2 })}
+
+
+
+
+ {t("availableSpace")}:
+
+
+ {formatFileSize(bucketMaxSize - bucketTotalSize, { precision: 2 })}
+
+
+
+
+ >
+ )}
+
{/* 状态提示 */}
usagePercentage ? bucketUsagePercentage : usagePercentage)}`}
>
- {getStatusIcon(usagePercentage)}
+ {getStatusIcon(hasBucketLimit && bucketUsagePercentage > usagePercentage ? bucketUsagePercentage : usagePercentage)}
- {usagePercentage >= 90
- ? t("storageFull")
- : usagePercentage >= 70
- ? t("storageHigh")
- : t("storageGood")}
+ {(() => {
+ const criticalPercentage = hasBucketLimit && bucketUsagePercentage > usagePercentage ? bucketUsagePercentage : usagePercentage;
+ if (criticalPercentage >= 90) {
+ return hasBucketLimit && bucketUsagePercentage >= 90 ? t("bucketStorageFull") : t("storageFull");
+ } else if (criticalPercentage >= 70) {
+ return hasBucketLimit && bucketUsagePercentage >= 70 ? t("bucketStorageHigh") : t("storageHigh");
+ } else {
+ return t("storageGood");
+ }
+ })()}
);
}
-export function CircularStorageIndicator({ files, plan, size = 32 }) {
+export function CircularStorageIndicator({ files, plan, bucketUsage, size = 32 }) {
const totalSize = files?.totalSize || 0;
const maxSize = Number(plan?.stMaxTotalSize || 0);
const usagePercentage =
maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
+ // 存储桶级别的配额信息
+ const bucketTotalSize = bucketUsage?.usage?.totalSize || 0;
+ const bucketMaxSize = bucketUsage?.limits?.maxStorage || 0;
+ const bucketUsagePercentage =
+ bucketMaxSize > 0 ? Math.min((bucketTotalSize / bucketMaxSize) * 100, 100) : 0;
+ const hasBucketLimit = bucketMaxSize > 0;
+
+ // 使用更严格的限制来显示
+ const displayPercentage = hasBucketLimit && bucketUsagePercentage > usagePercentage ? bucketUsagePercentage : usagePercentage;
+
// 圆形参数
const radius = (size - 6) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset =
- circumference - (usagePercentage / 100) * circumference;
+ circumference - (displayPercentage / 100) * circumference;
// 根据使用率确定颜色
const getColor = (percentage) => {
@@ -139,7 +218,7 @@ export function CircularStorageIndicator({ files, plan, size = 32 }) {
cx={size / 2}
cy={size / 2}
r={radius}
- stroke={getColor(usagePercentage)}
+ stroke={getColor(displayPercentage)}
strokeWidth="3"
fill="none"
strokeLinecap="round"
@@ -150,9 +229,9 @@ export function CircularStorageIndicator({ files, plan, size = 32 }) {
- {Math.round(usagePercentage)}%
+ {Math.round(displayPercentage)}%
);
diff --git a/components/file/upload.tsx b/components/file/upload.tsx
index ac977a6..77f355f 100644
--- a/components/file/upload.tsx
+++ b/components/file/upload.tsx
@@ -257,27 +257,34 @@ export const FileUploader = ({
) : (
(file.status === "error" ||
file.status === "cancelled") && (
-
-
-
- {t("Aborted")}
+
+
+
+
+ {file.status === "cancelled" ? t("Aborted") : t("Failed")}
+
+
+
-
-
+ {file.status === "error" && file.error && (
+
+ {file.error}
+
+ )}
)
)}
diff --git a/content/docs/developer/cloud-storag-zh.mdx b/content/docs/developer/cloud-storag-zh.mdx
new file mode 100644
index 0000000..625bbe1
--- /dev/null
+++ b/content/docs/developer/cloud-storag-zh.mdx
@@ -0,0 +1,259 @@
+---
+title: 云存储配置
+description: 如何配置多平台云存储
+---
+
+
+
+## 概览
+
+管理员可以在 `/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 访问
\ No newline at end of file
diff --git a/content/docs/developer/cloud-storage.mdx b/content/docs/developer/cloud-storage.mdx
new file mode 100644
index 0000000..0f579b9
--- /dev/null
+++ b/content/docs/developer/cloud-storage.mdx
@@ -0,0 +1,262 @@
+---
+title: Cloud Storage
+description: How to config the cloud storage api
+---
+
+
+
+## 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://
.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
diff --git a/content/docs/developer/s3.mdx b/content/docs/developer/s3.mdx
deleted file mode 100644
index 3b08cc4..0000000
--- a/content/docs/developer/s3.mdx
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/hooks/use-file-upload.ts b/hooks/use-file-upload.ts
index c0302fd..f938484 100644
--- a/hooks/use-file-upload.ts
+++ b/hooks/use-file-upload.ts
@@ -146,7 +146,25 @@ export function useFileUpload({ bucketInfo, userId, api }: Props) {
});
if (!response.ok) {
- throw new Error("获取预签名 URL 失败");
+ // 尝试获取后端返回的具体错误信息
+ let errorMessage = "获取预签名 URL 失败";
+ try {
+ const errorText = await response.text();
+ if (errorText) {
+ // 如果返回的是JSON格式的错误信息,尝试解析
+ try {
+ const errorData = JSON.parse(errorText);
+ errorMessage = errorData.message || errorData.error || errorText;
+ } catch {
+ // 如果不是JSON,直接使用文本内容
+ errorMessage = errorText;
+ }
+ }
+ } catch {
+ // 如果无法读取响应内容,使用默认错误信息
+ errorMessage = `上传失败 (${response.status})`;
+ }
+ throw new Error(errorMessage);
}
const data = await response.json();
@@ -331,14 +349,15 @@ export function useFileUpload({ bucketInfo, userId, api }: Props) {
await Promise.allSettled(uploadPromises);
} catch (error) {
console.error("上传失败:", error);
- // 将所有 pending 状态的文件设置为错误状态
+ // 将所有 pending 状态的文件设置为错误状态,并显示具体错误信息
+ const errorMessage = error instanceof Error ? error.message : "上传失败";
setFiles((prev) =>
prev.map((file) =>
file.status === "pending"
? {
...file,
status: "error",
- error: "上传失败",
+ error: errorMessage,
}
: file,
),
diff --git a/lib/dto/files.ts b/lib/dto/files.ts
index cc20470..0941ac7 100644
--- a/lib/dto/files.ts
+++ b/lib/dto/files.ts
@@ -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" };
+ }
+}
diff --git a/lib/r2.ts b/lib/r2.ts
index 3aa3b98..dbf54ce 100644
--- a/lib/r2.ts
+++ b/lib/r2.ts
@@ -34,6 +34,7 @@ export interface BucketItem {
prefix?: string;
file_types?: string;
file_size?: string;
+ max_storage?: string; // 存储桶最大存储容量(字节)
region?: string;
public: boolean;
}
diff --git a/locales/en.json b/locales/en.json
index 73ec486..4bbf049 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -214,6 +214,11 @@
"storageFull": "Storage space is almost full",
"storageHigh": "Storage space usage is high",
"storageGood": "Storage space is sufficient",
+ "planQuota": "Plan Quota",
+ "bucketQuota": "Bucket Quota",
+ "bucketCapacity": "Bucket Capacity",
+ "bucketStorageFull": "Bucket storage is almost full",
+ "bucketStorageHigh": "Bucket storage usage is high",
"items": "items",
"Total": "Total",
"Configuration Error": "Configuration Error"
@@ -360,6 +365,7 @@
"Uploading": "Uploading",
"Completed": "Completed",
"Aborted": "Aborted",
+ "Failed": "Failed",
"Drop files to upload them to": "Drop files to upload them to",
"Drag and drop file(s) here": "Drag and drop file(s) here",
"or": "or",
@@ -623,6 +629,8 @@
"Region": "Region",
"Prefix": "Prefix",
"Optional": "Optional",
+ "Max Storage": "Max Storage",
+ "maxStorageTooltip": "Set maximum storage capacity for this bucket (in bytes). If not set, uses plan quota global limit.",
"Allowed File Types": "Allowed File Types",
"Public": "Public",
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket": "Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket",
diff --git a/locales/zh.json b/locales/zh.json
index 237aa1e..5c87543 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -214,6 +214,11 @@
"storageFull": "存储空间即将用完",
"storageHigh": "存储空间使用较多",
"storageGood": "存储空间充足",
+ "planQuota": "计划配额",
+ "bucketQuota": "存储桶配额",
+ "bucketCapacity": "存储桶容量",
+ "bucketStorageFull": "存储桶空间即将用完",
+ "bucketStorageHigh": "存储桶空间使用较多",
"items": "条",
"Total": "共",
"Configuration Error": "配置错误"
@@ -360,6 +365,7 @@
"Uploading": "上传中",
"Completed": "已完成",
"Aborted": "已中止",
+ "Failed": "失败",
"Drop files to upload them to": "将文件上传到",
"Drag and drop file(s) here": "将文件拖到此处上传",
"or": "或",
@@ -623,6 +629,8 @@
"Region": "存储桶区域",
"Prefix": "前缀",
"Optional": "可选",
+ "Max Storage": "最大存储容量",
+ "maxStorageTooltip": "设置此存储桶的最大存储容量(字节)。如果不设置,默认使用 Plan 配额的全局限制。",
"Allowed File Types": "允许的文件类型",
"Public": "公开",
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶",
diff --git a/public/_static/docs/cos-cors.png b/public/_static/docs/cos-cors.png
new file mode 100644
index 0000000..2daf819
Binary files /dev/null and b/public/_static/docs/cos-cors.png differ
diff --git a/public/sw.js.map b/public/sw.js.map
index 6110b66..990ae64 100644
--- a/public/sw.js.map
+++ b/public/sw.js.map
@@ -1 +1 @@
-{"version":3,"file":"sw.js","sources":["../../../../../../private/var/folders/9b/3qmyp8zd2xvdspdrp149fyg00000gn/T/bf4c8ec428159b5c3ebe0be7863842cb/sw.js"],"sourcesContent":["import {registerRoute as workbox_routing_registerRoute} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-routing@6.6.0/node_modules/workbox-routing/registerRoute.mjs';\nimport {NetworkFirst as workbox_strategies_NetworkFirst} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkFirst.mjs';\nimport {NetworkOnly as workbox_strategies_NetworkOnly} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkOnly.mjs';\nimport {clientsClaim as workbox_core_clientsClaim} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-core@6.6.0/node_modules/workbox-core/clientsClaim.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\nimportScripts(\n \n);\n\n\n\n\n\n\n\nself.skipWaiting();\n\nworkbox_core_clientsClaim();\n\n\n\nworkbox_routing_registerRoute(\"/\", new workbox_strategies_NetworkFirst({ \"cacheName\":\"start-url\", plugins: [{ cacheWillUpdate: async ({ request, response, event, state }) => { if (response && response.type === 'opaqueredirect') { return new Response(response.body, { status: 200, statusText: 'OK', headers: response.headers }) } return response } }] }), 'GET');\nworkbox_routing_registerRoute(/.*/i, new workbox_strategies_NetworkOnly({ \"cacheName\":\"dev\", plugins: [] }), 'GET');\n\n\n\n\n"],"names":["importScripts","self","skipWaiting","workbox_core_clientsClaim","workbox_routing_registerRoute","workbox_strategies_NetworkFirst","plugins","cacheWillUpdate","request","response","event","state","type","Response","body","status","statusText","headers","workbox_strategies_NetworkOnly"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"sw.js","sources":["../../../../../../private/var/folders/9b/3qmyp8zd2xvdspdrp149fyg00000gn/T/da3081931a4b4b8e0299a7aed11c8d3e/sw.js"],"sourcesContent":["import {registerRoute as workbox_routing_registerRoute} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-routing@6.6.0/node_modules/workbox-routing/registerRoute.mjs';\nimport {NetworkFirst as workbox_strategies_NetworkFirst} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkFirst.mjs';\nimport {NetworkOnly as workbox_strategies_NetworkOnly} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkOnly.mjs';\nimport {clientsClaim as workbox_core_clientsClaim} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-core@6.6.0/node_modules/workbox-core/clientsClaim.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\nimportScripts(\n \n);\n\n\n\n\n\n\n\nself.skipWaiting();\n\nworkbox_core_clientsClaim();\n\n\n\nworkbox_routing_registerRoute(\"/\", new workbox_strategies_NetworkFirst({ \"cacheName\":\"start-url\", plugins: [{ cacheWillUpdate: async ({ request, response, event, state }) => { if (response && response.type === 'opaqueredirect') { return new Response(response.body, { status: 200, statusText: 'OK', headers: response.headers }) } return response } }] }), 'GET');\nworkbox_routing_registerRoute(/.*/i, new workbox_strategies_NetworkOnly({ \"cacheName\":\"dev\", plugins: [] }), 'GET');\n\n\n\n\n"],"names":["importScripts","self","skipWaiting","workbox_core_clientsClaim","workbox_routing_registerRoute","workbox_strategies_NetworkFirst","plugins","cacheWillUpdate","request","response","event","state","type","Response","body","status","statusText","headers","workbox_strategies_NetworkOnly"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgBAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAa,EAEZ,CAAA;EAQDC,CAAI,CAAA,CAAA,CAAA,CAACC,CAAW,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAE,CAAA;AAElBC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAyB,EAAE,CAAA;AAI3BC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAA6B,CAAC,CAAA,CAAA,CAAG,CAAE,CAAA,CAAA,CAAA,CAAA,CAAIC,oBAA+B,CAAC,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAW,EAAC,CAAW,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;EAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAO,EAAE,CAAC,CAAA;GAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAe,EAAE,CAAO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;QAAEC,CAAO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;QAAEC,CAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;QAAEC,CAAK,CAAA,CAAA,CAAA,CAAA,CAAA;AAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA;AAAM,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAC,CAAK,CAAA,CAAA,CAAA,CAAA,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAIF,QAAQ,CAAIA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAQ,CAACG,CAAI,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAK,gBAAgB,CAAE,CAAA,CAAA;AAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,OAAO,CAAIC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAQ,CAACJ,CAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAACK,IAAI,CAAE,CAAA,CAAA;EAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAM,EAAE,CAAG,CAAA,CAAA,CAAA;EAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAU,EAAE,CAAI,CAAA,CAAA,CAAA,CAAA;YAAEC,CAAO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAER,CAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAACQ,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA;AAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAC,CAAC,CAAA;EAAC,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAOR,QAAQ,CAAA;EAAC,CAAA,CAAA,CAAA,CAAA,CAAA;KAAG,CAAA;AAAE,CAAA,CAAA,CAAC,CAAC,CAAA,CAAE,CAAK,CAAA,CAAA,CAAA,CAAA,CAAC,CAAA;AACxWL,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAA6B,CAAC,CAAA,CAAA,CAAA,CAAA,CAAK,CAAE,CAAA,CAAA,CAAA,CAAA,CAAIc,mBAA8B,CAAC,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAW,EAAC,CAAK,CAAA,CAAA,CAAA,CAAA,CAAA;EAAEZ,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAO,EAAE,CAAA,CAAA;EAAG,CAAC,CAAC,CAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAK,CAAC,CAAA;;"}
\ No newline at end of file