feat: file manager

This commit is contained in:
bietiaop
2025-02-01 22:47:51 +08:00
parent ef9907f4b6
commit 719189be55
8 changed files with 1208 additions and 3 deletions

View File

@@ -36,6 +36,7 @@
"@heroui/spinner": "2.2.7",
"@heroui/switch": "2.2.9",
"@heroui/system": "2.4.7",
"@heroui/table": "^2.2.9",
"@heroui/tabs": "2.2.8",
"@heroui/theme": "2.4.6",
"@heroui/tooltip": "2.2.8",
@@ -56,6 +57,7 @@
"framer-motion": "^12.0.6",
"monaco-editor": "^0.52.2",
"motion": "^12.0.6",
"path-browserify": "^1.0.1",
"qface": "^1.4.1",
"qrcode.react": "^4.2.0",
"quill": "^2.0.3",
@@ -83,6 +85,7 @@
"@types/event-source-polyfill": "^1.0.5",
"@types/fabric": "^5.3.9",
"@types/node": "^22.12.0",
"@types/path-browserify": "^1.0.3",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-window": "^1.8.8",

View File

@@ -0,0 +1,166 @@
import {
FaFile,
FaFileAudio,
FaFileCode,
FaFileCsv,
FaFileExcel,
FaFileImage,
FaFileLines,
FaFilePdf,
FaFilePowerpoint,
FaFileVideo,
FaFileWord,
FaFileZipper,
FaFolderClosed
} from 'react-icons/fa6'
export interface FileIconProps {
name?: string
isDirectory?: boolean
}
const FileIcon = (props: FileIconProps) => {
const { name, isDirectory = false } = props
if (isDirectory) {
return <FaFolderClosed className="text-yellow-500" />
}
const ext = name?.split('.').pop() || ''
if (ext) {
switch (ext.toLowerCase()) {
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'svg':
case 'bmp':
case 'ico':
case 'webp':
case 'tiff':
case 'tif':
case 'heic':
case 'heif':
case 'avif':
case 'apng':
case 'flif':
case 'ai':
case 'psd':
case 'xcf':
case 'sketch':
case 'fig':
case 'xd':
case 'svgz':
return <FaFileImage className="text-green-500" />
case 'pdf':
return <FaFilePdf className="text-red-500" />
case 'doc':
case 'docx':
return <FaFileWord className="text-blue-500" />
case 'xls':
case 'xlsx':
return <FaFileExcel className="text-green-500" />
case 'csv':
return <FaFileCsv className="text-green-500" />
case 'ppt':
case 'pptx':
return <FaFilePowerpoint className="text-red-500" />
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
case 'bz2':
case 'xz':
case 'lz':
case 'lzma':
case 'zst':
case 'zstd':
case 'z':
case 'taz':
case 'tz':
case 'tzo':
return <FaFileZipper className="text-green-500" />
case 'txt':
return <FaFileLines className="text-gray-500" />
case 'mp3':
case 'wav':
case 'flac':
return <FaFileAudio className="text-green-500" />
case 'mp4':
case 'avi':
case 'mov':
case 'wmv':
return <FaFileVideo className="text-red-500" />
case 'html':
case 'css':
case 'js':
case 'ts':
case 'jsx':
case 'tsx':
case 'json':
case 'xml':
case 'yaml':
case 'yml':
case 'md':
case 'sh':
case 'py':
case 'java':
case 'c':
case 'cpp':
case 'cs':
case 'go':
case 'php':
case 'rb':
case 'pl':
case 'swift':
case 'kt':
case 'rs':
case 'sql':
case 'r':
case 'scala':
case 'groovy':
case 'dart':
case 'lua':
case 'perl':
case 'h':
case 'm':
case 'mm':
case 'makefile':
case 'cmake':
case 'dockerfile':
case 'gradle':
case 'properties':
case 'ini':
case 'conf':
case 'env':
case 'bat':
case 'cmd':
case 'ps1':
case 'psm1':
case 'psd1':
case 'ps1xml':
case 'psc1':
case 'pssc':
case 'nuspec':
case 'resx':
case 'resw':
case 'csproj':
case 'vbproj':
case 'vcxproj':
case 'fsproj':
case 'sln':
case 'suo':
case 'user':
case 'userosscache':
case 'sln.docstates':
case 'dll':
return <FaFileCode className="text-blue-500" />
default:
return <FaFile className="text-gray-500" />
}
}
return <FaFile className="text-gray-500" />
}
export default FileIcon

