mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-02 00:30:25 +00:00
582 lines
19 KiB
TypeScript
582 lines
19 KiB
TypeScript
/* eslint-disable @stylistic/indent */
|
||
import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs';
|
||
import { Button } from '@heroui/button';
|
||
import { Input } from '@heroui/input';
|
||
import type { Selection, SortDescriptor } from '@react-types/shared';
|
||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||
import clsx from 'clsx';
|
||
import { motion } from 'motion/react';
|
||
import path from 'path-browserify';
|
||
import { useEffect, useState } from 'react';
|
||
import { useDropzone } from 'react-dropzone';
|
||
import toast from 'react-hot-toast';
|
||
import { FiDownload, FiMove, FiPlus, FiUpload } from 'react-icons/fi';
|
||
import { MdRefresh } from 'react-icons/md';
|
||
import { TbTrash } from 'react-icons/tb';
|
||
import { TiArrowBack } from 'react-icons/ti';
|
||
import { useLocation, useNavigate } from 'react-router-dom';
|
||
|
||
import key from '@/const/key';
|
||
import CreateFileModal from '@/components/file_manage/create_file_modal';
|
||
import FileEditModal from '@/components/file_manage/file_edit_modal';
|
||
import FilePreviewModal from '@/components/file_manage/file_preview_modal';
|
||
import FileTable from '@/components/file_manage/file_table';
|
||
import MoveModal from '@/components/file_manage/move_modal';
|
||
import RenameModal from '@/components/file_manage/rename_modal';
|
||
|
||
import useDialog from '@/hooks/use-dialog';
|
||
|
||
import FileManager, { FileInfo } from '@/controllers/file_manager';
|
||
|
||
export default function FileManagerPage () {
|
||
const [files, setFiles] = useState<FileInfo[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||
column: 'name',
|
||
direction: 'ascending',
|
||
});
|
||
const dialog = useDialog();
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
// 修改 currentPath 初始化逻辑,去掉可能的前导斜杠
|
||
let currentPath = decodeURIComponent(location.hash.slice(1) || '/');
|
||
if (/^\/[A-Z]:$/i.test(currentPath)) {
|
||
currentPath = currentPath.slice(1);
|
||
}
|
||
const [editingFile, setEditingFile] = useState<{
|
||
path: string;
|
||
content: string;
|
||
} | null>(null);
|
||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||
const [newFileName, setNewFileName] = useState('');
|
||
const [fileType, setFileType] = useState<'file' | 'directory'>('file');
|
||
const [selectedFiles, setSelectedFiles] = useState<Selection>(new Set());
|
||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
|
||
const [renamingFile, setRenamingFile] = useState<string>('');
|
||
const [moveTargetPath, setMoveTargetPath] = useState('');
|
||
const [jumpPath, setJumpPath] = useState('');
|
||
const [previewFile, setPreviewFile] = useState<string>('');
|
||
const [showUpload, setShowUpload] = useState<boolean>(false);
|
||
|
||
const sortFiles = (files: FileInfo[], descriptor: typeof sortDescriptor) => {
|
||
return [...files].sort((a, b) => {
|
||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||
const direction = descriptor.direction === 'ascending' ? 1 : -1;
|
||
switch (descriptor.column) {
|
||
case 'name':
|
||
return direction * a.name.localeCompare(b.name);
|
||
case 'type': {
|
||
const aType = a.isDirectory ? '目录' : '文件';
|
||
const bType = a.isDirectory ? '目录' : '文件';
|
||
return direction * aType.localeCompare(bType);
|
||
}
|
||
case 'size':
|
||
return direction * ((a.size || 0) - (b.size || 0));
|
||
case 'mtime':
|
||
return (
|
||
direction *
|
||
(new Date(a.mtime).getTime() - new Date(b.mtime).getTime())
|
||
);
|
||
default:
|
||
return 0;
|
||
}
|
||
});
|
||
};
|
||
|
||
const loadFiles = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const fileList = await FileManager.listFiles(currentPath);
|
||
setFiles(sortFiles(fileList, sortDescriptor));
|
||
} catch (_error) {
|
||
toast.error('加载文件列表失败');
|
||
setFiles([]);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadFiles();
|
||
}, [currentPath]);
|
||
|
||
const handleSortChange = (descriptor: typeof sortDescriptor) => {
|
||
setSortDescriptor(descriptor);
|
||
setFiles((prev) => sortFiles(prev, descriptor));
|
||
};
|
||
|
||
const handleDirectoryClick = (dirPath: string) => {
|
||
if (dirPath === '..') {
|
||
if (/^[A-Z]:$/i.test(currentPath)) {
|
||
navigate('/file_manager#/');
|
||
return;
|
||
}
|
||
const parentPath = path.dirname(currentPath);
|
||
navigate(
|
||
`/file_manager#${encodeURIComponent(parentPath === currentPath ? '/' : parentPath)}`
|
||
);
|
||
return;
|
||
}
|
||
navigate(
|
||
`/file_manager#${encodeURIComponent(path.join(currentPath, dirPath))}`
|
||
);
|
||
};
|
||
|
||
const handleEdit = async (filePath: string) => {
|
||
try {
|
||
const content = await FileManager.readFile(filePath);
|
||
setEditingFile({ path: filePath, content });
|
||
} catch (_error) {
|
||
toast.error('打开文件失败');
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!editingFile) return;
|
||
try {
|
||
await FileManager.writeFile(editingFile.path, editingFile.content);
|
||
toast.success('保存成功');
|
||
setEditingFile(null);
|
||
loadFiles();
|
||
} catch (_error) {
|
||
toast.error('保存失败');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (filePath: string) => {
|
||
dialog.confirm({
|
||
title: '删除文件',
|
||
content: <div>确定要删除文件 {filePath} 吗?</div>,
|
||
onConfirm: async () => {
|
||
try {
|
||
await FileManager.delete(filePath);
|
||
toast.success('删除成功');
|
||
loadFiles();
|
||
} catch (_error) {
|
||
toast.error('删除失败');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleCreate = async () => {
|
||
if (!newFileName) return;
|
||
const newPath = path.join(currentPath, newFileName);
|
||
try {
|
||
if (fileType === 'directory') {
|
||
if (!(await FileManager.createDirectory(newPath))) {
|
||
toast.error('目录已存在');
|
||
return;
|
||
}
|
||
} else {
|
||
if (!(await FileManager.createFile(newPath))) {
|
||
toast.error('文件已存在');
|
||
return;
|
||
}
|
||
}
|
||
toast.success('创建成功');
|
||
setIsCreateModalOpen(false);
|
||
setNewFileName('');
|
||
loadFiles();
|
||
} catch (error) {
|
||
toast.error((error as Error)?.message || '创建失败');
|
||
}
|
||
};
|
||
|
||
const handleBatchDelete = async () => {
|
||
const selectedArray =
|
||
selectedFiles instanceof Set
|
||
? Array.from(selectedFiles)
|
||
: files.map((f) => f.name);
|
||
if (selectedArray.length === 0) return;
|
||
dialog.confirm({
|
||
title: '批量删除',
|
||
content: <div>确定要删除选中的 {selectedArray.length} 个项目吗?</div>,
|
||
onConfirm: async () => {
|
||
try {
|
||
const paths = selectedArray.map((key) =>
|
||
path.join(currentPath, key.toString())
|
||
);
|
||
await FileManager.batchDelete(paths);
|
||
toast.success('批量删除成功');
|
||
setSelectedFiles(new Set());
|
||
loadFiles();
|
||
} catch (_error) {
|
||
toast.error('批量删除失败');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleRename = async () => {
|
||
if (!renamingFile || !newFileName) return;
|
||
try {
|
||
await FileManager.rename(
|
||
path.join(currentPath, renamingFile),
|
||
path.join(currentPath, newFileName)
|
||
);
|
||
toast.success('重命名成功');
|
||
setIsRenameModalOpen(false);
|
||
setRenamingFile('');
|
||
setNewFileName('');
|
||
loadFiles();
|
||
} catch (_error) {
|
||
toast.error('重命名失败');
|
||
}
|
||
};
|
||
|
||
const handleMove = async (sourceName: string) => {
|
||
if (!moveTargetPath) return;
|
||
try {
|
||
await FileManager.move(
|
||
path.join(currentPath, sourceName),
|
||
path.join(moveTargetPath, sourceName)
|
||
);
|
||
toast.success('移动成功');
|
||
setIsMoveModalOpen(false);
|
||
setMoveTargetPath('');
|
||
loadFiles();
|
||
} catch (_error) {
|
||
toast.error('移动失败');
|
||
}
|
||
};
|
||
|
||
const handleBatchMove = async () => {
|
||
if (!moveTargetPath) return;
|
||
const selectedArray =
|
||
selectedFiles instanceof Set
|
||
? Array.from(selectedFiles)
|
||
: files.map((f) => f.name);
|
||
if (selectedArray.length === 0) return;
|
||
try {
|
||
const items = selectedArray.map((name) => ({
|
||
sourcePath: path.join(currentPath, name.toString()),
|
||
targetPath: path.join(moveTargetPath, name.toString()),
|
||
}));
|
||
await FileManager.batchMove(items);
|
||
toast.success('批量移动成功');
|
||
setIsMoveModalOpen(false);
|
||
setMoveTargetPath('');
|
||
setSelectedFiles(new Set());
|
||
loadFiles();
|
||
} catch (_error) {
|
||
toast.error('批量移动失败');
|
||
}
|
||
};
|
||
|
||
const handleCopyPath = (fileName: string) => {
|
||
navigator.clipboard.writeText(path.join(currentPath, fileName));
|
||
toast.success('路径已复制');
|
||
};
|
||
|
||
const handleMoveClick = (fileName: string) => {
|
||
setRenamingFile(fileName);
|
||
setMoveTargetPath('');
|
||
setIsMoveModalOpen(true);
|
||
};
|
||
|
||
const handleDownload = (filePath: string) => {
|
||
FileManager.download(filePath);
|
||
};
|
||
|
||
const handleBatchDownload = async () => {
|
||
const selectedArray =
|
||
selectedFiles instanceof Set
|
||
? Array.from(selectedFiles)
|
||
: files.map((f) => f.name);
|
||
if (selectedArray.length === 0) return;
|
||
const paths = selectedArray.map((key) =>
|
||
path.join(currentPath, key.toString())
|
||
);
|
||
await FileManager.batchDownload(paths);
|
||
};
|
||
|
||
const handlePreview = (filePath: string) => {
|
||
setPreviewFile(filePath);
|
||
};
|
||
|
||
const onDrop = async (acceptedFiles: File[]) => {
|
||
try {
|
||
// 遍历处理文件,保持文件夹结构
|
||
const processedFiles = acceptedFiles.map((file) => {
|
||
const relativePath = file.webkitRelativePath || file.name;
|
||
// 不需要额外的编码处理,浏览器会自动处理
|
||
return new File([file], relativePath, {
|
||
type: file.type,
|
||
lastModified: file.lastModified,
|
||
});
|
||
});
|
||
|
||
toast
|
||
.promise(FileManager.upload(currentPath, processedFiles), {
|
||
loading: '正在上传文件...',
|
||
success: '上传成功',
|
||
error: '上传失败',
|
||
})
|
||
.then(() => {
|
||
loadFiles();
|
||
});
|
||
} catch (_error) {
|
||
toast.error('上传失败');
|
||
}
|
||
};
|
||
|
||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||
onDrop,
|
||
noClick: true, // 禁用自动点击,使用 open() 手动触发
|
||
onDragOver: (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
},
|
||
useFsAccessApi: false, // 添加此选项以避免某些浏览器的文件系统API问题
|
||
});
|
||
|
||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||
const hasBackground = !!backgroundImage;
|
||
|
||
return (
|
||
<div className='h-full flex flex-col relative gap-4 w-full p-2 md:p-4'>
|
||
<div className={clsx(
|
||
'mb-4 flex flex-col md:flex-row items-stretch md:items-center gap-4 sticky top-14 z-10 backdrop-blur-sm shadow-sm py-2 px-4 rounded-sm transition-colors',
|
||
hasBackground
|
||
? 'bg-white/20 dark:bg-black/10 border border-white/40 dark:border-white/10'
|
||
: 'bg-white/60 dark:bg-black/40 border border-white/40 dark:border-white/10'
|
||
)}
|
||
>
|
||
<div className='flex items-center gap-2 overflow-x-auto hide-scrollbar pb-1 md:pb-0'>
|
||
<Button
|
||
radius='sm'
|
||
color='primary'
|
||
size='sm'
|
||
isIconOnly
|
||
variant='flat'
|
||
onPress={() => handleDirectoryClick('..')}
|
||
className='text-lg min-w-8'
|
||
>
|
||
<TiArrowBack />
|
||
</Button>
|
||
|
||
<Button
|
||
radius='sm'
|
||
color='primary'
|
||
size='sm'
|
||
isIconOnly
|
||
variant='flat'
|
||
onPress={() => setIsCreateModalOpen(true)}
|
||
className='text-lg min-w-8'
|
||
>
|
||
<FiPlus />
|
||
</Button>
|
||
|
||
<Button
|
||
radius='sm'
|
||
color='primary'
|
||
isLoading={loading}
|
||
size='sm'
|
||
isIconOnly
|
||
variant='flat'
|
||
onPress={loadFiles}
|
||
className='text-lg min-w-8'
|
||
>
|
||
<MdRefresh />
|
||
</Button>
|
||
<Button
|
||
radius='sm'
|
||
color='primary'
|
||
size='sm'
|
||
isIconOnly
|
||
variant='flat'
|
||
onPress={() => setShowUpload((prev) => !prev)}
|
||
className='text-lg min-w-8'
|
||
>
|
||
<FiUpload />
|
||
</Button>
|
||
|
||
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
|
||
selectedFiles === 'all') && (
|
||
<>
|
||
<Button
|
||
radius='sm'
|
||
color='primary'
|
||
size='sm'
|
||
variant='flat'
|
||
onPress={handleBatchDelete}
|
||
className='text-sm px-2 min-w-fit'
|
||
startContent={<TbTrash className='text-lg' />}
|
||
>
|
||
(
|
||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||
)
|
||
</Button>
|
||
<Button
|
||
radius='sm'
|
||
color='primary'
|
||
size='sm'
|
||
variant='flat'
|
||
onPress={() => {
|
||
setMoveTargetPath('');
|
||
setIsMoveModalOpen(true);
|
||
}}
|
||
className='text-sm px-2 min-w-fit'
|
||
startContent={<FiMove className='text-lg' />}
|
||
>
|
||
(
|
||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||
)
|
||
</Button>
|
||
<Button
|
||
radius='sm'
|
||
color='primary'
|
||
size='sm'
|
||
variant='flat'
|
||
onPress={handleBatchDownload}
|
||
className='text-sm px-2 min-w-fit'
|
||
startContent={<FiDownload className='text-lg' />}
|
||
>
|
||
(
|
||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||
)
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className='flex flex-col md:flex-row flex-1 gap-2 overflow-hidden items-stretch md:items-center'>
|
||
<Breadcrumbs
|
||
radius='sm'
|
||
className='flex-1 bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border border-white/20 px-2 py-2 rounded-sm overflow-x-auto hide-scrollbar whitespace-nowrap'
|
||
>
|
||
{currentPath.split('/').map((part, index, parts) => (
|
||
<BreadcrumbItem
|
||
key={part}
|
||
isCurrent={index === parts.length - 1}
|
||
onPress={() => {
|
||
const newPath = parts.slice(0, index + 1).join('/');
|
||
navigate(`/file_manager#${encodeURIComponent(newPath)}`);
|
||
}}
|
||
>
|
||
{part}
|
||
</BreadcrumbItem>
|
||
))}
|
||
</Breadcrumbs>
|
||
<Input
|
||
radius='sm'
|
||
type='text'
|
||
placeholder='输入跳转路径'
|
||
value={jumpPath}
|
||
onChange={(e) => setJumpPath(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && jumpPath.trim() !== '') {
|
||
navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`);
|
||
}
|
||
}}
|
||
className='w-full md:w-64'
|
||
classNames={{
|
||
inputWrapper: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<motion.div
|
||
initial={{ height: 0 }}
|
||
animate={{ height: showUpload ? 'auto' : 0 }}
|
||
transition={{ duration: 0.2 }}
|
||
className={clsx(
|
||
'border-dashed rounded-sm text-center overflow-hidden',
|
||
isDragActive ? 'border-primary bg-primary/10' : 'border-default-300',
|
||
showUpload ? 'mb-4 border-2' : 'border-none'
|
||
)}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}}
|
||
>
|
||
<div {...getRootProps()} className='w-full h-full p-4 cursor-pointer hover:bg-default-100 transition-colors'>
|
||
<input {...getInputProps()} multiple />
|
||
<div className='flex flex-col items-center gap-2'>
|
||
<FiUpload className='text-3xl text-primary' />
|
||
<p className='text-default-600'>拖拽文件到此处上传</p>
|
||
<Button radius='sm' color='primary' size='sm' variant='flat' onPress={open}>
|
||
点击选择文件
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
|
||
<FileTable
|
||
files={files}
|
||
currentPath={currentPath}
|
||
loading={loading}
|
||
sortDescriptor={sortDescriptor}
|
||
onSortChange={handleSortChange}
|
||
selectedFiles={selectedFiles}
|
||
onSelectionChange={setSelectedFiles}
|
||
onDirectoryClick={handleDirectoryClick}
|
||
onEdit={handleEdit}
|
||
onPreview={handlePreview}
|
||
onRenameRequest={(name) => {
|
||
setRenamingFile(name);
|
||
setNewFileName(name);
|
||
setIsRenameModalOpen(true);
|
||
}}
|
||
onMoveRequest={handleMoveClick}
|
||
onCopyPath={handleCopyPath}
|
||
onDelete={handleDelete}
|
||
onDownload={handleDownload}
|
||
/>
|
||
|
||
<FileEditModal
|
||
isOpen={!!editingFile}
|
||
file={editingFile}
|
||
onClose={() => setEditingFile(null)}
|
||
onSave={handleSave}
|
||
onContentChange={(newContent) =>
|
||
setEditingFile((prev) =>
|
||
prev ? { ...prev, content: newContent ?? '' } : null
|
||
)}
|
||
/>
|
||
|
||
<FilePreviewModal
|
||
isOpen={!!previewFile}
|
||
filePath={previewFile}
|
||
onClose={() => setPreviewFile('')}
|
||
/>
|
||
|
||
<CreateFileModal
|
||
isOpen={isCreateModalOpen}
|
||
fileType={fileType}
|
||
newFileName={newFileName}
|
||
onTypeChange={setFileType}
|
||
onNameChange={(e) => setNewFileName(e.target.value)}
|
||
onClose={() => setIsCreateModalOpen(false)}
|
||
onCreate={handleCreate}
|
||
/>
|
||
|
||
<RenameModal
|
||
isOpen={isRenameModalOpen}
|
||
newFileName={newFileName}
|
||
onNameChange={(e) => setNewFileName(e.target.value)}
|
||
onClose={() => setIsRenameModalOpen(false)}
|
||
onRename={handleRename}
|
||
/>
|
||
|
||
<MoveModal
|
||
isOpen={isMoveModalOpen}
|
||
moveTargetPath={moveTargetPath}
|
||
selectionInfo={
|
||
selectedFiles instanceof Set && selectedFiles.size > 0
|
||
? `${selectedFiles.size} 个项目`
|
||
: renamingFile
|
||
}
|
||
onClose={() => setIsMoveModalOpen(false)}
|
||
onMove={() =>
|
||
selectedFiles instanceof Set && selectedFiles.size > 0
|
||
? handleBatchMove()
|
||
: handleMove(renamingFile)}
|
||
onSelect={(dir) => setMoveTargetPath(dir)} // 替换原有 onTargetChange
|
||
/>
|
||
</div>
|
||
);
|
||
}
|