Merge pull request #65 from oiov/dev

Dev
This commit is contained in:
oiov
2025-07-22 17:06:05 +08:00
committed by GitHub
17 changed files with 417 additions and 109 deletions
+82 -4
View File
@@ -7,8 +7,8 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR from "swr";
import { BucketItem, CloudStorageCredentials } from "@/lib/r2";
import { cn, fetcher } from "@/lib/utils";
import { BucketItem, CloudStorageCredentials } from "@/lib/s3";
import { cn, fetcher, formatFileSize } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -365,7 +365,7 @@ export default function S3Configs({}: {}) {
{/* buckets */}
{config.buckets.map((bucket, index2) => (
<motion.div
className="relative grid grid-cols-1 gap-4 rounded-lg border border-dashed border-muted-foreground px-3 pb-3 pt-10 text-neutral-600 dark:text-neutral-400 sm:grid-cols-3"
className="relative grid grid-cols-1 gap-4 rounded-lg border border-dashed border-muted-foreground px-3 pb-3 pt-10 text-neutral-600 dark:text-neutral-400 sm:grid-cols-4"
key={`bucket-${index2}`}
layout
initial={{ opacity: 0, scale: 0.9 }}
@@ -446,6 +446,8 @@ export default function S3Configs({}: {}) {
region: "auto",
custom_domain: "",
file_size: "26214400",
max_storage: "1073741824",
max_files: "1000",
public: true,
});
setS3Configs(
@@ -538,6 +540,81 @@ export default function S3Configs({}: {}) {
/>
</div>
<div className="mt-1 space-y-2">
<div className="flex items-center gap-1">
<Label>{t("Max File Size")}</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.file_size}
placeholder="26214400"
onChange={(e) =>
updateBucket(index2, {
file_size: e.target.value,
})
}
/>
{bucket.file_size && (
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
{formatFileSize(Number(bucket.file_size))}
</span>
)}
</div>
</div>
<div className="space-y-1">
<Label>{t("Max File Count")}</Label>
<Input
value={bucket.max_files}
placeholder="1000"
onChange={(e) =>
updateBucket(index2, {
max_files: e.target.value,
})
}
/>
</div>
<div className="mt-1 space-y-2">
<div className="flex items-center gap-1">
<Label>{t("Max Storage")}</Label>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<Icons.help className="size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-64 text-wrap">
{t("maxStorageTooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="relative">
<Input
value={bucket.max_storage}
placeholder="10737418240"
onChange={(e) =>
updateBucket(index2, {
max_storage: e.target.value,
})
}
/>
{bucket.max_storage && (
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
{formatFileSize(Number(bucket.max_storage))}
</span>
)}
</div>
</div>
<div className="flex flex-col justify-center space-y-3">
<div className="flex items-center gap-1">
<Label>{t("Public")}</Label>
@@ -567,7 +644,7 @@ export default function S3Configs({}: {}) {
<div className="flex items-center justify-between gap-3">
<Link
className="text-sm text-blue-500 hover:underline"
href="/docs/developer/cloud-storage#cloudflare-r2"
href="/docs/developer/cloud-storage"
target="_blank"
>
{t("How to get the S3 credentials?")}
@@ -592,6 +669,7 @@ export default function S3Configs({}: {}) {
region: "auto",
custom_domain: "",
file_size: "26214400",
max_storage: "",
public: true,
},
],
+1 -1
View File
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/r2";
import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/s3";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: NextRequest) {
+1 -1
View File
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/r2";
import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/s3";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: NextRequest) {
+50 -16
View File
@@ -2,12 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getPlanQuota } from "@/lib/dto/plan";
import { getBucketStorageUsage } from "@/lib/dto/files";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import { createS3Client } from "@/lib/r2";
import { createS3Client } from "@/lib/s3";
import { getCurrentUser } from "@/lib/session";
import { restrictByTimeRange } from "@/lib/team";
import { generateFileKey } from "@/lib/utils";
export async function POST(request: NextRequest) {
@@ -44,21 +43,56 @@ export async function POST(request: NextRequest) {
});
}
const plan = await getPlanQuota(user.team!);
for (const file of files) {
if (Number(file.size) > Number(plan.stMaxFileSize)) {
return Response.json(`File (${file.name}) size limit exceeded`, {
status: 400,
});
const bucketConfig = buckets.find((b) => b.bucket === bucket);
if (bucketConfig?.file_size) {
for (const file of files) {
if (Number(file.size) > Number(bucketConfig?.file_size)) {
return Response.json(`File size limit exceeded`, {
status: 400,
});
}
}
}
// else {
// const plan = await getPlanQuota(user.team!);
// for (const file of files) {
// if (Number(file.size) > Number(plan.stMaxFileSize)) {
// return Response.json(`File (${file.name}) size limit exceeded`, {
// status: 400,
// });
// }
// }
// }
// 检查存储桶容量限制
const totalUploadSize = files.reduce(
(sum, file) => sum + Number(file.size),
0,
);
if (bucketConfig?.max_storage) {
const bucketUsage = await getBucketStorageUsage(
bucket,
provider,
user.id,
);
if (bucketUsage.success && bucketUsage.data) {
const currentUsage = bucketUsage.data.totalSize;
const maxStorage = Number(bucketConfig.max_storage);
if (currentUsage + totalUploadSize > maxStorage) {
const remainingSpace = maxStorage - currentUsage;
const remainingSpaceGB = (
remainingSpace /
(1024 * 1024 * 1024)
).toFixed(2);
return Response.json(
`Bucket storage limit exceeded. Remaining space: ${remainingSpaceGB} GB.`,
{ status: 403 },
);
}
}
}
// const limit = await restrictByTimeRange({
// model: "userFile",
// userId: user.id,
// limit: Number(plan.stMaxFileCount),
// rangeType: "month",
// });
// if (limit) return Response.json(limit.statusText, { status: limit.status });
const R2 = createS3Client(
providerChannel.endpoint,
+1 -1
View File
@@ -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>
+72 -17
View File
@@ -7,7 +7,7 @@ import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
import { UserFileData } from "@/lib/dto/files";
import { BucketItem, ClientStorageCredentials } from "@/lib/r2";
import { BucketItem, ClientStorageCredentials } from "@/lib/s3";
import { cn, fetcher } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import {
@@ -56,12 +56,28 @@ export type DisplayType = "List" | "Grid";
export interface FileListData {
total: number;
totalSize: number;
totalFiles: number;
list: UserFileData[];
}
export interface BucketUsage {
bucket: string;
provider: string;
usage: {
totalSize: number;
totalFiles: number;
};
limits: {
maxStorage: number;
maxFiles: number;
maxSingleFileSize: number;
};
}
export interface StorageUserPlan {
stMaxTotalSize: string;
stMaxFileSize: string;
stMaxFileCount: number;
}
export default function UserFileManager({ user, action }: FileListProps) {
@@ -94,6 +110,8 @@ export default function UserFileManager({ user, action }: FileListProps) {
status: "1",
});
const [bucketUsage, setBucketUsage] = useState<BucketUsage | null>(null);
// const isAdmin = action.includes("/admin");
const { mutate } = useSWRConfig();
@@ -135,10 +153,42 @@ export default function UserFileManager({ user, action }: FileListProps) {
channel: s3Configs[0].channel,
provider_name: s3Configs[0].provider_name,
public: s3Configs[0].buckets[0].public,
file_size: s3Configs[0].buckets[0].file_size,
max_files: s3Configs[0].buckets[0].max_files,
max_storage: s3Configs[0].buckets[0].max_storage,
});
}
}, [s3Configs]);
useEffect(() => {
if (
files &&
currentBucketInfo.bucket &&
currentBucketInfo.provider_name &&
plan
) {
setBucketUsage({
bucket: currentBucketInfo.bucket,
provider: currentBucketInfo.provider_name,
usage: {
totalSize: files.totalSize,
totalFiles: files.totalFiles,
},
limits: {
maxStorage: currentBucketInfo.max_storage
? Number(currentBucketInfo.max_storage)
: Number(plan.stMaxTotalSize),
maxFiles: currentBucketInfo.max_files
? Number(currentBucketInfo.max_files)
: Number(plan.stMaxFileCount),
maxSingleFileSize: currentBucketInfo.file_size
? Number(currentBucketInfo.file_size)
: Number(plan.stMaxFileSize),
},
});
}
}, [files, currentBucketInfo, plan]);
const handleRefresh = () => {
setSelectedFiles([]);
mutate(
@@ -151,18 +201,21 @@ export default function UserFileManager({ user, action }: FileListProps) {
provider: ClientStorageCredentials,
bucket: string,
) => {
console.log(provider, bucket);
setCurrentBucketInfo({
bucket: bucket,
custom_domain: provider.buckets.find((b) => b.bucket === bucket)
?.custom_domain,
prefix: provider.buckets.find((b) => b.bucket === bucket)?.prefix,
platform: provider.platform,
channel: provider.channel,
provider_name: provider.provider_name,
public: true,
});
const new_bucket = provider.buckets.find((b) => b.bucket === bucket);
if (new_bucket) {
setCurrentBucketInfo({
bucket: bucket,
custom_domain: new_bucket.custom_domain,
prefix: new_bucket.prefix,
platform: provider.platform,
channel: provider.channel,
provider_name: provider.provider_name,
public: true,
file_size: new_bucket.file_size,
max_files: new_bucket.max_files,
max_storage: new_bucket.max_storage,
});
}
};
const handleSelectAllFiles = () => {
@@ -257,15 +310,15 @@ export default function UserFileManager({ user, action }: FileListProps) {
/>
</div>
{/* Storage */}
{files && files.totalSize > 0 && plan && (
{bucketUsage?.bucket && (
<ClickableTooltip
content={
<div className="w-80">
<FileSizeDisplay files={files} plan={plan} t={t} />
<FileSizeDisplay bucketUsage={bucketUsage} t={t} />
</div>
}
>
<CircularStorageIndicator files={files} plan={plan} size={36} />
<CircularStorageIndicator bucketUsage={bucketUsage} size={36} />
</ClickableTooltip>
)}
{/* Bucket Select */}
@@ -310,9 +363,11 @@ export default function UserFileManager({ user, action }: FileListProps) {
{!isLoading &&
s3Configs &&
s3Configs.length > 0 &&
currentBucketInfo && (
currentBucketInfo &&
bucketUsage && (
<FileUploader
bucketInfo={currentBucketInfo}
bucketUsage={bucketUsage}
action="/api/storage"
plan={plan}
userId={user.id}
+72 -26
View File
@@ -1,13 +1,15 @@
import React from "react";
import { AlertTriangle, CheckCircle, HardDrive } from "lucide-react";
import { formatFileSize } from "@/lib/utils";
import { formatFileSize, nFormatter } from "@/lib/utils";
export function FileSizeDisplay({ files, plan, t }) {
const totalSize = files?.totalSize || 0;
const maxSize = Number(plan?.stMaxTotalSize || 0);
export function FileSizeDisplay({ bucketUsage, t }) {
// 从统一的 bucketUsage 中获取数据
const totalSize = bucketUsage?.usage?.totalSize || 0;
const maxSize = bucketUsage?.limits?.maxStorage || 0;
const usagePercentage =
maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
const bucketName = bucketUsage?.bucket || "";
const getStatusColor = (percentage) => {
if (percentage >= 90) return "text-red-600";
@@ -27,6 +29,31 @@ export function FileSizeDisplay({ files, plan, t }) {
return <CheckCircle className="h-4 w-4" />;
};
const getStatusText = (percentage) => {
if (percentage >= 90) return t("storageFull");
if (percentage >= 70) return t("storageHigh");
return t("storageGood");
};
// 处理无数据或异常情况
if (!bucketUsage || maxSize <= 0) {
return (
<div className="mx-auto w-full max-w-md p-4">
<div className="mb-3 flex items-center gap-2">
<HardDrive className="h-5 w-5 text-neutral-600 dark:text-neutral-200" />
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
{t("storageUsage")}
</h3>
</div>
<div className="rounded-md bg-neutral-50 p-3 dark:bg-neutral-800">
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t("storageDataUnavailable")}
</span>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-md p-4">
{/* 标题 */}
@@ -41,7 +68,7 @@ export function FileSizeDisplay({ files, plan, t }) {
<div className="mb-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-neutral-500 dark:text-neutral-300">
{t("used")}
{bucketName ? `${bucketName}` : t("storageQuota")}
</span>
<span className="text-xs text-neutral-500 dark:text-neutral-300">
{usagePercentage.toFixed(1)}%
@@ -56,53 +83,58 @@ export function FileSizeDisplay({ files, plan, t }) {
</div>
{/* 详细信息 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600 dark:text-neutral-300">
<div className="mb-3 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-neutral-600 dark:text-neutral-300">
{t("usedSpace")}:
</span>
<span className="text-sm font-medium">
<span className="font-medium">
{formatFileSize(totalSize, { precision: 2 })}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600 dark:text-neutral-300">
<div className="flex items-center justify-between text-xs">
<span className="text-neutral-600 dark:text-neutral-300">
{t("totalCapacity")}:
</span>
<span className="text-sm font-medium">
<span className="font-medium">
{formatFileSize(maxSize, { precision: 2 })}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600 dark:text-neutral-300">
<div className="flex items-center justify-between text-xs">
<span className="text-neutral-600 dark:text-neutral-300">
{t("availableSpace")}:
</span>
<span className="text-sm font-medium">
<span className="font-medium">
{formatFileSize(maxSize - totalSize, { precision: 2 })}
</span>
</div>
{bucketUsage?.usage?.totalFiles !== undefined && (
<div className="flex items-center justify-between text-xs">
<span className="text-neutral-600 dark:text-neutral-300">
{t("totalFiles")}:
</span>
<span className="font-medium">
{bucketUsage.usage.totalFiles.toLocaleString()} /{" "}
{nFormatter(bucketUsage.limits.maxFiles)}
</span>
</div>
)}
</div>
{/* 状态提示 */}
<div
className={`mt-3 flex items-center gap-2 rounded-md bg-neutral-50 p-2 dark:bg-neutral-800 ${getStatusColor(usagePercentage)}`}
className={`flex items-center gap-2 rounded-md bg-neutral-50 p-2 dark:bg-neutral-800 ${getStatusColor(usagePercentage)}`}
>
{getStatusIcon(usagePercentage)}
<span className="text-xs">
{usagePercentage >= 90
? t("storageFull")
: usagePercentage >= 70
? t("storageHigh")
: t("storageGood")}
</span>
<span className="text-xs">{getStatusText(usagePercentage)}</span>
</div>
</div>
);
}
export function CircularStorageIndicator({ files, plan, size = 32 }) {
const totalSize = files?.totalSize || 0;
const maxSize = Number(plan?.stMaxTotalSize || 0);
export function CircularStorageIndicator({ bucketUsage, size = 32 }) {
const totalSize = bucketUsage?.usage?.totalSize || 0;
const maxSize = bucketUsage?.limits?.maxStorage || 0;
const usagePercentage =
maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
@@ -119,6 +151,20 @@ export function CircularStorageIndicator({ files, plan, size = 32 }) {
return "#3b82f6"; // blue-500
};
// 处理无数据情况
if (!bucketUsage || maxSize <= 0) {
return (
<div
className="relative flex items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-700"
style={{ width: size, height: size }}
>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
-
</span>
</div>
);
}
return (
<div
className="relative flex cursor-pointer items-center"
+36 -26
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import { Play, RotateCcw, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
@@ -17,7 +17,7 @@ import {
import { CopyButton } from "@/components/shared/copy-button";
import { Icons } from "@/components/shared/icons";
import { BucketInfo, StorageUserPlan } from ".";
import { BucketInfo, BucketUsage, StorageUserPlan } from ".";
import DragAndDrop from "./drag-and-drop";
export const FileUploader = ({
@@ -25,12 +25,13 @@ export const FileUploader = ({
action,
plan,
userId,
onRefresh,
bucketUsage,
}: {
bucketInfo: BucketInfo;
action: string;
userId: string;
plan?: StorageUserPlan;
bucketUsage: BucketUsage;
onRefresh: () => void;
}) => {
const t = useTranslations("Components");
@@ -98,11 +99,11 @@ export const FileUploader = ({
</div>
<Badge className="text-xs">
{t("Limit")}:{" "}
{formatFileSize(Number(plan?.stMaxFileSize || "0"), {
precision: 0,
{formatFileSize(bucketUsage.limits.maxSingleFileSize, {
precision: 1,
})}{" "}
/{" "}
{formatFileSize(Number(plan?.stMaxTotalSize || "0"), {
{formatFileSize(bucketUsage.limits.maxStorage, {
precision: 0,
})}
</Badge>
@@ -257,27 +258,36 @@ 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-right text-xs text-red-600 dark:text-red-400">
{file.error}
</div>
)}
</div>
)
)}
+2 -4
View File
@@ -407,11 +407,10 @@ export function PlanForm({
</FormSectionColumns>
</div>
<div className="relative grid-cols-1 gap-2 rounded-md border bg-neutral-50 px-3 pb-3 pt-8 dark:bg-neutral-900 md:grid md:grid-cols-2">
{/* <div className="relative grid-cols-1 gap-2 rounded-md border bg-neutral-50 px-3 pb-3 pt-8 dark:bg-neutral-900 md:grid md:grid-cols-2">
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
{t("Storage Service")}
</h2>
{/* Max File Size - stMaxFileSize */}
<FormSectionColumns title={t("Max File Size")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="Record-Limit">
@@ -445,7 +444,6 @@ export function PlanForm({
)}
</div>
</FormSectionColumns>
{/* Max File Size - stMaxTotalSize */}
<FormSectionColumns title={t("Max Total Size")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="Record-Limit">
@@ -480,7 +478,7 @@ export function PlanForm({
)}
</div>
</FormSectionColumns>
</div>
</div> */}
{/* Action buttons */}
<div className="mt-3 flex justify-end gap-3">
+22 -3
View File
@@ -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,
),
+45 -3
View File
@@ -159,18 +159,22 @@ export async function getUserFiles(options: QueryUserFileOptions = {}) {
prisma.userFile.count({ where }),
prisma.userFile.aggregate({
where: {
// bucket,
// providerName,
bucket,
providerName,
status: 1,
...(userId && { userId }),
},
_sum: { size: true },
_count: {
id: true,
},
}),
]);
return {
total,
totalSize: storageValueToBytes(totalSize._sum.size || 0),
totalFiles: totalSize._count.id || 0,
list: files,
};
} catch (error) {
@@ -276,7 +280,7 @@ export async function getUserFileStats(userId: string) {
success: true,
data: {
totalFiles,
totalSize: totalSize._sum.size || 0,
totalSize: storageValueToBytes(totalSize._sum.size || 0),
filesByProvider,
},
};
@@ -359,3 +363,41 @@ 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,
userId?: string,
): Promise<
| { success: true; data: { totalSize: number; totalFiles: number } }
| { success: false; error: string }
> {
try {
const result = await prisma.userFile.aggregate({
where: {
...(userId && { userId }),
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" };
}
}
+4 -2
View File
@@ -32,8 +32,10 @@ export interface BucketItem {
bucket: string;
custom_domain?: string;
prefix?: string;
file_types?: string;
file_size?: string;
file_types?: string; // 允许上传的文件类型
file_size?: string; // 单个文件限制(字节)
max_files?: string; // 存储桶最大文件数量
max_storage?: string; // 存储桶最大存储容量(字节)
region?: string;
public: boolean;
}
+2 -2
View File
@@ -12,8 +12,8 @@ export const createRecordSchema = z.object({
.string()
.regex(/^[a-zA-Z0-9-_]+$/, "Invalid characters")
.min(1)
.max(32),
content: z.string().min(1).max(32),
.max(64),
content: z.string().min(1).max(1024),
ttl: z.number().min(1).max(36000).default(1),
proxied: z.boolean().default(false),
comment: z.string().optional(),
+13 -1
View File
@@ -211,9 +211,17 @@
"usedSpace": "Used Space",
"totalCapacity": "Total Capacity",
"availableSpace": "Available Space",
"totalFiles": "Total Files",
"storageDataUnavailable": "Storage data is unavailable",
"storageFull": "Storage space is almost full",
"storageHigh": "Storage space usage is high",
"storageGood": "Storage space is sufficient",
"planQuota": "Plan Quota",
"planUsagePercentage": "Usage Percentage",
"bucketQuota": "Bucket Quota",
"bucketCapacity": "Bucket Capacity",
"bucketStorageFull": "Bucket storage is almost full",
"bucketStorageHigh": "Bucket storage usage is high",
"items": "items",
"Total": "Total",
"Configuration Error": "Configuration Error"
@@ -360,6 +368,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 +632,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",
@@ -632,6 +643,7 @@
"Unique": "Unique",
"Add Provider": "Add Provider",
"{length} Buckets": "{length} Buckets",
"Save Modifications": "Save Modifications"
"Save Modifications": "Save Modifications",
"Max File Count": "Max File Count"
}
}
+12
View File
@@ -211,9 +211,17 @@
"usedSpace": "已使用空间",
"totalCapacity": "总容量",
"availableSpace": "剩余空间",
"totalFiles": "总文件数",
"storageDataUnavailable": "无数据",
"storageFull": "存储空间即将用完",
"storageHigh": "存储空间使用较多",
"storageGood": "存储空间充足",
"planQuota": "计划配额",
"planUsagePercentage": "使用率",
"bucketQuota": "存储桶配额",
"bucketCapacity": "存储桶容量",
"bucketStorageFull": "存储桶空间即将用完",
"bucketStorageHigh": "存储桶空间使用较多",
"items": "条",
"Total": "共",
"Configuration Error": "配置错误"
@@ -360,6 +368,7 @@
"Uploading": "上传中",
"Completed": "已完成",
"Aborted": "已中止",
"Failed": "失败",
"Drop files to upload them to": "将文件上传到",
"Drag and drop file(s) here": "将文件拖到此处上传",
"or": "或",
@@ -620,9 +629,12 @@
"Bucket Name": "存储桶名称",
"Public Domain": "公开域名或自定义域名",
"Max File Size": "上传文件大小限制",
"Max File Count": "文件数量限制",
"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": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶",
@@ -9,7 +9,7 @@ INSERT INTO "system_configs"
VALUES
(
's3_config_list',
'[{"enabled":true,"platform":"cloudflare","channel":"r2","provider_name":"Cloudflare R2","account_id":"","access_key_id":"","secret_access_key":"","endpoint":"https://<account_id>.r2.cloudflarestorage.com","buckets":[{"bucket":"","prefix":"","file_types":"","region":"auto","custom_domain":"","file_size":"26214400","public":true}]},{"enabled":false,"platform":"tencent","channel":"cos","provider_name":"腾讯云 COS","endpoint":"","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"","public":true}]}]',
'[{"enabled":true,"platform":"cloudflare","channel":"r2","provider_name":"Cloudflare R2","account_id":"","access_key_id":"","secret_access_key":"","endpoint":"https://<account_id>.r2.cloudflarestorage.com","buckets":[{"bucket":"","prefix":"","file_types":"","region":"auto","custom_domain":"","file_size":"26214400","max_storage":"1073741824","max_files":"1000","public":true}]},{"enabled":false,"platform":"tencent","channel":"cos","provider_name":"腾讯云 COS","endpoint":"","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","max_storage":"1073741824","max_files":"1000","region":"","public":true}]}]',
'OBJECT',
'R2 存储桶配置'
);
+1 -1
View File
File diff suppressed because one or more lines are too long