diff --git a/napcat.webui/src/components/file_manage/create_file_modal.tsx b/napcat.webui/src/components/file_manage/create_file_modal.tsx new file mode 100644 index 00000000..b4694f07 --- /dev/null +++ b/napcat.webui/src/components/file_manage/create_file_modal.tsx @@ -0,0 +1,64 @@ +import { Button, ButtonGroup } from '@heroui/button' +import { Input } from '@heroui/input' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader +} from '@heroui/modal' + +interface CreateFileModalProps { + isOpen: boolean + fileType: 'file' | 'directory' + newFileName: string + onTypeChange: (type: 'file' | 'directory') => void + onNameChange: (e: React.ChangeEvent) => void + onClose: () => void + onCreate: () => void +} + +export default function CreateFileModal({ + isOpen, + fileType, + newFileName, + onTypeChange, + onNameChange, + onClose, + onCreate +}: CreateFileModalProps) { + return ( + + + 新建 + +
+ + + + + +
+
+ + + + +
+
+ ) +} diff --git a/napcat.webui/src/components/file_manage/file_edit_modal.tsx b/napcat.webui/src/components/file_manage/file_edit_modal.tsx new file mode 100644 index 00000000..cb34e51a --- /dev/null +++ b/napcat.webui/src/components/file_manage/file_edit_modal.tsx @@ -0,0 +1,56 @@ +import { Button } from '@heroui/button' +import { Code } from '@heroui/code' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader +} from '@heroui/modal' + +import CodeEditor from '@/components/code_editor' + +interface FileEditModalProps { + isOpen: boolean + file: { path: string; content: string } | null + onClose: () => void + onSave: () => void + onContentChange: (newContent?: string) => void +} + +export default function FileEditModal({ + isOpen, + file, + onClose, + onSave, + onContentChange +}: FileEditModalProps) { + return ( + + + + 编辑文件 + {file?.path} + + +
+ +
+
+ + + + +
+
+ ) +} diff --git a/napcat.webui/src/components/file_manage/file_table.tsx b/napcat.webui/src/components/file_manage/file_table.tsx new file mode 100644 index 00000000..6e6e86ec --- /dev/null +++ b/napcat.webui/src/components/file_manage/file_table.tsx @@ -0,0 +1,158 @@ +import { Button, ButtonGroup } from '@heroui/button' +import { Spinner } from '@heroui/spinner' +import { + type Selection, + type SortDescriptor, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow +} from '@heroui/table' +import { Tooltip } from '@heroui/tooltip' +import path from 'path-browserify' +import { FiCopy, FiEdit2, FiMove, FiTrash2 } from 'react-icons/fi' + +import FileIcon from '@/components/file_icon' + +import type { FileInfo } from '@/controllers/file_manager' + +interface FileTableProps { + files: FileInfo[] + currentPath: string + loading: boolean + sortDescriptor: SortDescriptor + onSortChange: (descriptor: SortDescriptor) => void + selectedFiles: Selection + onSelectionChange: (selected: Selection) => void + onDirectoryClick: (dirPath: string) => void + onEdit: (filePath: string) => void + onRenameRequest: (name: string) => void + onMoveRequest: (name: string) => void + onCopyPath: (fileName: string) => void + onDelete: (filePath: string) => void +} + +export default function FileTable({ + files, + currentPath, + loading, + sortDescriptor, + onSortChange, + selectedFiles, + onSelectionChange, + onDirectoryClick, + onEdit, + onRenameRequest, + onMoveRequest, + onCopyPath, + onDelete +}: FileTableProps) { + return ( + + + + 名称 + + + 类型 + + + 大小 + + + 修改时间 + + 操作 + + + + + } + items={files} + > + {(file: FileInfo) => ( + + + + + {file.isDirectory ? '目录' : '文件'} + + {isNaN(file.size) || file.isDirectory ? '-' : `${file.size} 字节`} + + {new Date(file.mtime).toLocaleString()} + + + + + + + + + + + + + + + + + + )} + +
+ ) +} diff --git a/napcat.webui/src/components/file_manage/move_modal.tsx b/napcat.webui/src/components/file_manage/move_modal.tsx new file mode 100644 index 00000000..270aaf9d --- /dev/null +++ b/napcat.webui/src/components/file_manage/move_modal.tsx @@ -0,0 +1,168 @@ +import { Button } from '@heroui/button' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader +} from '@heroui/modal' +import { Spinner } from '@heroui/spinner' +import clsx from 'clsx' +import path from 'path-browserify' +import { useState } from 'react' +import { IoAdd, IoRemove } from 'react-icons/io5' + +import FileManager from '@/controllers/file_manager' + +interface MoveModalProps { + isOpen: boolean + moveTargetPath: string + selectionInfo: string + onClose: () => void + onMove: () => void + onSelect: (dir: string) => void // 新增回调 +} + +// 将 DirectoryTree 改为递归组件 +// 新增 selectedPath 属性,用于标识当前选中的目录 +function DirectoryTree({ + basePath, + onSelect, + selectedPath +}: { + basePath: string + onSelect: (dir: string) => void + selectedPath?: string +}) { + const [dirs, setDirs] = useState([]) + const [expanded, setExpanded] = useState(false) + // 新增loading状态 + const [loading, setLoading] = useState(false) + + const fetchDirectories = async () => { + try { + // 直接使用 basePath 调用接口,移除 process.platform 判断 + const list = await FileManager.listDirectories(basePath) + setDirs(list.map((item) => item.name)) + } catch (error) { + // ...error handling... + } + } + + const handleToggle = async () => { + if (!expanded) { + setExpanded(true) + setLoading(true) + await fetchDirectories() + setLoading(false) + } else { + setExpanded(false) + } + } + + const handleClick = () => { + onSelect(basePath) + handleToggle() + } + + // 计算显示的名称 + const getDisplayName = () => { + if (basePath === '/') return '/' + if (/^[A-Z]:$/i.test(basePath)) return basePath + return path.basename(basePath) + } + + // 更新 Button 的 variant 逻辑 + const isSeleted = selectedPath === basePath + const variant = isSeleted + ? 'solid' + : selectedPath && path.dirname(selectedPath) === basePath + ? 'flat' + : 'light' + + return ( +
+
+ } + > + {getDisplayName()} + + {expanded && ( +
+ {loading ? ( +
+ +
+ ) : ( + dirs.map((dirName) => { + const childPath = + basePath === '/' && /^[A-Z]:$/i.test(dirName) + ? dirName + : path.join(basePath, dirName) + return ( + + ) + }) + )} +
+ )} + + ) +} + +export default function MoveModal({ + isOpen, + moveTargetPath, + selectionInfo, + onClose, + onMove, + onSelect +}: MoveModalProps) { + return ( + + + 选择目标目录 + +
+ +
+

+ 当前选择:{moveTargetPath || '未选择'} +

+

移动项:{selectionInfo}

+
+ + + + +
+
+ ) +} diff --git a/napcat.webui/src/components/file_manage/rename_modal.tsx b/napcat.webui/src/components/file_manage/rename_modal.tsx new file mode 100644 index 00000000..3d0f256c --- /dev/null +++ b/napcat.webui/src/components/file_manage/rename_modal.tsx @@ -0,0 +1,44 @@ +import { Button } from '@heroui/button' +import { Input } from '@heroui/input' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader +} from '@heroui/modal' + +interface RenameModalProps { + isOpen: boolean + newFileName: string + onNameChange: (e: React.ChangeEvent) => void + onClose: () => void + onRename: () => void +} + +export default function RenameModal({ + isOpen, + newFileName, + onNameChange, + onClose, + onRename +}: RenameModalProps) { + return ( + + + 重命名 + + + + + + + + + + ) +} diff --git a/napcat.webui/src/controllers/file_manager.ts b/napcat.webui/src/controllers/file_manager.ts index 9af3f0dd..77d8599f 100644 --- a/napcat.webui/src/controllers/file_manager.ts +++ b/napcat.webui/src/controllers/file_manager.ts @@ -15,6 +15,14 @@ export default class FileManager { return data.data } + // 新增:按目录获取 + public static async listDirectories(path: string = '/') { + const { data } = await serverRequest.get>( + `/File/list?path=${encodeURIComponent(path)}&onlyDirectory=true` + ) + return data.data + } + public static async createDirectory(path: string): Promise { const { data } = await serverRequest.post>( '/File/mkdir', diff --git a/napcat.webui/src/pages/dashboard/file_manager.tsx b/napcat.webui/src/pages/dashboard/file_manager.tsx index 1ed2f057..98ac4497 100644 --- a/napcat.webui/src/pages/dashboard/file_manager.tsx +++ b/napcat.webui/src/pages/dashboard/file_manager.tsx @@ -1,35 +1,21 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs' -import { Button, ButtonGroup } from '@heroui/button' -import { Code } from '@heroui/code' +import { Button } from '@heroui/button' import { Input } from '@heroui/input' -import { - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader -} from '@heroui/modal' -import { Spinner } from '@heroui/spinner' -import { - SortDescriptor, - Table, - TableBody, - TableCell, - TableColumn, - TableHeader, - TableRow -} from '@heroui/table' -import { Tooltip } from '@heroui/tooltip' -import { Selection } from '@react-types/shared' +import type { Selection, SortDescriptor } from '@react-types/shared' import path from 'path-browserify' import { useEffect, useState } from 'react' import toast from 'react-hot-toast' -import { FiCopy, FiEdit2, FiMove, FiPlus, FiTrash2 } from 'react-icons/fi' +import { FiMove, FiPlus } 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 CodeEditor from '@/components/code_editor' -import FileIcon from '@/components/file_icon' +import CreateFileModal from '@/components/file_manage/create_file_modal' +import FileEditModal from '@/components/file_manage/file_edit_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' @@ -38,7 +24,6 @@ import FileManager, { FileInfo } from '@/controllers/file_manager' export default function FileManagerPage() { const [files, setFiles] = useState([]) const [loading, setLoading] = useState(false) - // 修改初始排序状态 const [sortDescriptor, setSortDescriptor] = useState({ column: 'name', direction: 'ascending' @@ -46,7 +31,11 @@ export default function FileManagerPage() { const dialog = useDialog() const location = useLocation() const navigate = useNavigate() - const currentPath = decodeURIComponent(location.hash.slice(1) || '/') + // 修改 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 @@ -59,22 +48,18 @@ export default function FileManagerPage() { const [isMoveModalOpen, setIsMoveModalOpen] = useState(false) const [renamingFile, setRenamingFile] = useState('') const [moveTargetPath, setMoveTargetPath] = useState('') + const [jumpPath, setJumpPath] = useState('') - const sortFiles = (files: FileInfo[], descriptor: SortDescriptor) => { + const sortFiles = (files: FileInfo[], descriptor: typeof sortDescriptor) => { return [...files].sort((a, b) => { - // 始终保持目录在前面 - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - + 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 = b.isDirectory ? '目录' : '文件' + const bType = a.isDirectory ? '目录' : '文件' return direction * aType.localeCompare(bType) } case 'size': @@ -93,8 +78,8 @@ export default function FileManagerPage() { const loadFiles = async () => { setLoading(true) try { - const files = await FileManager.listFiles(currentPath) - setFiles(sortFiles(files, sortDescriptor)) + const fileList = await FileManager.listFiles(currentPath) + setFiles(sortFiles(fileList, sortDescriptor)) } catch (error) { toast.error('加载文件列表失败') setFiles([]) @@ -106,38 +91,26 @@ export default function FileManagerPage() { loadFiles() }, [currentPath]) - const handleSortChange = (descriptor: SortDescriptor) => { + const handleSortChange = (descriptor: typeof sortDescriptor) => { setSortDescriptor(descriptor) setFiles((prev) => sortFiles(prev, descriptor)) } const handleDirectoryClick = (dirPath: string) => { - // Windows 系统下处理盘符切换 - if (dirPath.match(/^[A-Z]:\\?$/i)) { - navigate(`/file_manager#${encodeURIComponent(dirPath)}`) - return - } - - // 处理返回上级目录 if (dirPath === '..') { - // 检查是否在盘符根目录(如 C:) if (/^[A-Z]:$/i.test(currentPath)) { navigate('/file_manager#/') return } - const parentPath = path.dirname(currentPath) - // 如果已经在根目录,则显示盘符列表(Windows)或保持在根目录(其他系统) - if (parentPath === currentPath) { - navigate('/file_manager#/') - return - } - navigate(`/file_manager#${encodeURIComponent(parentPath)}`) + navigate( + `/file_manager#${encodeURIComponent(parentPath === currentPath ? '/' : parentPath)}` + ) return } - - const newPath = path.join(currentPath, dirPath) - navigate(`/file_manager#${encodeURIComponent(newPath)}`) + navigate( + `/file_manager#${encodeURIComponent(path.join(currentPath, dirPath))}` + ) } const handleEdit = async (filePath: string) => { @@ -164,11 +137,7 @@ export default function FileManagerPage() { const handleDelete = async (filePath: string) => { dialog.confirm({ title: '删除文件', - content: ( -
- 确定要删除文件 {filePath} 吗? -
- ), + content:
确定要删除文件 {filePath} 吗?
, onConfirm: async () => { try { await FileManager.delete(filePath) @@ -186,14 +155,12 @@ export default function FileManagerPage() { const newPath = path.join(currentPath, newFileName) try { if (fileType === 'directory') { - const result = await FileManager.createDirectory(newPath) - if (!result) { + if (!(await FileManager.createDirectory(newPath))) { toast.error('目录已存在') return } } else { - const result = await FileManager.createFile(newPath) - if (!result) { + if (!(await FileManager.createFile(newPath))) { toast.error('文件已存在') return } @@ -203,26 +170,24 @@ export default function FileManagerPage() { setNewFileName('') loadFiles() } catch (error) { - const err = error as Error - toast.error(err?.message || '创建失败') + toast.error((error as Error)?.message || '创建失败') } } const handleBatchDelete = async () => { - // 处理 Selection 类型 const selectedArray = - selectedFiles === 'all' - ? files.map((f) => f.name) - : Array.from(selectedFiles as Set) - + selectedFiles instanceof Set + ? Array.from(selectedFiles) + : files.map((f) => f.name) if (selectedArray.length === 0) return - dialog.confirm({ title: '批量删除', content:
确定要删除选中的 {selectedArray.length} 个项目吗?
, onConfirm: async () => { try { - const paths = selectedArray.map((key) => path.join(currentPath, key)) + const paths = selectedArray.map((key) => + path.join(currentPath, key.toString()) + ) await FileManager.batchDelete(paths) toast.success('批量删除成功') setSelectedFiles(new Set()) @@ -234,13 +199,13 @@ export default function FileManagerPage() { }) } - // 处理重命名 const handleRename = async () => { if (!renamingFile || !newFileName) return try { - const oldPath = path.join(currentPath, renamingFile) - const newPath = path.join(currentPath, newFileName) - await FileManager.rename(oldPath, newPath) + await FileManager.rename( + path.join(currentPath, renamingFile), + path.join(currentPath, newFileName) + ) toast.success('重命名成功') setIsRenameModalOpen(false) setRenamingFile('') @@ -251,13 +216,13 @@ export default function FileManagerPage() { } } - // 处理移动 const handleMove = async (sourceName: string) => { if (!moveTargetPath) return try { - const sourcePath = path.join(currentPath, sourceName) - const targetPath = path.join(moveTargetPath, sourceName) - await FileManager.move(sourcePath, targetPath) + await FileManager.move( + path.join(currentPath, sourceName), + path.join(moveTargetPath, sourceName) + ) toast.success('移动成功') setIsMoveModalOpen(false) setMoveTargetPath('') @@ -267,20 +232,17 @@ export default function FileManagerPage() { } } - // 处理批量移动 const handleBatchMove = async () => { if (!moveTargetPath) return const selectedArray = - selectedFiles === 'all' - ? files.map((f) => f.name) - : Array.from(selectedFiles as Set) - + 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), - targetPath: path.join(moveTargetPath, name) + sourcePath: path.join(currentPath, name.toString()), + targetPath: path.join(moveTargetPath, name.toString()) })) await FileManager.batchMove(items) toast.success('批量移动成功') @@ -293,14 +255,11 @@ export default function FileManagerPage() { } } - // 添加复制路径处理函数 const handleCopyPath = (fileName: string) => { - const fullPath = path.join(currentPath, fileName) - navigator.clipboard.writeText(fullPath) + navigator.clipboard.writeText(path.join(currentPath, fileName)) toast.success('路径已复制') } - // 修改移动按钮的点击处理 const handleMoveClick = (fileName: string) => { setRenamingFile(fileName) setMoveTargetPath('') @@ -309,7 +268,7 @@ export default function FileManagerPage() { return (
-
+
+ - {(selectedFiles === 'all' || - (selectedFiles as Set).size > 0) && ( + + + {((selectedFiles instanceof Set && selectedFiles.size > 0) || + selectedFiles === 'all') && ( <> )} - + {currentPath.split('/').map((part, index, parts) => ( ))} + setJumpPath(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && jumpPath.trim() !== '') { + navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`) + } + }} + className="ml-auto w-64" + />
- - - - 名称 - - - 类型 - - - 大小 - - - 修改时间 - - 操作 - - - - - } - items={files} - > - {(file) => ( - - - - - {file.isDirectory ? '目录' : '文件'} - - {isNaN(file.size) || file.isDirectory - ? '-' - : `${file.size} 字节`} - - {new Date(file.mtime).toLocaleString()} - - - - - - - - - - - - - - - - - - )} - -
+ onDirectoryClick={handleDirectoryClick} + onEdit={handleEdit} + onRenameRequest={(name) => { + setRenamingFile(name) + setNewFileName(name) + setIsRenameModalOpen(true) + }} + onMoveRequest={handleMoveClick} + onCopyPath={handleCopyPath} + onDelete={handleDelete} + /> - {/* 文件编辑对话框 */} - setEditingFile(null)} - > - - - 编辑文件 - {editingFile?.path} - - -
- - setEditingFile((prev) => - prev ? { ...prev, content: value || '' } : null - ) - } - options={{ wordWrap: 'on' }} - /> -
-
- - - - -
-
+ onSave={handleSave} + onContentChange={(newContent) => + setEditingFile((prev) => + prev ? { ...prev, content: newContent ?? '' } : null + ) + } + /> - {/* 新建文件/目录对话框 */} - setNewFileName(e.target.value)} onClose={() => setIsCreateModalOpen(false)} - > - - 新建 - -
- - - - - setNewFileName(e.target.value)} - /> -
-
- - - - -
-
+ onCreate={handleCreate} + /> - {/* 重命名对话框 */} - setNewFileName(e.target.value)} onClose={() => setIsRenameModalOpen(false)} - > - - 重命名 - - setNewFileName(e.target.value)} - /> - - - - - - - + onRename={handleRename} + /> - {/* 移动对话框 */} - setIsMoveModalOpen(false)}> - - 移动到 - -
- setMoveTargetPath(e.target.value)} - placeholder="请输入完整目标路径" - /> -

- 当前选择: - {selectedFiles === 'all' || - (selectedFiles as Set).size > 0 - ? `${selectedFiles === 'all' ? files.length : (selectedFiles as Set).size} 个项目` - : renamingFile} -

-
-
- - - - -
-
+ 0 + ? `${selectedFiles.size} 个项目` + : renamingFile + } + onClose={() => setIsMoveModalOpen(false)} + onMove={() => + selectedFiles instanceof Set && selectedFiles.size > 0 + ? handleBatchMove() + : handleMove(renamingFile) + } + onSelect={(dir) => setMoveTargetPath(dir)} // 替换原有 onTargetChange + />
) } diff --git a/src/webui/src/api/File.ts b/src/webui/src/api/File.ts index 6f23510d..1da78820 100644 --- a/src/webui/src/api/File.ts +++ b/src/webui/src/api/File.ts @@ -28,6 +28,10 @@ const getRootDirs = async (): Promise => { // 规范化路径 const normalizePath = (inputPath: string): string => { if (!inputPath) return isWindows ? 'C:\\' : '/'; + // 如果是Windows且输入为纯盘符(可能带或不带斜杠),统一返回 "X:\" + if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) { + return inputPath.slice(0, 2) + '\\'; + } return path.normalize(inputPath); }; @@ -41,16 +45,6 @@ interface FileInfo { // 添加系统文件黑名单 const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']); -// 检查文件或目录是否存在 -const checkExists = async (pathToCheck: string): Promise => { - try { - await fs.access(pathToCheck); - return true; - } catch { - return false; - } -}; - // 检查同类型的文件或目录是否存在 const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise => { try { @@ -67,6 +61,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => { try { const requestPath = (req.query.path as string) || (isWindows ? 'C:\\' : '/'); const normalizedPath = normalizePath(requestPath); + const onlyDirectory = req.query.onlyDirectory === 'true'; // 如果是根路径且在Windows系统上,返回盘符列表 if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) { @@ -95,7 +90,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => { } const files = await fs.readdir(normalizedPath); - const fileInfos: FileInfo[] = []; + let fileInfos: FileInfo[] = []; for (const file of files) { // 跳过系统文件 @@ -117,9 +112,14 @@ export const ListFilesHandler: RequestHandler = async (req, res) => { } } + // 如果请求参数 onlyDirectory 为 true,则只返回目录信息 + if (onlyDirectory) { + fileInfos = fileInfos.filter((info) => info.isDirectory); + } + return sendSuccess(res, fileInfos); } catch (error) { - // console.error('读取目录失败:', error); + console.error('读取目录失败:', error); return sendError(res, '读取目录失败'); } };