Files
NapCatQQ/packages/napcat-webui-frontend/src/pages/dashboard/file_manager.tsx

582 lines
19 KiB
TypeScript
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.
/* 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>
);
}