储存桶file文件的增删改查

This commit is contained in:
liamzi
2024-12-16 13:38:42 +08:00
parent 5882bf9548
commit 550032d1dc
4 changed files with 574 additions and 8 deletions

View File

@@ -0,0 +1,528 @@
import React, { useState, useEffect, useMemo } from 'react';
import { supabase } from '@/config/supabase';
import { Card, Upload, Button, message, List, Switch, Space, Input, Select, Tag, Pagination, Modal, Image } from 'antd';
import { UploadOutlined, FileTextOutlined, FileImageOutlined,
FileMarkdownOutlined, FilePdfOutlined, FileWordOutlined,
FileExcelOutlined, InboxOutlined, SearchOutlined, EditOutlined } from '@ant-design/icons';
import MonacoEditor from '@monaco-editor/react';
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 [pagination, setPagination] = useState({
current: 1,
pageSize: 200,
});
const [isRenaming, setIsRenaming] = useState(false);
const [newFileName, setNewFileName] = useState('');
// 文件图标映射
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 () => {
setLoading(true);
try {
const { data, error } = await supabase.storage
.from('file')
.list('', {
sortBy: { column: 'created_at', order: 'desc' }
});
if (error) throw error;
setAllFiles(data || []);
} catch (error) {
console.error('获取文件列表错误:', 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 {
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 }) => {
try {
const fileName = file.name;
// 检查文件是否已存在
const fileExists = allFiles.some(f => f.name === fileName);
if (fileExists) {
throw new Error('文件已存在');
}
const { data, error } = await supabase.storage
.from('file')
.upload(fileName, file);
if (error) throw error;
message.success(`${fileName} 上传成功`);
onSuccess(data);
fetchAllFiles();
} catch (error) {
message.error(`${file.name} 上传失败: ${error.message}`);
onError(error);
}
},
beforeUpload: (file) => {
const isLt50M = file.size / 1024 / 1024 < 50;
if (!isLt50M) {
message.error('文件必须小于 50MB!');
return false;
}
return true;
},
};
// 文件过滤逻辑
const filteredFiles = useMemo(() => {
return allFiles.filter(file => {
const matchesSearch = file.name.toLowerCase().includes(searchText.toLowerCase());
if (selectedType === '全部') return matchesSearch;
const mimetype = file.metadata?.mimetype;
const matchesType = FILE_TYPES[selectedType]?.includes(mimetype);
return matchesSearch && matchesType;
});
}, [allFiles, searchText, selectedType]);
// 当前页的文件
const currentPageFiles = useMemo(() => {
const { current, pageSize } = pagination;
const start = (current - 1) * pageSize;
const end = start + pageSize;
return filteredFiles.slice(start, end);
}, [filteredFiles, pagination]);
// 获取文件类型统计
const typeStats = useMemo(() => {
const stats = { '全部': allFiles.length };
Object.entries(FILE_TYPES).forEach(([type, mimetypes]) => {
stats[type] = allFiles.filter(file => {
const mimetype = file.metadata?.mimetype;
return mimetypes.includes(mimetype);
}).length;
});
return stats;
}, [allFiles]);
// 处理分页变化
const handlePageChange = (page, pageSize) => {
setPagination({ ...pagination, current: page, pageSize });
};
useEffect(() => {
fetchAllFiles();
}, []);
// 判断是否是图片
const isImage = (file) => {
return file.metadata?.mimetype?.startsWith('image/');
};
// 判断是否是HTML
const isHtml = (file) => {
return file.metadata?.mimetype === 'text/html';
};
// 保存文件内容
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('文件删除成功');
fetchAllFiles();
if (selectedFile?.name === fileName) {
setSelectedFile(null);
setFileContent('');
}
} catch (error) {
message.error(`删除失败: ${error.message}`);
}
};
return (
<div className="flex h-screen bg-gray-50">
{/* 左侧文件列表 */}
<div className="w-1/3 p-4 border-r border-gray-200 flex flex-col">
{/* 上传区域 */}
<div className="mb-4">
<Dragger {...uploadProps} className="bg-white p-4 rounded-lg shadow-sm">
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击<EFBFBD><EFBFBD>拖拽文件到此区域上传</p>
<p className="ant-upload-hint text-xs text-gray-500">
支持单个或批量上传文件大小不超过50MB
</p>
</Dragger>
</div>
{/* 搜索和筛选区域 */}
<div className="mb-4 space-y-3">
<Search
placeholder="搜索文件名..."
allowClear
onChange={e => {
setSearchText(e.target.value);
setPagination(prev => ({ ...prev, current: 1 }));
}}
className="w-full"
size="large"
/>
<div className="flex flex-wrap gap-2">
{Object.entries({ '全部': null, ...FILE_TYPES }).map(([type]) => (
<Tag.CheckableTag
key={type}
checked={selectedType === type}
onChange={checked => {
setSelectedType(checked ? type : '全部');
setPagination(prev => ({ ...prev, current: 1 }));
}}
className="cursor-pointer"
>
{type} ({typeStats[type] || 0})
</Tag.CheckableTag>
))}
</div>
</div>
{/* 文件列表 */}
<div className="flex-1 overflow-y-auto">
<List
loading={loading}
dataSource={searchText ? filteredFiles : currentPageFiles}
locale={{ emptyText: '暂无文件' }}
renderItem={file => (
<List.Item
className={`cursor-pointer hover:bg-gray-100 ${
selectedFile?.name === file.name ? 'bg-blue-50' : ''
}`}
onClick={() => previewFile(file)}
actions={[
<Button
key="delete"
type="text"
danger
onClick={(e) => {
e.stopPropagation();
handleDelete(file.name);
}}
>
删除
</Button>
]}
>
<List.Item.Meta
avatar={
isImage(file) ? (
<Image
src={getFileUrl(file.name)}
alt={file.name}
width={32}
height={32}
className="object-cover rounded"
preview={false}
loading="lazy"
placeholder={
<div className="w-8 h-8 bg-gray-200 rounded animate-pulse" />
}
/>
) : getFileIcon(file)
}
title={file.name}
description={
<div className="text-xs text-gray-500">
<span>类型: {file.metadata?.mimetype}</span>
<span className="ml-2">大小: {(file.metadata?.size / 1024).toFixed(2)} KB</span>
<span className="ml-2">
创建时间: {new Date(file.created_at).toLocaleString()}
</span>
</div>
}
/>
</List.Item>
)}
/>
{/* 分页器 - 只在非搜索状态下显示 */}
{!searchText && (
<div className="mt-4 flex justify-center">
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={filteredFiles.length}
onChange={handlePageChange}
showSizeChanger
showQuickJumper
showTotal={(total) => `${total} 个文件`}
pageSizeOptions={['10', '20', '50', '100', '200']}
/>
</div>
)}
</div>
</div>
{/* 右侧预览区域 */}
<div className="flex-1 p-4 overflow-y-auto bg-white">
{selectedFile ? (
<>
<div className="mb-4 border-b pb-4">
<Space size="middle" align="center">
<Space>
<span className="font-medium text-lg">
{selectedFile.name}
</span>
<Button
type="text"
icon={<EditOutlined />}
onClick={startRename}
/>
</Space>
{isHtml(selectedFile) && (
<Switch
checkedChildren="预览"
unCheckedChildren="代码"
checked={isPreview}
onChange={setIsPreview}
/>
)}
{!isImage(selectedFile) && (
<Button
type="primary"
onClick={handleSaveContent}
>
保存
</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"
loading="lazy"
preview={{
toolbarRender: () => null, // 隐藏底部工具栏
maskClassName: 'backdrop-blur-sm'
}}
placeholder={
<div className="w-full h-[80vh] bg-gray-100 rounded flex items-center justify-center">
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
}
/>
</div>
) : isHtml(selectedFile) ? (
isPreview ? (
<iframe
srcDoc={fileContent}
className="w-full h-[calc(100vh-200px)] border rounded"
/>
) : (
<MonacoEditor
height="calc(100vh - 200px)"
language="html"
theme="vs-light"
value={fileContent}
onChange={handleEditorChange}
options={{
minimap: { enabled: true }
}}
/>
)
) : (
<MonacoEditor
height="calc(100vh - 200px)"
language="plaintext"
theme="vs-light"
value={fileContent}
onChange={handleEditorChange}
options={{
minimap: { enabled: false }
}}
/>
)}
</div>
</>
) : (
<div className="h-full flex items-center justify-center text-gray-400">
选择文件以预览
</div>
)}
</div>
{/* 重命名对话框 */}
<Modal
title="重命名文件"
open={isRenaming}
onOk={handleRename}
onCancel={() => setIsRenaming(false)}
okText="确认"
cancelText="取消"
>
<Input
value={newFileName}
onChange={e => setNewFileName(e.target.value)}
placeholder="请输入新文件名"
autoFocus
/>
</Modal>
</div>
);
};
export default StorageManager;