feat: add file upload limit on plan
This commit is contained in:
@@ -324,7 +324,7 @@ export default function S3Configs({}: {}) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{/* <div className="space-y-1">
|
||||
<Label>{t("Max File Size")} (Bytes)</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
@@ -349,7 +349,7 @@ export default function S3Configs({}: {}) {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Region")}</Label>
|
||||
<Input
|
||||
|
||||
@@ -52,6 +52,9 @@ export async function POST(req: NextRequest) {
|
||||
rcNewRecords: plan.rcNewRecords,
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
@@ -95,6 +98,9 @@ export async function PUT(req: NextRequest) {
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: plan.isActive,
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
S3Client,
|
||||
UploadPartCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { createUserFile } from "@/lib/dto/files";
|
||||
import { getPlanQuota } from "@/lib/dto/plan";
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { CloudStorageCredentials, createS3Client } from "@/lib/r2";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { restrictByTimeRange } from "@/lib/team";
|
||||
import { extractFileNameAndExtension, generateFileKey } from "@/lib/utils";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
@@ -52,7 +55,7 @@ export async function POST(request: Request): Promise<Response> {
|
||||
|
||||
switch (endpoint) {
|
||||
case "create-multipart-upload":
|
||||
return createMultipartUpload(formData, R2);
|
||||
return createMultipartUpload(user, formData, R2);
|
||||
case "complete-multipart-upload":
|
||||
return completeMultipartUpload(
|
||||
formData,
|
||||
@@ -73,14 +76,25 @@ export async function POST(request: Request): Promise<Response> {
|
||||
|
||||
// Initiates a multipart upload
|
||||
async function createMultipartUpload(
|
||||
user: User,
|
||||
formData: FormData,
|
||||
R2: S3Client,
|
||||
): Promise<Response> {
|
||||
const fileName = formData.get("fileName") as string;
|
||||
const fileType = formData.get("fileType") as string;
|
||||
const fileSize = Number(formData.get("fileSize") as string);
|
||||
const bucket = formData.get("bucket") as string;
|
||||
const prefix = (formData.get("prefix") as string) || "";
|
||||
|
||||
const plan = await getPlanQuota(user.team!);
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userUrl",
|
||||
userId: user.id,
|
||||
limit: plan.stMaxFileSize,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit) return Response.json(limit.statusText, { status: limit.status });
|
||||
|
||||
const fileKey = generateFileKey(fileName, prefix);
|
||||
try {
|
||||
const params = {
|
||||
|
||||
@@ -10,11 +10,10 @@ import { ViewTransitions } from "next-view-transitions";
|
||||
import { cn, constructMetadata } from "@/lib/utils";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import ModalProvider from "@/components/modals/providers";
|
||||
import GoogleAnalytics from "@/components/shared/GoogleAnalytics";
|
||||
import UmamiAnalytics from "@/components/shared/UmamiAnalytics";
|
||||
import { TailwindIndicator } from "@/components/tailwind-indicator";
|
||||
|
||||
import GoogleAnalytics from "../components/shared/GoogleAnalytics";
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { toast } from "sonner";
|
||||
import { UserFileData } from "@/lib/dto/files";
|
||||
import {
|
||||
cn,
|
||||
downloadFile,
|
||||
downloadFileFromUrl,
|
||||
formatDate,
|
||||
formatFileSize,
|
||||
@@ -217,22 +216,25 @@ export default function UserFileList({
|
||||
<>
|
||||
{file.shortUrlId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.link className="size-3 flex-shrink-0 text-blue-500" />
|
||||
<Icons.unLink className="size-3 flex-shrink-0 text-blue-500" />
|
||||
<Link
|
||||
href={"https://" + shortLinks[index]}
|
||||
className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500"
|
||||
className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800"
|
||||
target="_blank"
|
||||
>
|
||||
https://{shortLinks[index]}
|
||||
</Link>
|
||||
<CopyButton className="size-6" value={getFileUrl(file.path)} />
|
||||
<CopyButton
|
||||
className="size-6"
|
||||
value={`https://${shortLinks[index]}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.link className="size-3 flex-shrink-0" />
|
||||
<Link
|
||||
href={getFileUrl(file.path)}
|
||||
className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500"
|
||||
className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800"
|
||||
target="_blank"
|
||||
>
|
||||
{getFileUrl(file.path)}
|
||||
@@ -241,7 +243,7 @@ export default function UserFileList({
|
||||
</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">
|
||||
<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
|
||||
@@ -251,7 +253,7 @@ export default function UserFileList({
|
||||
</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">
|
||||
<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
|
||||
|
||||
@@ -9,7 +9,7 @@ import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { UserFileData } from "@/lib/dto/files";
|
||||
import { BucketItem, ClientStorageCredentials } from "@/lib/r2";
|
||||
import { cn, fetcher, formatFileSize } from "@/lib/utils";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -21,6 +21,12 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import UserFileList from "@/components/file/file-list";
|
||||
import Uploader from "@/components/file/uploader";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
@@ -34,6 +40,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { CircularStorageIndicator, FileSizeDisplay } from "./storage-size";
|
||||
|
||||
export interface FileListProps {
|
||||
user: Pick<User, "id" | "name" | "apiKey" | "email" | "role" | "team">;
|
||||
@@ -54,6 +61,11 @@ export interface FileListData {
|
||||
list: UserFileData[];
|
||||
}
|
||||
|
||||
export interface StorageUserPlan {
|
||||
stMaxTotalSize: string;
|
||||
stMaxFileSize: string;
|
||||
}
|
||||
|
||||
export default function UserFileManager({ user, action }: FileListProps) {
|
||||
const t = useTranslations("List");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -72,8 +84,6 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
||||
const [selectedFiles, setSelectedFiles] = useState<UserFileData[]>([]);
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
|
||||
const [userStorageLimit, setUserStorageLimit] = useState(524288000); // 500 MB
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const { data: r2Configs, isLoading } = useSWR<ClientStorageCredentials>(
|
||||
@@ -93,6 +103,11 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const { data: plan } = useSWR<StorageUserPlan>(
|
||||
`/api/plan?team=${user.team}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (r2Configs && r2Configs.buckets && r2Configs.buckets.length > 0) {
|
||||
setBucketInfo({
|
||||
@@ -169,11 +184,21 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{files && (
|
||||
<p>
|
||||
{formatFileSize(files.totalSize)}/
|
||||
{formatFileSize(userStorageLimit)}
|
||||
</p>
|
||||
{files && files.totalSize > 0 && plan && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger className="flex items-center gap-2">
|
||||
<CircularStorageIndicator
|
||||
files={files}
|
||||
plan={plan}
|
||||
size={36}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="w-80">
|
||||
<FileSizeDisplay files={files} plan={plan} t={t} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
@@ -210,6 +235,7 @@ export default function UserFileManager({ user, action }: FileListProps) {
|
||||
bucketInfo={bucketInfo}
|
||||
action={action}
|
||||
onRefresh={handleRefresh}
|
||||
plan={plan}
|
||||
/>
|
||||
|
||||
<div className="flex items-center">
|
||||
|
||||
159
components/file/storage-size.tsx
Normal file
159
components/file/storage-size.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from "react";
|
||||
import { AlertTriangle, CheckCircle, HardDrive } from "lucide-react";
|
||||
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
|
||||
export function FileSizeDisplay({ files, plan, t }) {
|
||||
const totalSize = files?.totalSize || 0;
|
||||
const maxSize = Number(plan?.stMaxTotalSize || 0);
|
||||
const usagePercentage =
|
||||
maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
|
||||
|
||||
const getStatusColor = (percentage) => {
|
||||
if (percentage >= 90) return "text-red-600";
|
||||
if (percentage >= 70) return "text-yellow-600";
|
||||
return "text-green-600";
|
||||
};
|
||||
|
||||
const getProgressColor = (percentage) => {
|
||||
if (percentage >= 90) return "bg-red-500";
|
||||
if (percentage >= 70) return "bg-yellow-500";
|
||||
return "bg-blue-500";
|
||||
};
|
||||
|
||||
const getStatusIcon = (percentage) => {
|
||||
if (percentage >= 90) return <AlertTriangle className="h-4 w-4" />;
|
||||
if (percentage >= 70) return <AlertTriangle className="h-4 w-4" />;
|
||||
return <CheckCircle className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
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="mb-3">
|
||||
<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">
|
||||
{usagePercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-neutral-200 dark:bg-neutral-600">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getProgressColor(usagePercentage)}`}
|
||||
style={{ width: `${usagePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("usedSpace")}:
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatFileSize(totalSize, { precision: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("totalCapacity")}:
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatFileSize(maxSize, { precision: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t("availableSpace")}:
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatFileSize(maxSize - totalSize, { precision: 0 })}
|
||||
</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)}`}
|
||||
>
|
||||
{getStatusIcon(usagePercentage)}
|
||||
<span className="text-xs">
|
||||
{usagePercentage >= 90
|
||||
? t("storageFull")
|
||||
: usagePercentage >= 70
|
||||
? t("storageHigh")
|
||||
: t("storageGood")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CircularStorageIndicator({ files, plan, 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 radius = (size - 6) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset =
|
||||
circumference - (usagePercentage / 100) * circumference;
|
||||
|
||||
// 根据使用率确定颜色
|
||||
const getColor = (percentage) => {
|
||||
if (percentage >= 90) return "#ef4444"; // red-500
|
||||
if (percentage >= 70) return "#f59e0b"; // amber-500
|
||||
return "#3b82f6"; // blue-500
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-block"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg width={size} height={size} className="-rotate-90 transform">
|
||||
{/* 背景圆圈 */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
/>
|
||||
{/* 进度圆圈 */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={getColor(usagePercentage)}
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="transition-all duration-300 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
className="absolute inset-0 flex scale-[.85] items-center justify-center text-xs font-medium"
|
||||
style={{ color: getColor(usagePercentage) }}
|
||||
>
|
||||
{Math.round(usagePercentage)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { BucketInfo } from "@/components/file";
|
||||
import { BucketInfo, StorageUserPlan } from "@/components/file";
|
||||
|
||||
import { Icons } from "../shared/icons";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -38,10 +38,12 @@ export type UploadProgressType = {
|
||||
export default function Uploader({
|
||||
bucketInfo,
|
||||
action,
|
||||
plan,
|
||||
onRefresh,
|
||||
}: {
|
||||
bucketInfo: BucketInfo;
|
||||
action: string;
|
||||
plan?: StorageUserPlan;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const t = useTranslations("Components");
|
||||
@@ -67,6 +69,7 @@ export default function Uploader({
|
||||
const formData = new FormData();
|
||||
formData.append("fileName", file.name);
|
||||
formData.append("fileType", file.type);
|
||||
formData.append("fileSize", file.size.toString());
|
||||
formData.append("bucket", bucketInfo.bucket);
|
||||
formData.append("prefix", bucketInfo.prefix || "");
|
||||
formData.append("endPoint", "create-multipart-upload");
|
||||
@@ -181,9 +184,9 @@ export default function Uploader({
|
||||
|
||||
const uploadFile = async (file: File): Promise<void> => {
|
||||
try {
|
||||
if (file.size > Number(bucketInfo.file_size || "26214400")) {
|
||||
if (file.size > Number(plan?.stMaxFileSize || "26214400")) {
|
||||
toast.warning("Upload Failed", {
|
||||
description: `File '${file.name}' size exceeds the maximum allowed size of ${formatFileSize(Number(bucketInfo.file_size || "0"))} bytes.`,
|
||||
description: `File '${file.name}' size exceeds the maximum allowed size of ${formatFileSize(Number(plan?.stMaxFileSize || "0"))} bytes.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -271,7 +274,7 @@ export default function Uploader({
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<DrawerDescription className="px-4">
|
||||
<DrawerDescription className="flex items-center justify-between px-4">
|
||||
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
|
||||
<div className="truncate">{bucketInfo.provider_name}</div>
|
||||
<Icons.arrowRight className="size-3" />
|
||||
@@ -279,6 +282,12 @@ export default function Uploader({
|
||||
{bucketInfo.bucket}
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Max:{" "}
|
||||
{formatFileSize(Number(plan?.stMaxFileSize || "0"), {
|
||||
precision: 0,
|
||||
})}
|
||||
</p>
|
||||
</DrawerDescription>
|
||||
|
||||
<div className="space-y-4 p-4">
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Dispatch, SetStateAction, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { create } from "lodash";
|
||||
import { create, get } from "lodash";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PlanQuotaFormData } from "@/lib/dto/plan";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { createPlanSchema } from "@/lib/validations/plan";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -43,6 +44,12 @@ export function PlanForm({
|
||||
const t = useTranslations("List");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
const [currentStMaxTotalSize, setCurrentStMaxTotalSize] = useState(
|
||||
initData?.stMaxTotalSize || "5242880000",
|
||||
);
|
||||
const [currentStMaxFileSize, setCurrentStMaxFileSize] = useState(
|
||||
initData?.stMaxFileSize || "26214400",
|
||||
);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -64,6 +71,9 @@ export function PlanForm({
|
||||
emEmailAddresses: initData?.emEmailAddresses || 100,
|
||||
emDomains: initData?.emDomains || 1,
|
||||
emSendEmails: initData?.emSendEmails || 100,
|
||||
stMaxFileSize: initData?.stMaxFileSize || "26214400",
|
||||
stMaxTotalSize: initData?.stMaxTotalSize || "524288000",
|
||||
stMaxFileCount: initData?.stMaxFileCount || 1000,
|
||||
appSupport: initData?.appSupport || "BASIC",
|
||||
appApiAccess: initData?.appApiAccess || false,
|
||||
isActive: initData?.isActive || false,
|
||||
@@ -397,6 +407,81 @@ 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">
|
||||
<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">
|
||||
{t("Max File Size")}
|
||||
</Label>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="max-file-size"
|
||||
className="shadow-inner"
|
||||
size={32}
|
||||
{...register("stMaxFileSize")}
|
||||
onChange={(e) => setCurrentStMaxFileSize(e.target.value)}
|
||||
/>
|
||||
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
|
||||
=
|
||||
{formatFileSize(Number(currentStMaxFileSize), {
|
||||
precision: 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.stMaxFileSize ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.stMaxFileSize.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Maximum uploaded single file size in bytes")}.
|
||||
</p>
|
||||
)}
|
||||
</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">
|
||||
{t("Max Total Size")}
|
||||
</Label>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="max-total-size"
|
||||
className="shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
{...register("stMaxTotalSize")}
|
||||
onChange={(e) => setCurrentStMaxTotalSize(e.target.value)}
|
||||
/>
|
||||
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
|
||||
=
|
||||
{formatFileSize(Number(currentStMaxTotalSize), {
|
||||
precision: 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.stMaxTotalSize ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.stMaxTotalSize.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Maximum uploaded total file size in bytes")}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex justify-end gap-3">
|
||||
{type === "edit" && initData?.name !== "free" && (
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
SunMedium,
|
||||
Trash2,
|
||||
Type,
|
||||
Unlink,
|
||||
Unplug,
|
||||
User,
|
||||
UserCog,
|
||||
@@ -378,6 +379,7 @@ export const Icons = {
|
||||
</svg>
|
||||
),
|
||||
link: Link,
|
||||
unLink: Unlink,
|
||||
mail: Mail,
|
||||
mailPlus: MailPlus,
|
||||
mailOpen: MailOpen,
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface PlanQuota {
|
||||
emEmailAddresses: number;
|
||||
emDomains: number;
|
||||
emSendEmails: number;
|
||||
stMaxFileSize: string;
|
||||
stMaxTotalSize: string;
|
||||
stMaxFileCount: number;
|
||||
appSupport: string;
|
||||
appApiAccess: boolean;
|
||||
isActive: boolean;
|
||||
@@ -42,6 +45,9 @@ export async function getPlanQuota(planName: string) {
|
||||
emEmailAddresses: 0,
|
||||
emDomains: 0,
|
||||
emSendEmails: 0,
|
||||
stMaxFileSize: 26214400,
|
||||
stMaxTotalSize: 524288000,
|
||||
stMaxFileCount: 1000,
|
||||
appSupport: "BASIC",
|
||||
appApiAccess: true,
|
||||
isActive: true,
|
||||
@@ -60,6 +66,9 @@ export async function getPlanQuota(planName: string) {
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
appSupport: plan.appSupport.toLowerCase(),
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: plan.isActive,
|
||||
@@ -121,6 +130,9 @@ export async function updatePlanQuota(plan: PlanQuotaFormData) {
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: plan.isActive,
|
||||
@@ -144,6 +156,9 @@ export async function createPlan(plan: PlanQuota) {
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: true,
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function restrictByTimeRange({
|
||||
if (count >= limit) {
|
||||
return {
|
||||
status: 409,
|
||||
statusText: `You have exceeded the ${rangeType}ly ${model.toString()} creation limit (${limit}). Please try again later.`,
|
||||
statusText: `You have exceeded the ${rangeType}ly ${model.toString()} usage limit (${limit}). Please try again later.`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -13,6 +13,9 @@ export const createPlanSchema = z.object({
|
||||
emEmailAddresses: z.number().optional().default(0),
|
||||
emDomains: z.number().optional().default(0),
|
||||
emSendEmails: z.number().optional().default(0),
|
||||
stMaxFileSize: z.string().optional().default("26214400"),
|
||||
stMaxTotalSize: z.string().optional().default("524288000"),
|
||||
stMaxFileCount: z.number().optional().default(1000),
|
||||
appSupport: z.string().optional().default("BASIC"),
|
||||
appApiAccess: z.boolean().optional().default(true),
|
||||
isActive: z.boolean().optional().default(true),
|
||||
|
||||
@@ -200,7 +200,20 @@
|
||||
"Delete selected": "Delete selected",
|
||||
"Update short link": "Update short link",
|
||||
"QR Code": "QR Code",
|
||||
"Raw Data": "Raw Data"
|
||||
"Raw Data": "Raw Data",
|
||||
"Storage Service": "Storage Service",
|
||||
"Max File Size": "Max File Size",
|
||||
"Maximum uploaded single file size in bytes": "Maximum uploaded single file size in bytes",
|
||||
"Max Total Size": "Max Total Size",
|
||||
"Maximum uploaded total file size in bytes": "Maximum uploaded total file size in bytes",
|
||||
"storageUsage": "Storage Usage",
|
||||
"used": "Used",
|
||||
"usedSpace": "Used Space",
|
||||
"totalCapacity": "Total Capacity",
|
||||
"availableSpace": "Available Space",
|
||||
"storageFull": "Storage space is almost full",
|
||||
"storageHigh": "Storage space usage is high",
|
||||
"storageGood": "Storage space is sufficient"
|
||||
},
|
||||
"Components": {
|
||||
"Dashboard": "Dashboard",
|
||||
|
||||
@@ -200,7 +200,20 @@
|
||||
"Delete selected": "删除选中",
|
||||
"Update short link": "更新短链",
|
||||
"QR Code": "二维码",
|
||||
"Raw Data": "在线预览"
|
||||
"Raw Data": "在线预览",
|
||||
"Storage Service": "存储服务",
|
||||
"Max File Size": "单文件大小上限",
|
||||
"Maximum uploaded single file size in bytes": "单个文件最大上传大小(字节)",
|
||||
"Max Total Size": "总文件大小上限",
|
||||
"Maximum uploaded total file size in bytes": "所有文件最大上传总大小(字节)",
|
||||
"storageUsage": "存储空间使用情况",
|
||||
"used": "已使用",
|
||||
"usedSpace": "已使用空间",
|
||||
"totalCapacity": "总容量",
|
||||
"availableSpace": "剩余空间",
|
||||
"storageFull": "存储空间即将用完",
|
||||
"storageHigh": "存储空间使用较多",
|
||||
"storageGood": "存储空间充足"
|
||||
},
|
||||
"Components": {
|
||||
"Dashboard": "用户面板",
|
||||
|
||||
@@ -122,5 +122,4 @@ const withPWA = nextPWA({
|
||||
disable: false,
|
||||
});
|
||||
|
||||
// module.exports = withContentlayer(withPWA(withNextIntl(nextConfig)));
|
||||
export default withContentlayer(withPWA(withNextIntl(nextConfig)));
|
||||
|
||||
@@ -27,3 +27,9 @@ ON "user_files"("userId", "providerName", "status", "lastModified", "createdAt")
|
||||
|
||||
ALTER TABLE "user_files" ADD CONSTRAINT "user_files_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- BigInt
|
||||
ALTER TABLE "plans" ADD COLUMN "stMaxFileSize" TEXT NOT NULL DEFAULT '26214400';
|
||||
ALTER TABLE "plans" ADD COLUMN "stMaxTotalSize" TEXT NOT NULL DEFAULT '524288000';
|
||||
ALTER TABLE "plans" ADD COLUMN "stMaxFileCount" INTEGER NOT NULL DEFAULT 1000;
|
||||
|
||||
|
||||
@@ -313,6 +313,11 @@ model Plan {
|
||||
emDomains Int
|
||||
emSendEmails Int
|
||||
|
||||
// Storage (ST) related settings
|
||||
stMaxFileSize String @default("26214400")
|
||||
stMaxTotalSize String @default("524288000")
|
||||
stMaxFileCount Int @default(1000)
|
||||
|
||||
// App (APP) related settings
|
||||
appSupport String // "BASIC", "LIVE"
|
||||
appApiAccess Boolean
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user