View File

@@ -0,0 +1,90 @@
import { serverRequest } from '@/utils/request'
export interface FileInfo {
name: string
isDirectory: boolean
size: number
mtime: Date
}
export default class FileManager {
public static async listFiles(path: string = '/') {
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
`/File/list?path=${encodeURIComponent(path)}`
)
return data.data
}
public static async createDirectory(path: string): Promise<boolean> {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/mkdir',
{ path }
)
return data.data
}
public static async delete(path: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/delete',
{ path }
)
return data.data
}
public static async readFile(path: string) {
const { data } = await serverRequest.get<ServerResponse<string>>(
`/File/read?path=${encodeURIComponent(path)}`
)
return data.data
}
public static async writeFile(path: string, content: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/write',
{ path, content }
)
return data.data
}
public static async createFile(path: string): Promise<boolean> {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/create',
{ path }
)
return data.data
}
public static async batchDelete(paths: string[]) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/batchDelete',
{ paths }
)
return data.data
}
public static async rename(oldPath: string, newPath: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/rename',
{ oldPath, newPath }
)
return data.data
}
public static async move(sourcePath: string, targetPath: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/move',
{ sourcePath, targetPath }
)
return data.data
}
public static async batchMove(
items: { sourcePath: string; targetPath: string }[]
) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/batchMove',
{ items }
)
return data.data
}
}

View File

