Files
manage/src/pages/resource/bucket/index.jsx
‘Liammcl’ 780e7519c1 专案完成
2025-01-04 20:09:06 +08:00

754 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo } from "react";
import { supabase } from "@/config/supabase";
import {
Upload,
Button,
message,
List,
Switch,
Space,
Input,
Tag,
Modal,
Image,
Popconfirm,
} from "antd";
import {
UploadOutlined,
FileTextOutlined,
FileImageOutlined,
FileMarkdownOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
InboxOutlined,
SearchOutlined,
EditOutlined,
FolderOutlined,
} from "@ant-design/icons";
import MonacoEditor from "@monaco-editor/react";
import InfiniteScroll from 'react-infinite-scroll-component';
const { Dragger } = Upload;
const { Search } = Input;
// 文件类型配置
const FILE_TYPES = {
图片: ["image/jpeg", "image/png", "image/gif", "image/svg+xml"],
文档: [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain",
"text/markdown",
],
表格: [
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv",
],
代码: [
"text/html",
"text/javascript",
"text/css",
"application/json",
"text/jsx",
"text/tsx",
],
};
const StorageManager = () => {
const [allFiles, setAllFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [fileContent, setFileContent] = useState("");
const [isPreview, setIsPreview] = useState(false);
const [searchText, setSearchText] = useState("");
const [selectedType, setSelectedType] = useState("全部");
const [hasMore, setHasMore] = useState(true);
const [displayFiles, setDisplayFiles] = useState([]);
const INITIAL_LOAD_SIZE = 200; // 初始加载200条
const LOAD_MORE_SIZE = 100; // 每次加载100条
const [isRenaming, setIsRenaming] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [currentPath, setCurrentPath] = useState(""); // 添加当前路径状态
const [pathHistory, setPathHistory] = useState([]); // 添加路径历史记录
const [isUploading, setIsUploading] = useState(false); // 添加上传loading状态
// 文件图标映射
const getFileIcon = (file) => {
const mimetype = file.metadata?.mimetype;
const iconMap = {
"text/plain": <FileTextOutlined />,
"text/markdown": <FileMarkdownOutlined />,
"application/pdf": <FilePdfOutlined />,
"application/msword": <FileWordOutlined />,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
<FileWordOutlined />,
"application/vnd.ms-excel": <FileExcelOutlined />,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": (
<FileExcelOutlined />
),
};
if (mimetype?.startsWith("image/")) {
return <FileImageOutlined />;
}
return iconMap[mimetype] || <FileTextOutlined />;
};
// 获取所有文件
const fetchAllFiles = async (isInitial = true) => {
setLoading(true);
try {
const { data, error } = await supabase.storage
.from("file")
.list(currentPath, { // 使用当前路径
limit: isInitial ? INITIAL_LOAD_SIZE : LOAD_MORE_SIZE,
offset: isInitial ? 0 : displayFiles.length,
sortBy: { column: "created_at", order: "desc" },
});
if (error) throw error;
// 对数据进行排序,文件夹在前
const sortedData = (data || []).sort((a, b) => {
// 首先按照类型排序(文件夹在前)
if (a.metadata?.isFolder !== b.metadata?.isFolder) {
return a.metadata?.isFolder ? -1 : 1;
}
// 然后按照时间排序
return new Date(b.created_at) - new Date(a.created_at);
});
if (isInitial) {
setDisplayFiles(sortedData);
} else {
setDisplayFiles(prev => [...prev, ...sortedData]);
}
setHasMore(data?.length === (isInitial ? INITIAL_LOAD_SIZE : LOAD_MORE_SIZE));
} catch (error) {
message.error(`获取文件列表失败: ${error.message}`);
} finally {
setLoading(false);
}
};
// 获取文件URL
const getFileUrl = (fileName) => {
const { data } = supabase.storage.from("file").getPublicUrl(fileName);
return data.publicUrl;
};
// 预览文件
const previewFile = async (file) => {
try {
if (file.metadata?.mimetype === 'application/pdf' ||
file.metadata?.mimetype.includes('msword') ||
file.metadata?.mimetype.includes('spreadsheet')) {
window.open(getFileUrl(file.name), '_blank');
return;
}
// Existing preview logic for text files
const { data, error } = await supabase.storage
.from("file")
.download(file.name);
if (error) throw error;
const content = await data.text();
setSelectedFile(file);
setFileContent(content);
} catch (error) {
message.error("文件预览失败");
}
};
// 上传文件配置
const uploadProps = {
name: "file",
multiple: true,
showUploadList: false,
customRequest: async ({ file, onSuccess, onError }) => {
setIsUploading(true); // 开始上传时设置状态
try {
const originalName = file.name;
const fileName = handleFileName(originalName);
// 检查文件是否存在
const fileExists = allFiles.some((f) => f.name === fileName);
if (fileExists) {
throw new Error("文件已存在");
}
const { data, error } = await supabase.storage
.from("file")
.upload(fileName, file, {
cacheControl: '3600',
upsert: false
});
if (error) throw error;
message.success(`${originalName} 上传成功`);
onSuccess(data);
fetchAllFiles();
} catch (error) {
message.error(`${file.name} 上传失败: ${error.message}`);
onError(error);
} finally {
setIsUploading(false); // 上传完成后重置状态
}
},
beforeUpload: (file) => {
const isLt50M = file.size / 1024 / 1024 < 50;
if (!isLt50M) {
message.error("文件必须小于 50MB!");
return false;
}
return true;
},
};
// 文件过滤逻辑
const filteredFiles = useMemo(() => {
return displayFiles.filter((file) => {
const matchesSearch = file.name
.toLowerCase()
.includes(searchText.toLowerCase());
if (selectedType === "全部") return matchesSearch;
const mimetype = file.metadata?.mimetype || '';
const matchesType = FILE_TYPES[selectedType]?.some(type =>
mimetype.startsWith(type) || mimetype === type
);
return matchesSearch && matchesType;
});
}, [displayFiles, searchText, selectedType]);
// 当前页的文件
// 获取文件类型统计
const typeStats = useMemo(() => {
const stats = { 全部: 0, 其他: 0 };
// 初始化所有类型的计数为0
Object.keys(FILE_TYPES).forEach(type => {
stats[type] = 0;
});
// 统计每个文件的类型
displayFiles.forEach((file) => {
const mimetype = file.metadata?.mimetype || '';
let matched = false;
// 遍历所有文件类型配置
for (const [type, mimetypes] of Object.entries(FILE_TYPES)) {
if (mimetypes.some(t => mimetype.startsWith(t) || mimetype === t)) {
stats[type]++;
matched = true;
break; // 找到匹配后就跳出循环
}
}
// 如果没有匹配任何预定义类型,归类为"其他"
if (!matched) {
stats['其他']++;
}
// 更新总数
stats['全部']++;
});
return stats;
}, [displayFiles]);
useEffect(() => {
fetchAllFiles();
}, []);
// 判断是否是<E590A6><E698AF>
const isImage = (file) => {
return file.metadata?.mimetype?.startsWith("image/");
};
// 判断是否是HTML
const isHtml = (file) => {
return file.metadata?.mimetype === "text/html"||file.metadata?.mimetype === "text/plain";
};
// 保存文件内容
const handleSaveContent = async () => {
if (!selectedFile) return;
try {
// 创建 Blob 对象
const blob = new Blob([fileContent], { type: "text/plain" });
const file = new File([blob], selectedFile.name, { type: "text/plain" });
// 上传更新后的文件
const { error } = await supabase.storage
.from("file")
.update(selectedFile.name, file);
if (error) throw error;
message.success("保存成功");
fetchAllFiles(); // 刷新文件列表
} catch (error) {
console.error("保存文件错误:", error);
message.error(`保存失败: ${error.message}`);
}
};
// 更新编辑器内容
const handleEditorChange = (value) => {
setFileContent(value);
};
// 处理重命名
const handleRename = async () => {
if (!selectedFile || !newFileName) return;
try {
// 检查新文件名是否已存在
const fileExists = allFiles.some((f) => f.name === newFileName);
if (fileExists) {
throw new Error("文件名已存在");
}
// 获取原文件内容
const { data: fileData, error: downloadError } = await supabase.storage
.from("file")
.download(selectedFile.name);
if (downloadError) throw downloadError;
// 创建新文件
const { error: uploadError } = await supabase.storage
.from("file")
.upload(newFileName, fileData);
if (uploadError) throw uploadError;
// 删除旧文件
const { error: deleteError } = await supabase.storage
.from("file")
.remove([selectedFile.name]);
if (deleteError) throw deleteError;
message.success("重命名成功");
setIsRenaming(false);
setSelectedFile({ ...selectedFile, name: newFileName });
fetchAllFiles();
} catch (error) {
console.error("重命名错误:", error);
message.error(`重命名失败: ${error.message}`);
}
};
// 开始重命名
const startRename = () => {
setNewFileName(selectedFile.name);
setIsRenaming(true);
};
const handleDelete = async (fileName) => {
try {
const { error } = await supabase.storage.from("file").remove([fileName]);
if (error) throw error;
message.success("文件删除成功");
setDisplayFiles(prev => prev.filter(file => file.name !== fileName));
if (selectedFile?.name === fileName) {
setSelectedFile(null);
setFileContent("");
}
} catch (error) {
message.error(`删除失败: ${error.message}`);
}
};
const hasFilters = useMemo(() => {
return searchText !== '' || selectedType !== '全部';
}, [searchText, selectedType]);
const loadMoreFiles = () => {
if (!hasMore || loading || hasFilters) return; // 有过滤条件不加载更多
fetchAllFiles(false);
};
// 初始化加载
useEffect(() => {
fetchAllFiles(true);
}, []);
const handleTypeChange = (type) => {
setSelectedType(type);
};
const handleFileName = (fileName) => {
return fileName.replace(/\s+/g, '_');
};
// 渲染加载状态
const LoadingSpinner = () => (
<div className="flex justify-center items-center p-4">
<div className="w-6 h-6 border-2 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
</div>
);
// 处理文件夹点击
const handleFolderClick = (folderName) => {
const newPath = currentPath ? `${currentPath}/${folderName}` : folderName;
setPathHistory(prev => [...prev, currentPath]);
setCurrentPath(newPath);
setDisplayFiles([]); // 清空当前列表
setHasMore(true); // 重置加载更多状态
fetchAllFiles(true); // 重新获取文件列表
};
// 返回上一级
const handleBack = () => {
const previousPath = pathHistory[pathHistory.length - 1];
setPathHistory(prev => prev.slice(0, -1));
setCurrentPath(previousPath);
setDisplayFiles([]); // 清空当前列表
setHasMore(true); // 重置加载更多状态
fetchAllFiles(true); // 重新获取文件列表
};
// 修改文件列表渲染
const renderFileList = () => (
<div
className="flex-1 overflow-y-auto rounded-lg shadow-sm "
id="scrollableDiv"
>
{/* 面包屑导航样式优化 */}
{currentPath && (
<div className="p-4 border-b border-gray-100 dark:border-gray-700">
<div className="flex items-center space-x-2 text-sm">
<Button
type="link"
onClick={handleBack}
className="px-0 text-blue-500 dark:text-blue-400"
>
返回上级
</Button>
<span className="text-gray-500 dark:text-gray-400">/</span>
<span className="text-gray-900 dark:text-gray-100">{currentPath}</span>
</div>
</div>
)}
<InfiniteScroll
dataLength={displayFiles.length}
next={loadMoreFiles}
hasMore={!hasFilters && hasMore}
loader={<LoadingSpinner />}
scrollableTarget="scrollableDiv"
endMessage={
<p className="text-center text-gray-500 py-4 text-sm">
{displayFiles.length > 0
? hasFilters
? "已显示所有匹配文件"
: "已加载全部文件"
: "暂无文件"}
</p>
}
>
<List
loading={false}
dataSource={filteredFiles}
locale={{ emptyText: "暂无文件" }}
renderItem={(file) => (
<List.Item
className={`
relative group cursor-pointer transition-colors duration-200
hover:bg-gray-50 dark:hover:bg-gray-700
${selectedFile?.name === file.name ? "bg-blue-50 dark:bg-gray-700" : ""}
`}
onClick={() => file.metadata?.isFolder ? handleFolderClick(file.name) : previewFile(file)}
>
<List.Item.Meta
avatar={
file.metadata?.isFolder ? (
<div className="w-10 h-10 flex items-center justify-center bg-gray-100 rounded-lg text-gray-500">
<FolderOutlined className="text-xl" />
</div>
) : isImage(file) ? (
<Image
src={getFileUrl(file.name)}
alt={file.name}
width={40}
height={40}
className="object-cover rounded-lg"
preview={false}
loading="lazy"
placeholder={
<div className="w-10 h-10 bg-gray-100 rounded-lg animate-pulse" />
}
/>
) : (
<div className="w-10 h-10 flex items-center justify-center bg-gray-100 rounded-lg text-gray-500">
{getFileIcon(file)}
</div>
)
}
title={
<div className="flex items-center w-full pr-16">
<span className="text-sm font-medium text-gray-900 block w-full truncate group-hover:text-blue-500 transition-colors duration-200">
{file.name}
</span>
{!file.metadata?.isFolder && (
<Popconfirm
title="确认删除"
description={`是否确认删除${file.metadata?.isFolder ? '文件夹' : '文件'} "${file.name}"?`}
onConfirm={(e) => {
e?.stopPropagation();
handleDelete(currentPath ? `${currentPath}/${file.name}` : file.name);
}}
okText="确认"
cancelText="取消"
>
<Button
type="text"
danger
onClick={(e) => e.stopPropagation()}
className="absolute right-4 invisible group-hover:visible transition-all duration-200"
>
删除
</Button>
</Popconfirm>
)}
</div>
}
description={
<div className="text-xs text-gray-500 dark:text-gray-400 space-x-4">
<span>{file.metadata?.isFolder ? '文件夹' : `类型: ${file.metadata?.mimetype}`}</span>
{!file.metadata?.isFolder && (
<>
<span>大小: {(file.metadata?.size / 1024).toFixed(2)} KB</span>
<span>创建时间: {new Date(file.created_at).toLocaleString()}</span>
</>
)}
</div>
}
/>
</List.Item>
)}
/>
</InfiniteScroll>
</div>
);
const renderTypeTags = () => (
<div className="flex flex-wrap gap-2">
{Object.entries({ 全部: null, ...FILE_TYPES, 其他: null }).map(([type]) => (
<Tag.CheckableTag
key={type}
checked={selectedType === type}
onChange={(checked) => handleTypeChange(checked ? type : "全部")}
className={`cursor-pointer ${typeStats[type] === 0 ? 'opacity-50' : ''}`}
>
{`${type} (${typeStats[type] || 0})`}
</Tag.CheckableTag>
))}
</div>
);
return (
<div className="flex h-full ">
<div className="w-1/3 p-2 flex flex-col space-y-4 h-full overflow-auto ">
<div className="rounded-lg shadow-sm p-4 ">
<Dragger
{...uploadProps}
className={`px-6 py-8 group
${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={isUploading}
>
<div className="space-y-3 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full group-hover:bg-blue-100 dark:group-hover:bg-blue-800 transition-colors">
{isUploading ? (
<LoadingSpinner />
) : (
<InboxOutlined className="text-3xl text-blue-500 dark:text-blue-400" />
)}
</div>
<div>
<p className="text-base font-medium text-gray-900 dark:text-gray-100">
{isUploading ? '正在上传...' : '点击或拖拽文件上传'}
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
支持单个或批量上传文件大小不超过50MB
</p>
</div>
</div>
</Dragger>
</div>
<div className="space-y-3">
<Input
variant="filled"
placeholder="搜索文件名..."
allowClear
onChange={(e) => setSearchText(e.target.value)}
size="large"
/>
<div className=" p-3 rounded-lg shadow-sm">
{renderTypeTags()}
</div>
</div>
{/* 文件列表 */}
{loading && displayFiles.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<LoadingSpinner />
</div>
) : (
renderFileList()
)}
</div>
{/* 右侧预览区域 */}
<div className="flex-1 p-4 border-l border-gray-200">
{selectedFile ? (
<>
<div className="mb-4 pb-4 border-b border-gray-200">
<Space size="middle" align="center">
<Space>
<span className="text-lg font-medium ">
{selectedFile.name}
</span>
<Button
type="text"
icon={<EditOutlined />}
onClick={startRename}
className="text-gray-500 dark:text-gray-100 hover:text-blue-500"
/>
</Space>
{isHtml(selectedFile) && (
<Switch
checkedChildren="预览"
unCheckedChildren="代码"
checked={isPreview}
onChange={setIsPreview}
/>
)}
{!isImage(selectedFile) && (
<Button
type="primary"
onClick={handleSaveContent}
className="bg-blue-500 hover:bg-blue-600"
>
保存
</Button>
)}
</Space>
</div>
<div className="mt-4">
{isImage(selectedFile) ? (
<div className="flex justify-center">
<Image
src={getFileUrl(selectedFile.name)}
alt={selectedFile.name}
className="max-w-full max-h-[80vh] object-contain rounded-lg"
loading="lazy"
preview={{
toolbarRender: () => null,
maskClassName: "backdrop-blur-sm",
}}
placeholder={
<div className="w-full h-[80vh] bg-gray-50 rounded-lg flex items-center justify-center">
<LoadingSpinner />
</div>
}
/>
</div>
) : isHtml(selectedFile) ? (
isPreview ? (
<iframe
srcDoc={fileContent}
className="w-full h-[calc(100vh-200px)] border rounded-lg"
/>
) : (
<MonacoEditor
height="calc(100vh - 200px)"
language="html"
theme="vs-light"
value={fileContent}
onChange={handleEditorChange}
options={{
minimap: { enabled: true },
roundedSelection: true,
padding: { top: 16 },
}}
className="rounded-lg overflow-hidden"
/>
)
) : (
<MonacoEditor
height="calc(100vh - 200px)"
language="plaintext"
theme="vs-light"
value={fileContent}
onChange={handleEditorChange}
options={{
minimap: { enabled: false },
roundedSelection: true,
padding: { top: 16 },
}}
className="rounded-lg overflow-hidden"
/>
)}
</div>
</>
) : (
<div className="h-full flex items-center justify-center text-gray-400">
<div className="text-center">
<InboxOutlined className="text-4xl mb-2" />
<p>选择文件以预览</p>
</div>
</div>
)}
</div>
{/* 重命名对话框 */}
<Modal
title="重命名文件"
open={isRenaming}
onOk={handleRename}
onCancel={() => setIsRenaming(false)}
okText="确认"
cancelText="取消"
>
<Input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder="请输入新文件名"
autoFocus
className="mt-4"
/>
</Modal>
</div>
);
};
export default StorageManager;