feat: add file upload limit on plan

This commit is contained in:
oiov
2025-07-07 21:30:01 +08:00
parent 92bbb4468a
commit 436a30e9d0
19 changed files with 386 additions and 30 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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">

View 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>
);
}

View File

@@ -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">

View File

@@ -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" && (

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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),

View File

@@ -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",

View File

@@ -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": "用户面板",

View File

@@ -122,5 +122,4 @@ const withPWA = nextPWA({
disable: false,
});
// module.exports = withContentlayer(withPWA(withNextIntl(nextConfig)));
export default withContentlayer(withPWA(withNextIntl(nextConfig)));

View File

@@ -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;

View File

@@ -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