@@ -0,0 +1,655 @@
import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
import { Button, ButtonGroup } from '@heroui/button'
import { Code } from '@heroui/code'
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 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 { 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 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()
const currentPath = decodeURIComponent(location.hash.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 sortFiles = (files: FileInfo[], descriptor: 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 = b.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 files = await FileManager.listFiles(currentPath)
setFiles(sortFiles(files, sortDescriptor))
} catch (error) {
toast.error('加载文件列表失败')
setFiles([])
}
setLoading(false)
}
useEffect(() => {
loadFiles()
}, [currentPath])
const handleSortChange = (descriptor: 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)}`)
return
}
const newPath = path.join(currentPath, dirPath)
navigate(`/file_manager#${encodeURIComponent(newPath)}`)
}
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>
<Code>{filePath}</Code>
</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') {
const result = await FileManager.createDirectory(newPath)
if (!result) {
toast.error('目录已存在')
return
}
} else {
const result = await FileManager.createFile(newPath)
if (!result) {
toast.error('文件已存在')
return
}
}
toast.success('创建成功')
setIsCreateModalOpen(false)
setNewFileName('')
loadFiles()
} catch (error) {
const err = error as Error
toast.error(err?.message || '创建失败')
}
}
const handleBatchDelete = async () => {
// 处理 Selection 类型
const selectedArray =
selectedFiles === 'all'
? files.map((f) => f.name)
: Array.from(selectedFiles as Set<string>)
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))
await FileManager.batchDelete(paths)
toast.success('批量删除成功')
setSelectedFiles(new Set())
loadFiles()
} catch (error) {
toast.error('批量删除失败')
}
}
})
}
// 处理重命名
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)
toast.success('重命名成功')
setIsRenameModalOpen(false)
setRenamingFile('')
setNewFileName('')
loadFiles()
} catch (error) {
toast.error('重命名失败')
}
}
// 处理移动
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)
toast.success('移动成功')
setIsMoveModalOpen(false)
setMoveTargetPath('')
loadFiles()
} catch (error) {
toast.error('移动失败')
}
}
// 处理批量移动
const handleBatchMove = async () => {
if (!moveTargetPath) return
const selectedArray =
selectedFiles === 'all'
? files.map((f) => f.name)
: Array.from(selectedFiles as Set<string>)
if (selectedArray.length === 0) return
try {
const items = selectedArray.map((name) => ({
sourcePath: path.join(currentPath, name),
targetPath: path.join(moveTargetPath, name)
}))
await FileManager.batchMove(items)
toast.success('批量移动成功')
setIsMoveModalOpen(false)
setMoveTargetPath('')
setSelectedFiles(new Set())
loadFiles()
} catch (error) {
toast.error('批量移动失败')
}
}
// 添加复制路径处理函数
const handleCopyPath = (fileName: string) => {
const fullPath = path.join(currentPath, fileName)
navigator.clipboard.writeText(fullPath)
toast.success('路径已复制')
}
// 修改移动按钮的点击处理
const handleMoveClick = (fileName: string) => {
setRenamingFile(fileName)
setMoveTargetPath('')
setIsMoveModalOpen(true)
}
return (
<div className="p-4">
<div className="mb-4 flex items-center gap-4">
<Button
color="danger"
size="sm"
isIconOnly
variant="flat"
onPress={() => handleDirectoryClick('..')}
className="text-lg"
radius="full"
>
<TiArrowBack />
</Button>
<Button
color="danger"
size="sm"
isIconOnly
variant="flat"
onPress={() => setIsCreateModalOpen(true)}
className="text-lg"
radius="full"
>
<FiPlus />
</Button>
{(selectedFiles === 'all' ||
(selectedFiles as Set<string>).size > 0) && (
<>
<Button
color="danger"
size="sm"
variant="flat"
onPress={handleBatchDelete}
startContent={<FiTrash2 />}
>
(
{selectedFiles === 'all'
? files.length
: (selectedFiles as Set<string>).size}
)
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => {
setMoveTargetPath('')
setIsMoveModalOpen(true)
}}
startContent={<FiMove />}
>
</Button>
</>
)}
<Breadcrumbs>
{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>
</div>
<Table
aria-label="文件列表"
sortDescriptor={sortDescriptor}
onSortChange={handleSortChange}
onSelectionChange={setSelectedFiles}
defaultSelectedKeys={[]}
selectedKeys={selectedFiles}
selectionMode="multiple"
>
<TableHeader>
<TableColumn key="name" allowsSorting>
</TableColumn>
<TableColumn key="type" allowsSorting>
</TableColumn>
<TableColumn key="size" allowsSorting>
</TableColumn>
<TableColumn key="mtime" allowsSorting>
</TableColumn>
<TableColumn key="actions"></TableColumn>
</TableHeader>
<TableBody
isLoading={loading}
loadingContent={
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
}
items={files}
>
{(file) => (
<TableRow key={file.name}>
<TableCell>
<Button
variant="light"
onPress={() => {
if (file.isDirectory) {
handleDirectoryClick(file.name)
} else {
handleEdit(path.join(currentPath, file.name))
}
}}
className="text-left justify-start"
startContent={
<FileIcon name={file.name} isDirectory={file.isDirectory} />
}
>
{file.name}
</Button>
</TableCell>
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell>
{isNaN(file.size) || file.isDirectory
? '-'
: `${file.size} 字节`}
</TableCell>
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell>
<ButtonGroup size="sm">
<Tooltip content="重命名">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => {
setRenamingFile(file.name)
setNewFileName(file.name)
setIsRenameModalOpen(true)
}}
>
<FiEdit2 />
</Button>
</Tooltip>
<Tooltip content="移动">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => handleMoveClick(file.name)}
>
<FiMove />
</Button>
</Tooltip>
<Tooltip content="复制路径">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => handleCopyPath(file.name)}
>
<FiCopy />
</Button>
</Tooltip>
<Tooltip content="删除">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() =>
handleDelete(path.join(currentPath, file.name))
}
>
<FiTrash2 />
</Button>
</Tooltip>
</ButtonGroup>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* 文件编辑对话框 */}
<Modal
size="full"
isOpen={!!editingFile}
onClose={() => setEditingFile(null)}
>
<ModalContent>
<ModalHeader className="flex items-center gap-2 bg-content2 bg-opacity-50">
<span></span>
<Code className="text-xs">{editingFile?.path}</Code>
</ModalHeader>
<ModalBody className="p-0">
<div className="h-full">
<CodeEditor
height="100%"
value={editingFile?.content}
onChange={(value) =>
setEditingFile((prev) =>
prev ? { ...prev, content: value || '' } : null
)
}
options={{ wordWrap: 'on' }}
/>
</div>
</ModalBody>
<ModalFooter>
<Button
color="danger"
variant="flat"
onPress={() => setEditingFile(null)}
>
</Button>
<Button color="danger" onPress={handleSave}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 新建文件/目录对话框 */}
<Modal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<ButtonGroup color="danger">
<Button
variant={fileType === 'file' ? 'solid' : 'flat'}
onPress={() => setFileType('file')}
>
</Button>
<Button
variant={fileType === 'directory' ? 'solid' : 'flat'}
onPress={() => setFileType('directory')}
>
</Button>
</ButtonGroup>
<Input
label="名称"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
/>
</div>
</ModalBody>
<ModalFooter>
<Button
color="danger"
variant="flat"
onPress={() => setIsCreateModalOpen(false)}
>
</Button>
<Button color="danger" onPress={handleCreate}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 重命名对话框 */}
<Modal
isOpen={isRenameModalOpen}
onClose={() => setIsRenameModalOpen(false)}
>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<Input
label="新名称"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
/>
</ModalBody>
<ModalFooter>
<Button
color="danger"
variant="flat"
onPress={() => setIsRenameModalOpen(false)}
>
</Button>
<Button color="danger" onPress={handleRename}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 移动对话框 */}
<Modal isOpen={isMoveModalOpen} onClose={() => setIsMoveModalOpen(false)}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<Input
label="目标路径"
value={moveTargetPath}
onChange={(e) => setMoveTargetPath(e.target.value)}
placeholder="请输入完整目标路径"
/>
<p className="text-sm text-gray-500">
{selectedFiles === 'all' ||
(selectedFiles as Set<string>).size > 0
? `${selectedFiles === 'all' ? files.length : (selectedFiles as Set<string>).size} 个项目`
: renamingFile}
</p>
</div>
</ModalBody>
<ModalFooter>
<Button
color="danger"
variant="flat"
onPress={() => setIsMoveModalOpen(false)}
>
</Button>
<Button
color="danger"
onPress={() =>
selectedFiles === 'all' ||
(selectedFiles as Set<string>).size > 0
? handleBatchMove()
: handleMove(renamingFile)
}
>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -1,8 +1,6 @@
import { AnimatePresence, motion } from 'motion/react'
import { Route, Routes, useLocation } from 'react-router-dom'
import UnderConstruction from '@/components/under_construction'
import DefaultLayout from '@/layouts/default'
import DashboardIndexPage from './dashboard'
@@ -11,6 +9,7 @@ import ConfigPage from './dashboard/config'
import DebugPage from './dashboard/debug'
import HttpDebug from './dashboard/debug/http'
import WSDebug from './dashboard/debug/websocket'
import FileManagerPage from './dashboard/file_manager'
import LogsPage from './dashboard/logs'
import NetworkPage from './dashboard/network'
import TerminalPage from './dashboard/terminal'
@@ -36,7 +35,7 @@ export default function IndexPage() {
<Route path="ws" element={<WSDebug />} />
<Route path="http" element={<HttpDebug />} />
</Route>
<Route element={<UnderConstruction />} path="/file_manager" />
<Route element={<FileManagerPage />} path="/file_manager" />
<Route element={<TerminalPage />} path="/terminal" />
<Route element={<AboutPage />} path="/about" />
</Routes>