feats: file list render

This commit is contained in:
oiov
2025-07-06 18:56:41 +08:00
parent dc06fa446e
commit a0d7c1071a
18 changed files with 3363 additions and 383 deletions
+45 -32
View File
@@ -9,7 +9,7 @@ import { toast } from "sonner";
import useSWR from "swr";
import { CloudStorageCredentials } from "@/lib/r2";
import { cn, fetcher } from "@/lib/utils";
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";
@@ -140,7 +140,7 @@ export default function S3Configs({}: {}) {
<CollapsibleContent className="mt-3 space-y-4 rounded-lg border p-6 shadow-md">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label>Endpoint</Label>
<Label>{t("Endpoint")}</Label>
<Input
value={r2Credentials.endpoint}
placeholder="https://<account_id>.r2.cloudflarestorage.com"
@@ -153,7 +153,7 @@ export default function S3Configs({}: {}) {
/>
</div>
<div className="space-y-1">
<Label>Access Key ID</Label>
<Label>{t("Access Key ID")}</Label>
<Input
value={r2Credentials.access_key_id}
onChange={(e) =>
@@ -165,7 +165,7 @@ export default function S3Configs({}: {}) {
/>
</div>
<div className="space-y-1">
<Label>Secret Access Key</Label>
<Label>{t("Secret Access Key")}</Label>
<Input
value={r2Credentials.secret_access_key}
onChange={(e) =>
@@ -177,7 +177,7 @@ export default function S3Configs({}: {}) {
/>
</div>
<div className="flex flex-col justify-center space-y-3">
<Label className="">Enabled</Label>
<Label>{t("Enabled")}</Label>
<Switch
checked={r2Credentials.enabled}
onCheckedChange={(e) =>
@@ -191,7 +191,7 @@ export default function S3Configs({}: {}) {
</div>
{r2Credentials.buckets.map((bucket, index) => (
<motion.div
className="relative grid grid-cols-1 gap-4 rounded-lg border border-dashed border-muted-foreground px-3 pb-3 pt-8 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-3"
key={`bucket-${index}`}
layout
initial={{ opacity: 0, scale: 0.9 }}
@@ -203,8 +203,8 @@ export default function S3Configs({}: {}) {
scale: { duration: 0.2 },
}}
>
<p className="absolute left-2 top-2 text-xs text-muted-foreground">
Bucket {index + 1}
<p className="absolute left-2 top-3 text-xs text-muted-foreground">
{t("Bucket")} {index + 1}
</p>
<div className="absolute right-2 top-2 flex items-center justify-between space-x-2">
{index > 0 && (
@@ -289,7 +289,7 @@ export default function S3Configs({}: {}) {
</div>
<div className="space-y-1">
<Label>Bucket Name</Label>
<Label>{t("Bucket Name")}</Label>
<Input
value={bucket.bucket}
placeholder="bucket name"
@@ -307,10 +307,10 @@ export default function S3Configs({}: {}) {
/>
</div>
<div className="space-y-1">
<Label>Custom Domain (Optional)</Label>
<Label>{t("Public Domain")}</Label>
<Input
value={bucket.custom_domain}
placeholder="https://example.com"
placeholder="https://endpoint or custom domain"
onChange={(e) => {
const newBuckets = [...r2Credentials.buckets];
newBuckets[index] = {
@@ -325,25 +325,33 @@ export default function S3Configs({}: {}) {
/>
</div>
<div className="space-y-1">
<Label>Max File Size</Label>
<Input
value={bucket.file_size}
placeholder=""
onChange={(e) => {
const newBuckets = [...r2Credentials.buckets];
newBuckets[index] = {
...bucket,
file_size: e.target.value,
};
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
}}
/>
<Label>{t("Max File Size")} (Bytes)</Label>
<div className="relative">
<Input
value={bucket.file_size}
placeholder=""
onChange={(e) => {
const newBuckets = [...r2Credentials.buckets];
newBuckets[index] = {
...bucket,
file_size: e.target.value,
};
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
}}
/>
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
=
{formatFileSize(Number(bucket.file_size || "0"), {
precision: 0,
})}
</span>
</div>
</div>
<div className="space-y-1">
<Label>Region</Label>
<Label>{t("Region")}</Label>
<Input
value={bucket.region}
placeholder="auto"
@@ -361,10 +369,12 @@ export default function S3Configs({}: {}) {
/>
</div>
<div className="space-y-1">
<Label>Prefix (Optional)</Label>
<Label>
{t("Prefix")} ({t("Optional")})
</Label>
<Input
value={bucket.prefix}
placeholder=""
placeholder="2025/08/08"
onChange={(e) => {
const newBuckets = [...r2Credentials.buckets];
newBuckets[index] = {
@@ -379,10 +389,13 @@ export default function S3Configs({}: {}) {
/>
</div>
<div className="space-y-1">
<Label>Allowed File Types (Optional)</Label>
<Label>
{t("Allowed File Types")} ({t("Optional")})
</Label>
<Input
value={bucket.file_types}
placeholder="png,jpg"
placeholder=""
disabled
onChange={(e) => {
const newBuckets = [...r2Credentials.buckets];
newBuckets[index] = {
@@ -8,12 +8,6 @@ export default function DashboardRecordsLoading() {
heading="Manage DNS Records"
text="List and manage records"
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
</div>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
+12 -11
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getUserFiles } from "@/lib/dto/files";
import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import {
@@ -118,9 +118,9 @@ export async function DELETE(request: NextRequest) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { key, bucket } = await request.json();
const { keys, ids, bucket } = await request.json();
if (!key || !bucket) {
if (!keys || !ids || !bucket) {
return NextResponse.json("key and bucket is required", {
status: 400,
});
@@ -149,15 +149,16 @@ export async function DELETE(request: NextRequest) {
});
}
await deleteFile(
key,
createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
),
bucket,
const R2 = createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
);
for (const key of keys) {
await deleteFile(key, R2, bucket);
}
await softDeleteUserFiles(ids);
return NextResponse.json({ message: "File deleted successfully" });
} catch (error) {
return NextResponse.json({ error: "Error deleting file" }, { status: 500 });
+1 -1
View File
@@ -10,7 +10,7 @@ import {
import { createUserFile } from "@/lib/dto/files";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import { CloudStorageCredentials, createS3Client, getFileInfo } from "@/lib/r2";
import { CloudStorageCredentials, createS3Client } from "@/lib/r2";
import { getCurrentUser } from "@/lib/session";
import { extractFileNameAndExtension, generateFileKey } from "@/lib/utils";
+494 -253
View File
@@ -1,28 +1,31 @@
"use client";
import React, { useEffect, useState, useTransition } from "react";
import React, { useState, useTransition } from "react";
import Link from "next/link";
import {
Archive,
Calendar,
Code2,
Download,
FileCode,
FileSpreadsheet,
FileText,
FileType2,
Folder,
HardDrive,
Image,
Presentation,
Trash2,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
import { FileObject } from "@/lib/r2";
import { UserFileData } from "@/lib/dto/files";
import {
extractFileNameAndExtension,
fetcher,
formatDate,
formatFileSize,
truncateMiddle,
} from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import {
Tooltip,
TooltipContent,
@@ -31,39 +34,76 @@ import {
} from "@/components/ui/tooltip";
import { BucketInfo, DisplayType } from "@/components/file";
import { CopyButton } from "../shared/copy-button";
import { EmptyPlaceholder } from "../shared/empty-placeholder";
import { Icons } from "../shared/icons";
import { PaginationWrapper } from "../shared/pagination";
import { TimeAgoIntl } from "../shared/time-ago";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { Skeleton } from "../ui/skeleton";
import { TableCell, TableRow } from "../ui/table";
export default function FileManager({
export default function UserFileList({
bucketInfo,
action,
view,
currentPage,
pageSize,
setCurrentPage,
setPageSize,
onRefresh,
}: {
bucketInfo: BucketInfo;
action: string;
view: DisplayType;
currentPage: number;
pageSize: number;
setCurrentPage: (page: number) => void;
setPageSize: (size: number) => void;
onRefresh: () => void;
}) {
const [files, setFiles] = useState<FileObject[]>([]);
const [isLoadingFiles, startLoadingFiles] = useTransition();
const t = useTranslations("List");
const { isMobile } = useMediaQuery();
useEffect(() => {
if (bucketInfo.bucket) {
fetchFiles();
const [selectedFiles, setSelectedFiles] = useState<UserFileData[]>([]);
// const [showMutiCheckBox, setShowMutiCheckBox] = useState(false);
const { data: files, isLoading } = useSWR<{
total: number;
totalSize: number;
list: UserFileData[];
}>(
bucketInfo.bucket
? `${action}/r2/files?bucket=${bucketInfo.bucket}&page=${currentPage}&size=${pageSize}`
: null,
fetcher,
{
revalidateOnFocus: false,
},
);
const handleSelectFile = (file: UserFileData) => {
if (selectedFiles.includes(file)) {
setSelectedFiles(selectedFiles.filter((f) => f.id !== file.id));
} else {
setSelectedFiles([...selectedFiles, file]);
}
}, [bucketInfo.bucket]);
};
const fetchFiles = () => {
startLoadingFiles(async () => {
try {
const response = await fetch(
`${action}/r2/files?bucket=${bucketInfo.bucket}`,
);
const data = await response.json();
setFiles(Array.isArray(data) ? data : []);
} catch (error) {
console.error("Error fetching files:", error);
setFiles([]);
}
});
const handleSelectAllFiles = () => {
if (selectedFiles.length === files?.list.length) {
setSelectedFiles([]);
} else {
setSelectedFiles(files?.list || []);
}
};
const handleDownload = async (key: string) => {
@@ -81,68 +121,211 @@ export default function FileManager({
}
};
const handleDelete = async (key: string) => {
if (!confirm("确定要删除这个文件吗?")) return;
const handleDeleteMany = async () => {
try {
await fetch(`${action}/r2/files`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
keys: selectedFiles.map((f) => f.path),
ids: selectedFiles.map((f) => f.id),
bucket: bucketInfo.bucket,
}),
});
toast.success("File deleted successfully!");
onRefresh();
} catch (error) {
console.error("Error deleting file:", error);
toast.success("Error deleting file");
}
};
const handleDeleteSingle = async (file: UserFileData) => {
if (!confirm("Are you sure you want to delete this file?")) return;
try {
await fetch(`${action}/r2/files`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, bucket: bucketInfo.bucket }),
body: JSON.stringify({
keys: [file.path],
ids: [file.id],
bucket: bucketInfo.bucket,
}),
});
alert("File deleted successfully!");
fetchFiles();
toast.success("File deleted successfully!");
onRefresh();
} catch (error) {
console.error("Error deleting file:", error);
alert("Error deleting file");
toast.success("Error deleting file");
}
};
if (files?.total === 0) {
return (
<EmptyPlaceholder className="col-span-full shadow-none">
<EmptyPlaceholder.Icon name="fileText" />
<EmptyPlaceholder.Title>{t("No Files")}</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
{t("You don't upload any files yet")}
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
);
}
const renderListView = () => (
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="grid grid-cols-12 gap-4 bg-gray-50 px-6 py-3 text-sm font-medium text-gray-700">
<div className="col-span-6"></div>
<div className="col-span-2"></div>
<div className="col-span-3"></div>
<div className="col-span-1"></div>
<div className="overflow-hidden rounded-lg border bg-primary-foreground">
<div className="text-mute-foreground grid grid-cols-5 gap-4 bg-neutral-100 px-6 py-3 text-sm font-medium dark:bg-neutral-800 sm:grid-cols-12">
<div className="col-span-1 hidden sm:flex">
<Checkbox
className="mr-3 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300"
checked={selectedFiles.length === files?.list.length}
onCheckedChange={() => handleSelectAllFiles()}
/>
</div>
<div className="col-span-3">{t("Name")}</div>
<div className="col-span-1">{t("Size")}</div>
<div className="col-span-2 hidden sm:flex">{t("Type")}</div>
<div className="col-span-2 hidden sm:flex">{t("User")}</div>
<div className="col-span-2 hidden sm:flex">{t("Date")}</div>
<div className="col-span-1">{t("Actions")}</div>
</div>
<div className="divide-y divide-gray-200">
{files.map((file) => (
<div
key={file.Key}
className="grid grid-cols-12 gap-4 px-6 py-4 transition-colors hover:bg-gray-50"
>
<div className="col-span-6 flex items-center space-x-3">
{getFileIcon(file.Key || "", bucketInfo)}
<span className="truncate text-gray-900">{file.Key}</span>
</div>
<div className="col-span-2 flex items-center text-sm text-gray-500">
<HardDrive size={16} className="mr-1" />
{formatFileSize(file.Size || 0)}
</div>
<div className="col-span-3 flex items-center text-sm text-gray-500">
<Calendar size={16} className="mr-1" />
{formatDate(file.LastModified?.toString() || "")}
</div>
<div className="col-span-1 flex items-center space-x-2">
<button
onClick={() => file.Key && handleDownload(file.Key)}
className="text-blue-500 transition-colors hover:text-blue-600"
title="下载"
{isLoading ? (
<>
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
</>
) : (
<div className="divide-y divide-neutral-200 dark:divide-neutral-600">
{files &&
files.list.map((file) => (
<div
key={file.id}
className="text-mute-foreground grid grid-cols-5 gap-4 px-6 py-4 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-600 sm:grid-cols-12"
>
<Download size={16} />
</button>
<button
onClick={() => file.Key && handleDelete(file.Key)}
className="text-red-500 transition-colors hover:text-red-600"
title="删除"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
<div
className="col-span-1 hidden items-center gap-2 sm:flex"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={
selectedFiles.find((f) => f.id === file.id) !== undefined
}
onCheckedChange={() => handleSelectFile(file)}
className="mr-3 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300"
/>
</div>
<div className="col-span-3 flex items-center space-x-3 text-sm">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger className="flex items-center justify-start gap-1 break-all text-start">
{truncateMiddle(file.path)}
<CopyButton
value={`${bucketInfo.custom_domain}/${file.path}`}
/>
</TooltipTrigger>
<TooltipContent
side="right"
className="text-wrap p-3 text-start"
>
{file.mimeType.startsWith("image/") ? (
<img
className="mb-2 rounded shadow"
width={300}
height={300}
src={
bucketInfo.custom_domain
? `${bucketInfo.custom_domain}/${file.path}`
: `${file.path}`
}
alt={`${file.path}`}
/>
) : (
file.path
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="col-span-1 flex items-center text-nowrap text-sm">
{formatFileSize(file.size || 0)}
</div>
<div className="col-span-2 hidden items-center text-sm sm:flex">
<span className="truncate">{file.mimeType || "-"}</span>
</div>
<div className="col-span-2 hidden items-center text-sm sm:flex">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger className="truncate">
{file.user.name ?? file.user.email}
</TooltipTrigger>
<TooltipContent>
<p>{file.user.name}</p>
<p>{file.user.email}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="col-span-2 hidden items-center text-nowrap text-sm sm:flex">
<TimeAgoIntl date={file.updatedAt as Date} />
</div>
<div className="col-span-1 flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="size-[25px] p-1.5"
size="sm"
variant="ghost"
>
<Icons.moreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
// onClick={() => handleDownload(file.path)}
>
<Icons.link className="size-4" />
{t("Generate short link")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
onClick={() => handleDownload(file.path)}
>
<Icons.download className="size-4" />
{t("Download")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2 text-red-500"
size="sm"
variant="ghost"
onClick={() => file.path && handleDeleteSingle(file)}
>
<Icons.trash className="size-4" />
{t("Delete File")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
)}
</div>
);
@@ -153,197 +336,255 @@ export default function FileManager({
gridTemplateColumns: "repeat(auto-fill, minmax(10px, 100px))",
}}
>
{files.map((file) => (
<div
key={file.Key}
className="group relative flex cursor-pointer items-end rounded-md transition-all"
>
<div className="flex flex-col items-center justify-center space-y-2">
{React.cloneElement(getFileIcon(file.Key || "", bucketInfo), {
size: 40,
})}
<div className="w-full text-center">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger className="mx-auto line-clamp-2 max-w-[60px] break-all px-2 pb-1 text-left text-xs font-medium text-muted-foreground group-hover:text-blue-500 sm:max-w-[100px]">
{truncateMiddle(file.Key || "")}
</TooltipTrigger>
<TooltipContent
side="bottom"
className="max-w-[300px] space-y-1 p-3 text-start"
>
{["jpg", "jpeg", "png", "gif", "webp"].includes(
extractFileNameAndExtension(file.Key || "").extension ||
"",
) && (
<img
className="mb-2 rounded shadow"
width={300}
height={300}
src={
bucketInfo.custom_domain
? `${bucketInfo.custom_domain}/${file.Key}`
: `${file.Key}`
}
alt={`${file.Key}`}
/>
)}
<p className="break-all text-sm">{file.Key}</p>
<p className="mt-1 text-xs text-muted-foreground">
<strong>Size:</strong> {formatFileSize(file.Size || 0)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
<strong>Modified:</strong>{" "}
{formatDate(file.LastModified?.toString() || "")}
</p>
<div className="flex space-x-1">
<Button
onClick={() => file.Key && handleDownload(file.Key)}
className="size-7"
title="下载"
size="icon"
variant={"blue"}
>
<Download size={14} />
</Button>
<Button
onClick={() => file.Key && handleDelete(file.Key)}
className="size-7"
title="删除"
size="icon"
variant={"destructive"}
>
<Trash2 size={14} />
</Button>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isLoading &&
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
<Skeleton key={v} className="size-[100px]" />
))}
{files &&
files.list.map((file) => (
<div
key={file.id}
className="group relative flex cursor-pointer items-end rounded-md transition-all"
>
<div className="flex flex-col items-center justify-center space-y-2">
{React.cloneElement(
getFileIcon(file.path, file.mimeType, bucketInfo),
{
size: 40,
},
)}
<div className="w-full text-center">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger className="mx-auto line-clamp-2 max-w-[60px] break-all px-2 pb-1 text-left text-xs font-medium text-muted-foreground group-hover:text-blue-500 sm:max-w-[100px]">
{truncateMiddle(file.path || "")}
</TooltipTrigger>
<TooltipContent
side="right"
className="max-w-[300px] space-y-1 p-3 text-start"
>
{file.mimeType.startsWith("image/") && (
<img
className="mb-2 rounded shadow"
width={300}
height={300}
src={
bucketInfo.custom_domain
? `${bucketInfo.custom_domain}/${file.path}`
: `${file.path}`
}
alt={`${file.path}`}
/>
)}
<div className="flex items-center gap-2 break-all text-sm">
<Link
target="_blank"
href={`${bucketInfo.custom_domain}/${file.path}`}
>
{file.path}
</Link>
<CopyButton
value={`${bucketInfo.custom_domain}/${file.path}`}
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">
<strong>Size:</strong> {formatFileSize(file.size || 0)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
<strong>Type:</strong> {file.mimeType || "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
<strong>Modified:</strong>{" "}
{formatDate(file.lastModified?.toString() || "")}
</p>
<div className="flex justify-end space-x-1">
<Button
onClick={() => file.path && handleDownload(file.path)}
className="size-7"
title="下载"
size="icon"
variant={"blue"}
>
<Download size={14} />
</Button>
<Button
onClick={() => handleDeleteSingle(file)}
className="size-7"
title="删除"
size="icon"
variant={"destructive"}
>
<Trash2 size={14} />
</Button>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</div>
))}
))}
</div>
);
return (
<div className="">
{files.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Folder size={48} className="mb-4 text-gray-400" />
{isLoadingFiles ? (
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
<span className="text-sm text-gray-500">...</span>
</div>
) : (
<p className="text-lg text-gray-500"></p>
)}
</div>
) : (
<>{view === "List" ? renderListView() : renderGridView()}</>
<>
{view === "List" ? renderListView() : renderGridView()}
{files && Math.ceil(files.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={files.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</div>
</>
);
}
const getFileIcon = (filename: string, bucketInfo: BucketInfo) => {
const ext = filename.split(".").pop()?.toLowerCase();
function TableColumnSekleton() {
return (
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-12">
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-10" />
</TableCell>
<TableCell className="col-span-3 sm:col-span-2">
<Skeleton className="h-5 w-20" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-2 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-2 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-2 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
</TableRow>
);
}
const getFileIcon = (
filename: string,
mimeType: string | null,
bucketInfo: BucketInfo,
) => {
const iconProps = { size: 24, className: "text-gray-600" };
switch (ext) {
case "jpg":
case "jpeg":
case "png":
case "gif":
case "webp":
return (
<img
className="max-h-24 max-w-24 rounded shadow"
height={60}
width={60}
src={
bucketInfo.custom_domain
? `${bucketInfo.custom_domain}/${filename}`
: filename
}
alt={filename}
/>
);
case "svg":
// 如果没有 mimeType,回退到文件夹判断
if (!mimeType) {
if (filename.endsWith("/")) {
return <Folder {...iconProps} className="text-yellow-500" />;
}
return <FileText {...iconProps} className="text-gray-500" />;
}
// 图片类型 - 直接显示图片
if (mimeType.startsWith("image/")) {
if (mimeType === "image/svg+xml") {
return <Image {...iconProps} className="text-blue-500" />;
case "zip":
case "rar":
case "7z":
case "tar":
case "gz":
return <Archive {...iconProps} className="text-orange-500" />;
case "docx":
case "doc":
return <FileText {...iconProps} className="text-blue-600" />;
case "pptx":
case "ppt":
return <FileText {...iconProps} className="text-red-500" />;
case "xlsx":
case "xls":
case "csv":
return <FileSpreadsheet {...iconProps} className="text-green-600" />;
case "json":
return <FileCode {...iconProps} className="text-yellow-600" />;
case "md":
case "markdown":
return <FileType2 {...iconProps} className="text-gray-700" />;
default:
// 检查是否是文件夹(没有扩展名且以/结尾)
if (!ext && filename.endsWith("/")) {
return <Folder {...iconProps} className="text-yellow-500" />;
}
return <FileText {...iconProps} className="text-gray-500" />;
}
// 其他图片格式显示缩略图
return (
<img
className="max-h-24 max-w-24 rounded shadow"
height={60}
width={60}
src={
bucketInfo.custom_domain
? `${bucketInfo.custom_domain}/${filename}`
: filename
}
alt={filename}
/>
);
}
};
const truncateMiddle = (text: string, maxLength: number = 20): string => {
if (text.length <= maxLength) return text;
// 找到最后一个点的位置(文件扩展名)
const lastDotIndex = text.lastIndexOf(".");
if (lastDotIndex === -1 || lastDotIndex === 0) {
// 没有扩展名,直接中间截断
const half = Math.floor((maxLength - 3) / 2);
return text.slice(0, half) + "..." + text.slice(-half);
}
const extension = text.slice(lastDotIndex);
const nameWithoutExt = text.slice(0, lastDotIndex);
// 如果扩展名太长,直接截断整个文件名
if (extension.length > maxLength / 2) {
const half = Math.floor((maxLength - 3) / 2);
return text.slice(0, half) + "..." + text.slice(-half);
}
// 计算可用于文件名的长度
const availableLength = maxLength - extension.length - 3;
if (availableLength <= 0) {
return "..." + extension;
}
// 如果文件名部分不需要截断
if (nameWithoutExt.length <= availableLength) {
return text;
}
// 中间截断文件名部分
const startLength = Math.ceil(availableLength / 2);
const endLength = Math.floor(availableLength / 2);
return (
nameWithoutExt.slice(0, startLength) +
"..." +
nameWithoutExt.slice(-endLength) +
extension
);
// 压缩文件
if (
mimeType === "application/zip" ||
mimeType === "application/x-rar-compressed" ||
mimeType === "application/x-7z-compressed" ||
mimeType === "application/x-tar" ||
mimeType === "application/gzip" ||
mimeType === "application/x-gzip"
) {
return <Archive {...iconProps} className="text-orange-500" />;
}
// Microsoft Office 文档
if (
mimeType ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
mimeType === "application/msword"
) {
return <FileText {...iconProps} className="text-blue-600" />;
}
// Microsoft Office 演示文稿
if (
mimeType ===
"application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
mimeType === "application/vnd.ms-powerpoint"
) {
return <FileText {...iconProps} className="text-red-500" />;
}
// Microsoft Office 电子表格
if (
mimeType ===
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
mimeType === "application/vnd.ms-excel" ||
mimeType === "text/csv"
) {
return <FileSpreadsheet {...iconProps} className="text-green-600" />;
}
// JSON 文件
if (mimeType === "application/json") {
return <FileCode {...iconProps} className="text-yellow-600" />;
}
// Markdown 文件
if (mimeType === "text/markdown" || mimeType === "text/x-markdown") {
return <FileType2 {...iconProps} className="text-gray-700" />;
}
// 代码文件
if (
mimeType.startsWith("text/") ||
mimeType === "application/javascript" ||
mimeType === "application/typescript" ||
mimeType === "application/x-javascript" ||
mimeType === "text/javascript" ||
mimeType === "text/typescript"
) {
return <FileCode {...iconProps} className="text-blue-400" />;
}
// PDF 文件
if (mimeType === "application/pdf") {
return <FileText {...iconProps} className="text-red-600" />;
}
// 音频文件
if (mimeType.startsWith("audio/")) {
return <FileText {...iconProps} className="text-purple-500" />;
}
// 视频文件
if (mimeType.startsWith("video/")) {
return <FileText {...iconProps} className="text-pink-500" />;
}
// 默认文件图标
return <FileText {...iconProps} className="text-gray-500" />;
};
+112 -48
View File
@@ -2,7 +2,9 @@
import { useEffect, useState } from "react";
import { User } from "@prisma/client";
import useSWR from "swr";
import { RefreshCwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import useSWR, { useSWRConfig } from "swr";
import { BucketItem, ClientStorageCredentials } from "@/lib/r2";
import { fetcher } from "@/lib/utils";
@@ -12,16 +14,18 @@ import {
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import FileManager from "@/components/file/file-list";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import UserFileList from "@/components/file/file-list";
import Uploader from "@/components/file/uploader";
import { Icons } from "@/components/shared/icons";
import { EmptyPlaceholder } from "../shared/empty-placeholder";
import { Button } from "../ui/button";
export interface FileListProps {
user: Pick<User, "id" | "name" | "apiKey" | "email" | "role">;
action: string;
@@ -35,7 +39,10 @@ export interface BucketInfo extends BucketItem {
export type DisplayType = "List" | "Grid";
export default function UserFileList({ user, action }: FileListProps) {
export default function UserFileManager({ user, action }: FileListProps) {
const t = useTranslations("List");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [displayType, setDisplayType] = useState<DisplayType>("List");
const [bucketInfo, setBucketInfo] = useState<BucketInfo>({
bucket: "",
@@ -46,6 +53,8 @@ export default function UserFileList({ user, action }: FileListProps) {
provider_name: "",
});
const { mutate } = useSWRConfig();
const { data: r2Configs, isLoading } = useSWR<ClientStorageCredentials>(
`${action}/r2/configs`,
fetcher,
@@ -63,6 +72,13 @@ export default function UserFileList({ user, action }: FileListProps) {
}
}, [r2Configs]);
const handleRefresh = () => {
mutate(
`${action}/r2/files?bucket=${bucketInfo.bucket}&page=${currentPage}&size=${pageSize}`,
undefined,
);
};
const handleChangeBucket = (bucket: string) => {
const newBucketInfo = r2Configs?.buckets?.find(
(item) => item.bucket === bucket,
@@ -74,59 +90,107 @@ export default function UserFileList({ user, action }: FileListProps) {
};
return (
<>
<div>
<Tabs value={displayType}>
<div className="mb-4 flex items-center justify-between gap-3">
<TabsList className="mr-auto">
<TabsTrigger value="List" onClick={() => setDisplayType("List")}>
<Icons.list className="size-4" />
</TabsTrigger>
<TabsTrigger value="Grid" onClick={() => setDisplayType("Grid")}>
<Icons.layoutGrid className="size-4" />
</TabsTrigger>
</TabsList>
<div>
<Tabs value={displayType}>
<div className="mb-4 flex items-center justify-between gap-3">
<TabsList className="mr-auto">
<TabsTrigger value="List" onClick={() => setDisplayType("List")}>
<Icons.list className="size-4" />
</TabsTrigger>
<TabsTrigger value="Grid" onClick={() => setDisplayType("Grid")}>
<Icons.layoutGrid className="size-4" />
</TabsTrigger>
</TabsList>
{isLoading ? (
<Skeleton className="h-9 w-[120px] rounded border-r-0 shadow-inner" />
) : (
<Select
value={bucketInfo.bucket}
onValueChange={handleChangeBucket}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Select a bucket" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel className="mx-auto text-center">
{r2Configs?.provider_name}
</SelectLabel>
{r2Configs?.buckets?.map((item) => (
<SelectItem
key={item.bucket}
value={item.bucket}
onClick={() => handleChangeBucket(item.bucket)}
>
{item.bucket}
</SelectItem>
))}
</SelectGroup>
{/* <SelectSeparator /> */}
</SelectContent>
</Select>
)}
<Uploader
bucketInfo={bucketInfo}
action={action}
onRefresh={handleRefresh}
/>
<Button
className="h-9"
size={"icon"}
variant={"outline"}
onClick={() => handleRefresh()}
disabled={isLoading}
>
{isLoading ? (
<Skeleton className="h-9 w-[120px] rounded border-r-0 shadow-inner" />
<RefreshCwIcon className="size-4 animate-spin" />
) : (
<Select
value={bucketInfo.bucket}
onValueChange={handleChangeBucket}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Select a bucket" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel className="mx-auto text-center">
{r2Configs?.provider_name}
</SelectLabel>
{r2Configs?.buckets?.map((item) => (
<SelectItem
key={item.bucket}
value={item.bucket}
onClick={() => handleChangeBucket(item.bucket)}
>
{item.bucket}
</SelectItem>
))}
</SelectGroup>
{/* <SelectSeparator /> */}
</SelectContent>
</Select>
<RefreshCwIcon className="size-4" />
)}
</Button>
</div>
<Uploader bucketInfo={bucketInfo} action={action} />
{isLoading && (
<div className="mt-8 flex flex-col items-center gap-3">
<div className="flex size-20 animate-pulse items-center justify-center rounded-full bg-muted">
<Icons.storage className="size-10" />
</div>
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<RefreshCwIcon className="size-4 animate-spin" />
{t("Loading storage buckets")}...
</div>
</div>
)}
<FileManager
{!isLoading && !r2Configs?.buckets?.length && (
<EmptyPlaceholder className="col-span-full mt-8 shadow-none">
<EmptyPlaceholder.Icon name="storage" />
<EmptyPlaceholder.Title>
{t("No buckets found")}
</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
{t(
"The administrator has not configured the storage bucket, no file can be uploaded",
)}
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
{!isLoading && r2Configs?.buckets && r2Configs.buckets.length > 0 && (
<UserFileList
view={displayType}
bucketInfo={bucketInfo}
action={action}
currentPage={currentPage}
pageSize={pageSize}
setCurrentPage={setCurrentPage}
setPageSize={setPageSize}
onRefresh={handleRefresh}
/>
</Tabs>
</div>
</>
)}
</Tabs>
</div>
);
}
+5 -1
View File
@@ -37,9 +37,11 @@ export type UploadProgressType = {
export default function Uploader({
bucketInfo,
action,
onRefresh,
}: {
bucketInfo: BucketInfo;
action: string;
onRefresh: () => void;
}) {
const t = useTranslations("Components");
const [isOpen, setIsOpen] = useState(false);
@@ -230,6 +232,8 @@ export default function Uploader({
: item,
) ?? [],
);
onRefresh();
} catch (error) {
console.error(error);
}
@@ -246,7 +250,7 @@ export default function Uploader({
<>
{!isOpen && (
<Button
className="flex h-9 items-center gap-1"
className="flex h-9 items-center gap-1 text-nowrap"
onClick={() => setIsOpen(true)}
>
<Icons.cloudUpload className="size-5" />
@@ -13,6 +13,7 @@ description: 简单介绍 WR.DO 部署所需的环境变量
或参考社区优秀部署文档:
- https://linux.do/t/topic/711806
- https://bravexist.cn/2025/06/wr.do.html
- https://b23.tv/fWpMFQu (视频教程)
</Callout>
<Steps>
+1
View File
@@ -13,6 +13,7 @@ description: How to install the project.
Or read unofficial deployment tutorials:
- https://linux.do/t/topic/711806
- https://bravexist.cn/2025/06/wr.do.html
- https://b23.tv/fWpMFQu (Video tutorial)
</Callout>
<Steps>
+36 -24
View File
@@ -1,7 +1,14 @@
import { Prisma } from "@prisma/client";
import { Prisma, UserFile } from "@prisma/client";
import { prisma } from "../db";
export interface UserFileData extends UserFile {
user: {
name: string;
email: string;
};
}
export interface CreateUserFileInput {
userId: string;
name: string;
@@ -70,8 +77,8 @@ export async function createUserFile(data: CreateUserFileInput) {
});
return { success: true, data: userFile };
} catch (error) {
console.error("创建文件记录失败:", error);
return { success: false, error: "创建文件记录失败" };
console.error("Failed to create file record:", error);
return { success: false, error: "Failed to create file record" };
}
}
@@ -92,8 +99,8 @@ export async function getUserFileById(id: string) {
});
return { success: true, data: userFile };
} catch (error) {
console.error("查询文件记录失败:", error);
return { success: false, error: "查询文件记录失败" };
console.error("Failed to query file record:", error);
return { success: false, error: "Failed to query file record" };
}
}
@@ -124,7 +131,7 @@ export async function getUserFiles(options: QueryUserFileOptions = {}) {
...(shortUrlId && { shortUrlId }),
};
const [files, total] = await Promise.all([
const [files, total, totalSize] = await Promise.all([
prisma.userFile.findMany({
where,
include: {
@@ -140,10 +147,15 @@ export async function getUserFiles(options: QueryUserFileOptions = {}) {
take: limit,
}),
prisma.userFile.count({ where }),
prisma.userFile.aggregate({
where,
_sum: { size: true },
}),
]);
return {
total,
totalSize: totalSize._sum.size || 0,
list: files,
};
} catch (error) {
@@ -173,8 +185,8 @@ export async function updateUserFile(id: string, data: UpdateUserFileInput) {
});
return { success: true, data: userFile };
} catch (error) {
console.error("更新文件记录失败:", error);
return { success: false, error: "更新文件记录失败" };
console.error("Failed to update file record:", error);
return { success: false, error: "Failed to update file record" };
}
}
@@ -190,8 +202,8 @@ export async function softDeleteUserFile(id: string) {
});
return { success: true, data: userFile };
} catch (error) {
console.error("删除文件记录失败:", error);
return { success: false, error: "删除文件记录失败" };
console.error("Delete file record failed:", error);
return { success: false, error: "Delete file record failed" };
}
}
@@ -209,8 +221,8 @@ export async function softDeleteUserFiles(ids: string[]) {
});
return { success: true, data: result };
} catch (error) {
console.error("批量删除文件记录失败:", error);
return { success: false, error: "批量删除文件记录失败" };
console.error("Delete file records failed:", error);
return { success: false, error: "Delete file records failed" };
}
}
@@ -222,8 +234,8 @@ export async function deleteUserFile(id: string) {
});
return { success: true, data: userFile };
} catch (error) {
console.error("删除文件记录失败:", error);
return { success: false, error: "删除文件记录失败" };
console.error("Delete file record failed:", error);
return { success: false, error: "Delete file record failed" };
}
}
@@ -255,8 +267,8 @@ export async function getUserFileStats(userId: string) {
},
};
} catch (error) {
console.error("获取文件统计失败:", error);
return { success: false, error: "获取文件统计失败" };
console.error("Failed to get file statistics:", error);
return { success: false, error: "Failed to get file statistics" };
}
}
@@ -284,8 +296,8 @@ export async function getUserFileByPath(path: string, providerName?: string) {
return { success: true, data: userFile };
} catch (error) {
console.error("根据路径查找文件失败:", error);
return { success: false, error: "根据路径查找文件失败" };
console.error("Failed to query file record:", error);
return { success: false, error: "Failed to query file record" };
}
}
@@ -295,7 +307,7 @@ export async function getUserFileByShortUrlId(shortUrlId: string) {
const userFile = await prisma.userFile.findFirst({
where: {
shortUrlId,
status: 1, // 只查询正常状态的文件
status: 1,
},
include: {
user: {
@@ -309,12 +321,12 @@ export async function getUserFileByShortUrlId(shortUrlId: string) {
});
return { success: true, data: userFile };
} catch (error) {
console.error("根据短链接ID查询文件失败:", error);
return { success: false, error: "根据短链接ID查询文件失败" };
console.error("Failed to query file record:", error);
return { success: false, error: "Failed to query file record" };
}
}
// 清理过期文件记录(可选功能)
// 清理过期文件记录
export async function cleanupExpiredFiles(days: number = 30) {
try {
const expiredDate = new Date();
@@ -331,7 +343,7 @@ export async function cleanupExpiredFiles(days: number = 30) {
return { success: true, data: result };
} catch (error) {
console.error("清理过期文件失败:", error);
return { success: false, error: "清理过期文件失败" };
console.error("Failed to clean up expired files:", error);
return { success: false, error: "Failed to clean up expired files" };
}
}
+49 -1
View File
@@ -189,6 +189,54 @@ export const truncate = (str: string, length: number) => {
return `${str.slice(0, length)}...`;
};
export const truncateMiddle = (
text: string,
maxLength: number = 20,
): string => {
if (text.length <= maxLength) return text;
// 找到最后一个点的位置(文件扩展名)
const lastDotIndex = text.lastIndexOf(".");
if (lastDotIndex === -1 || lastDotIndex === 0) {
// 没有扩展名,直接中间截断
const half = Math.floor((maxLength - 3) / 2);
return text.slice(0, half) + "..." + text.slice(-half);
}
const extension = text.slice(lastDotIndex);
const nameWithoutExt = text.slice(0, lastDotIndex);
// 如果扩展名太长,直接截断整个文件名
if (extension.length > maxLength / 2) {
const half = Math.floor((maxLength - 3) / 2);
return text.slice(0, half) + "..." + text.slice(-half);
}
// 计算可用于文件名的长度
const availableLength = maxLength - extension.length - 3;
if (availableLength <= 0) {
return "..." + extension;
}
// 如果文件名部分不需要截断
if (nameWithoutExt.length <= availableLength) {
return text;
}
// 中间截断文件名部分
const startLength = Math.ceil(availableLength / 2);
const endLength = Math.floor(availableLength / 2);
return (
nameWithoutExt.slice(0, startLength) +
"..." +
nameWithoutExt.slice(-endLength) +
extension
);
};
export const getBlurDataURL = async (url: string | null) => {
if (!url) {
return "data:image/webp;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
@@ -522,4 +570,4 @@ export function generateFileKey(fileName: string, prefix?: string): string {
const day = String(date.getDate()).padStart(2, "0");
return `${year}/${month}/${day}/${fileName}`;
}
}
+24 -2
View File
@@ -185,7 +185,17 @@
"Duplicate": "Duplicate",
"Confirm duplicate domain": "Confirm duplicate domain",
"This will duplicate all configuration information for the {domain} domain, and create a new domain": "This will duplicate all configuration information for the {domain} domain, and create a new domain",
"Add User": "Add User"
"Add User": "Add User",
"Loading storage buckets": "Loading storage buckets",
"No buckets found": "No buckets found",
"The administrator has not configured the storage bucket, no file can be uploaded": "The administrator has not configured the storage bucket, no file can be uploaded",
"No Files": "No Files",
"You don't upload any files yet": "You don't upload any files yet",
"Size": "Size",
"Date": "Date",
"Download": "Download",
"Generate short link": "Shorten link",
"Delete File": "Delete"
},
"Components": {
"Dashboard": "Dashboard",
@@ -574,6 +584,18 @@
"Verified": "Verified",
"Verify Configuration": "Verify Configuration",
"Clear": "Clear",
"How to get the R2 credentials?": "How to get the R2 credentials?"
"How to get the R2 credentials?": "How to get the R2 credentials?",
"Endpoint": "Endpoint",
"Access Key ID": "Access Key ID",
"Secret Access Key": "Secret Access Key",
"Enabled": "Enabled",
"Bucket": "Bucket",
"Bucket Name": "Bucket Name",
"Public Domain": "Public Domain",
"Max File Size": "Max File Size",
"Region": "Region",
"Prefix": "Prefix",
"Optional": "Optional",
"Allowed File Types": "Allowed File Types"
}
}
+24 -2
View File
@@ -185,7 +185,17 @@
"Duplicate": "复制",
"Confirm duplicate domain": "确认复制域名",
"This will duplicate all configuration information for the {domain} domain, and create a new domain": "这将复制 {domain} 域名的所有配置信息,并创建一个新域名",
"Add User": "添加用户"
"Add User": "添加用户",
"Loading storage buckets": "正在加载存储桶",
"No buckets found": "未配置存储桶",
"The administrator has not configured the storage bucket, no file can be uploaded": "管理员未配置存储桶,无法上传文件",
"No Files": "暂无文件",
"You don't upload any files yet": "您还没有上传任何文件",
"Size": "大小",
"Date": "日期",
"Download": "下载文件",
"Generate short link": "生成短链",
"Delete File": "删除文件"
},
"Components": {
"Dashboard": "用户面板",
@@ -574,6 +584,18 @@
"Verified": "已就绪",
"Verify Configuration": "验证配置",
"Clear": "清空",
"How to get the R2 credentials?": "如何获取 R2 授权配置?"
"How to get the R2 credentials?": "如何获取 R2 授权配置?",
"Endpoint": "S3 端点",
"Access Key ID": "访问密钥 ID",
"Secret Access Key": "机密访问密钥",
"Enabled": "启用",
"Bucket": "存储桶",
"Bucket Name": "存储桶名称",
"Public Domain": "公开域名或自定义域名",
"Max File Size": "上传文件大小限制",
"Region": "存储桶区域",
"Prefix": "前缀",
"Optional": "可选",
"Allowed File Types": "允许的文件类型"
}
}
+101 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long