feat: 文件管理

This commit is contained in:
bietiaop
2025-02-02 11:37:58 +08:00
parent 719189be55
commit 6039e9bb46
8 changed files with 653 additions and 377 deletions

View File

@@ -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<HTMLInputElement>) => void
onClose: () => void
onCreate: () => void
}
export default function CreateFileModal({
isOpen,
fileType,
newFileName,
onTypeChange,
onNameChange,
onClose,
onCreate
}: CreateFileModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<ButtonGroup color="danger">
<Button
variant={fileType === 'file' ? 'solid' : 'flat'}
onPress={() => onTypeChange('file')}
>
</Button>
<Button
variant={fileType === 'directory' ? 'solid' : 'flat'}
onPress={() => onTypeChange('directory')}
>
</Button>
</ButtonGroup>
<Input label="名称" value={newFileName} onChange={onNameChange} />
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
<Button color="danger" onPress={onCreate}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -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 (
<Modal size="full" isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader className="flex items-center gap-2 bg-content2 bg-opacity-50">
<span></span>
<Code className="text-xs">{file?.path}</Code>
</ModalHeader>
<ModalBody className="p-0">
<div className="h-full">
<CodeEditor
height="100%"
value={file?.content || ''}
onChange={onContentChange}
options={{ wordWrap: 'on' }}
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
<Button color="danger" onPress={onSave}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -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 (
<Table
aria-label="文件列表"
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={onSelectionChange}
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: FileInfo) => (
<TableRow key={file.name}>
<TableCell>
<Button
variant="light"
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: onEdit(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={() => onRenameRequest(file.name)}
>
<FiEdit2 />
</Button>
</Tooltip>
<Tooltip content="移动">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onMoveRequest(file.name)}
>
<FiMove />
</Button>
</Tooltip>
<Tooltip content="复制路径">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onCopyPath(file.name)}
>
<FiCopy />
</Button>
</Tooltip>
<Tooltip content="删除">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDelete(path.join(currentPath, file.name))}
>
<FiTrash2 />
</Button>
</Tooltip>
</ButtonGroup>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}

View File

@@ -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<string[]>([])
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 (
<div className="ml-4">
<Button
onPress={handleClick}
className="py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md"
size="sm"
color="danger"
variant={variant}
startContent={
<div
className={clsx(
'rounded-md',
isSeleted ? 'bg-danger-600' : 'bg-danger-50'
)}
>
{expanded ? <IoRemove /> : <IoAdd />}
</div>
}
>
{getDisplayName()}
</Button>
{expanded && (
<div>
{loading ? (
<div className="flex py-1 px-8">
<Spinner size="sm" color="danger" />
</div>
) : (
dirs.map((dirName) => {
const childPath =
basePath === '/' && /^[A-Z]:$/i.test(dirName)
? dirName
: path.join(basePath, dirName)
return (
<DirectoryTree
key={childPath}
basePath={childPath}
onSelect={onSelect}
selectedPath={selectedPath}
/>
)
})
)}
</div>
)}
</div>
)
}
export default function MoveModal({
isOpen,
moveTargetPath,
selectionInfo,
onClose,
onMove,
onSelect
}: MoveModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className="rounded-md p-2 border border-default-300 overflow-auto max-h-60">
<DirectoryTree
basePath="/"
onSelect={onSelect}
selectedPath={moveTargetPath}
/>
</div>
<p className="text-sm text-default-500 mt-2">
{moveTargetPath || '未选择'}
</p>
<p className="text-sm text-default-500">{selectionInfo}</p>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
<Button color="danger" onPress={onMove}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -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<HTMLInputElement>) => void
onClose: () => void
onRename: () => void
}
export default function RenameModal({
isOpen,
newFileName,
onNameChange,
onClose,
onRename
}: RenameModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<Input label="新名称" value={newFileName} onChange={onNameChange} />
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
<Button color="danger" onPress={onRename}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -15,6 +15,14 @@ export default class FileManager {
return data.data return data.data
} }
// 新增:按目录获取
public static async listDirectories(path: string = '/') {
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
`/File/list?path=${encodeURIComponent(path)}&onlyDirectory=true`
)
return data.data
}
public static async createDirectory(path: string): Promise<boolean> { public static async createDirectory(path: string): Promise<boolean> {
const { data } = await serverRequest.post<ServerResponse<boolean>>( const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/mkdir', '/File/mkdir',

View File

@@ -1,35 +1,21 @@
import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs' import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
import { Button, ButtonGroup } from '@heroui/button' import { Button } from '@heroui/button'
import { Code } from '@heroui/code'
import { Input } from '@heroui/input' import { Input } from '@heroui/input'
import { import type { Selection, SortDescriptor } from '@react-types/shared'
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 path from 'path-browserify'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import toast from 'react-hot-toast' 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 { TiArrowBack } from 'react-icons/ti'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import CodeEditor from '@/components/code_editor' import CreateFileModal from '@/components/file_manage/create_file_modal'
import FileIcon from '@/components/file_icon' 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' import useDialog from '@/hooks/use-dialog'
@@ -38,7 +24,6 @@ import FileManager, { FileInfo } from '@/controllers/file_manager'
export default function FileManagerPage() { export default function FileManagerPage() {
const [files, setFiles] = useState<FileInfo[]>([]) const [files, setFiles] = useState<FileInfo[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// 修改初始排序状态
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
column: 'name', column: 'name',
direction: 'ascending' direction: 'ascending'
@@ -46,7 +31,11 @@ export default function FileManagerPage() {
const dialog = useDialog() const dialog = useDialog()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() 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<{ const [editingFile, setEditingFile] = useState<{
path: string path: string
content: string content: string
@@ -59,22 +48,18 @@ export default function FileManagerPage() {
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false) const [isMoveModalOpen, setIsMoveModalOpen] = useState(false)
const [renamingFile, setRenamingFile] = useState<string>('') const [renamingFile, setRenamingFile] = useState<string>('')
const [moveTargetPath, setMoveTargetPath] = 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) => { 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 const direction = descriptor.direction === 'ascending' ? 1 : -1
switch (descriptor.column) { switch (descriptor.column) {
case 'name': case 'name':
return direction * a.name.localeCompare(b.name) return direction * a.name.localeCompare(b.name)
case 'type': { case 'type': {
const aType = a.isDirectory ? '目录' : '文件' const aType = a.isDirectory ? '目录' : '文件'
const bType = b.isDirectory ? '目录' : '文件' const bType = a.isDirectory ? '目录' : '文件'
return direction * aType.localeCompare(bType) return direction * aType.localeCompare(bType)
} }
case 'size': case 'size':
@@ -93,8 +78,8 @@ export default function FileManagerPage() {
const loadFiles = async () => { const loadFiles = async () => {
setLoading(true) setLoading(true)
try { try {
const files = await FileManager.listFiles(currentPath) const fileList = await FileManager.listFiles(currentPath)
setFiles(sortFiles(files, sortDescriptor)) setFiles(sortFiles(fileList, sortDescriptor))
} catch (error) { } catch (error) {
toast.error('加载文件列表失败') toast.error('加载文件列表失败')
setFiles([]) setFiles([])
@@ -106,38 +91,26 @@ export default function FileManagerPage() {
loadFiles() loadFiles()
}, [currentPath]) }, [currentPath])
const handleSortChange = (descriptor: SortDescriptor) => { const handleSortChange = (descriptor: typeof sortDescriptor) => {
setSortDescriptor(descriptor) setSortDescriptor(descriptor)
setFiles((prev) => sortFiles(prev, descriptor)) setFiles((prev) => sortFiles(prev, descriptor))
} }
const handleDirectoryClick = (dirPath: string) => { const handleDirectoryClick = (dirPath: string) => {
// Windows 系统下处理盘符切换
if (dirPath.match(/^[A-Z]:\\?$/i)) {
navigate(`/file_manager#${encodeURIComponent(dirPath)}`)
return
}
// 处理返回上级目录
if (dirPath === '..') { if (dirPath === '..') {
// 检查是否在盘符根目录(如 C:
if (/^[A-Z]:$/i.test(currentPath)) { if (/^[A-Z]:$/i.test(currentPath)) {
navigate('/file_manager#/') navigate('/file_manager#/')
return return
} }
const parentPath = path.dirname(currentPath) const parentPath = path.dirname(currentPath)
// 如果已经在根目录则显示盘符列表Windows或保持在根目录其他系统 navigate(
if (parentPath === currentPath) { `/file_manager#${encodeURIComponent(parentPath === currentPath ? '/' : parentPath)}`
navigate('/file_manager#/') )
return
}
navigate(`/file_manager#${encodeURIComponent(parentPath)}`)
return return
} }
navigate(
const newPath = path.join(currentPath, dirPath) `/file_manager#${encodeURIComponent(path.join(currentPath, dirPath))}`
navigate(`/file_manager#${encodeURIComponent(newPath)}`) )
} }
const handleEdit = async (filePath: string) => { const handleEdit = async (filePath: string) => {
@@ -164,11 +137,7 @@ export default function FileManagerPage() {
const handleDelete = async (filePath: string) => { const handleDelete = async (filePath: string) => {
dialog.confirm({ dialog.confirm({
title: '删除文件', title: '删除文件',
content: ( content: <div> {filePath} </div>,
<div>
<Code>{filePath}</Code>
</div>
),
onConfirm: async () => { onConfirm: async () => {
try { try {
await FileManager.delete(filePath) await FileManager.delete(filePath)
@@ -186,14 +155,12 @@ export default function FileManagerPage() {
const newPath = path.join(currentPath, newFileName) const newPath = path.join(currentPath, newFileName)
try { try {
if (fileType === 'directory') { if (fileType === 'directory') {
const result = await FileManager.createDirectory(newPath) if (!(await FileManager.createDirectory(newPath))) {
if (!result) {
toast.error('目录已存在') toast.error('目录已存在')
return return
} }
} else { } else {
const result = await FileManager.createFile(newPath) if (!(await FileManager.createFile(newPath))) {
if (!result) {
toast.error('文件已存在') toast.error('文件已存在')
return return
} }
@@ -203,26 +170,24 @@ export default function FileManagerPage() {
setNewFileName('') setNewFileName('')
loadFiles() loadFiles()
} catch (error) { } catch (error) {
const err = error as Error toast.error((error as Error)?.message || '创建失败')
toast.error(err?.message || '创建失败')
} }
} }
const handleBatchDelete = async () => { const handleBatchDelete = async () => {
// 处理 Selection 类型
const selectedArray = const selectedArray =
selectedFiles === 'all' selectedFiles instanceof Set
? files.map((f) => f.name) ? Array.from(selectedFiles)
: Array.from(selectedFiles as Set<string>) : files.map((f) => f.name)
if (selectedArray.length === 0) return if (selectedArray.length === 0) return
dialog.confirm({ dialog.confirm({
title: '批量删除', title: '批量删除',
content: <div> {selectedArray.length} </div>, content: <div> {selectedArray.length} </div>,
onConfirm: async () => { onConfirm: async () => {
try { try {
const paths = selectedArray.map((key) => path.join(currentPath, key)) const paths = selectedArray.map((key) =>
path.join(currentPath, key.toString())
)
await FileManager.batchDelete(paths) await FileManager.batchDelete(paths)
toast.success('批量删除成功') toast.success('批量删除成功')
setSelectedFiles(new Set()) setSelectedFiles(new Set())
@@ -234,13 +199,13 @@ export default function FileManagerPage() {
}) })
} }
// 处理重命名
const handleRename = async () => { const handleRename = async () => {
if (!renamingFile || !newFileName) return if (!renamingFile || !newFileName) return
try { try {
const oldPath = path.join(currentPath, renamingFile) await FileManager.rename(
const newPath = path.join(currentPath, newFileName) path.join(currentPath, renamingFile),
await FileManager.rename(oldPath, newPath) path.join(currentPath, newFileName)
)
toast.success('重命名成功') toast.success('重命名成功')
setIsRenameModalOpen(false) setIsRenameModalOpen(false)
setRenamingFile('') setRenamingFile('')
@@ -251,13 +216,13 @@ export default function FileManagerPage() {
} }
} }
// 处理移动
const handleMove = async (sourceName: string) => { const handleMove = async (sourceName: string) => {
if (!moveTargetPath) return if (!moveTargetPath) return
try { try {
const sourcePath = path.join(currentPath, sourceName) await FileManager.move(
const targetPath = path.join(moveTargetPath, sourceName) path.join(currentPath, sourceName),
await FileManager.move(sourcePath, targetPath) path.join(moveTargetPath, sourceName)
)
toast.success('移动成功') toast.success('移动成功')
setIsMoveModalOpen(false) setIsMoveModalOpen(false)
setMoveTargetPath('') setMoveTargetPath('')
@@ -267,20 +232,17 @@ export default function FileManagerPage() {
} }
} }
// 处理批量移动
const handleBatchMove = async () => { const handleBatchMove = async () => {
if (!moveTargetPath) return if (!moveTargetPath) return
const selectedArray = const selectedArray =
selectedFiles === 'all' selectedFiles instanceof Set
? files.map((f) => f.name) ? Array.from(selectedFiles)
: Array.from(selectedFiles as Set<string>) : files.map((f) => f.name)
if (selectedArray.length === 0) return if (selectedArray.length === 0) return
try { try {
const items = selectedArray.map((name) => ({ const items = selectedArray.map((name) => ({
sourcePath: path.join(currentPath, name), sourcePath: path.join(currentPath, name.toString()),
targetPath: path.join(moveTargetPath, name) targetPath: path.join(moveTargetPath, name.toString())
})) }))
await FileManager.batchMove(items) await FileManager.batchMove(items)
toast.success('批量移动成功') toast.success('批量移动成功')
@@ -293,14 +255,11 @@ export default function FileManagerPage() {
} }
} }
// 添加复制路径处理函数
const handleCopyPath = (fileName: string) => { const handleCopyPath = (fileName: string) => {
const fullPath = path.join(currentPath, fileName) navigator.clipboard.writeText(path.join(currentPath, fileName))
navigator.clipboard.writeText(fullPath)
toast.success('路径已复制') toast.success('路径已复制')
} }
// 修改移动按钮的点击处理
const handleMoveClick = (fileName: string) => { const handleMoveClick = (fileName: string) => {
setRenamingFile(fileName) setRenamingFile(fileName)
setMoveTargetPath('') setMoveTargetPath('')
@@ -309,7 +268,7 @@ export default function FileManagerPage() {
return ( return (
<div className="p-4"> <div className="p-4">
<div className="mb-4 flex items-center gap-4"> <div className="mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1">
<Button <Button
color="danger" color="danger"
size="sm" size="sm"
@@ -317,10 +276,10 @@ export default function FileManagerPage() {
variant="flat" variant="flat"
onPress={() => handleDirectoryClick('..')} onPress={() => handleDirectoryClick('..')}
className="text-lg" className="text-lg"
radius="full"
> >
<TiArrowBack /> <TiArrowBack />
</Button> </Button>
<Button <Button
color="danger" color="danger"
size="sm" size="sm"
@@ -328,24 +287,34 @@ export default function FileManagerPage() {
variant="flat" variant="flat"
onPress={() => setIsCreateModalOpen(true)} onPress={() => setIsCreateModalOpen(true)}
className="text-lg" className="text-lg"
radius="full"
> >
<FiPlus /> <FiPlus />
</Button> </Button>
{(selectedFiles === 'all' ||
(selectedFiles as Set<string>).size > 0) && ( <Button
color="danger"
isLoading={loading}
size="sm"
isIconOnly
variant="flat"
onPress={loadFiles}
className="text-lg"
>
<MdRefresh />
</Button>
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
selectedFiles === 'all') && (
<> <>
<Button <Button
color="danger" color="danger"
size="sm" size="sm"
variant="flat" variant="flat"
onPress={handleBatchDelete} onPress={handleBatchDelete}
startContent={<FiTrash2 />} className="text-sm"
startContent={<TbTrash className="text-lg" />}
> >
( (
{selectedFiles === 'all' {selectedFiles instanceof Set ? selectedFiles.size : files.length}
? files.length
: (selectedFiles as Set<string>).size}
) )
</Button> </Button>
<Button <Button
@@ -356,13 +325,16 @@ export default function FileManagerPage() {
setMoveTargetPath('') setMoveTargetPath('')
setIsMoveModalOpen(true) setIsMoveModalOpen(true)
}} }}
startContent={<FiMove />} className="text-sm"
startContent={<FiMove className="text-lg" />}
> >
(
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button> </Button>
</> </>
)} )}
<Breadcrumbs> <Breadcrumbs className="flex-1 shadow-small px-2 py-2 rounded-lg">
{currentPath.split('/').map((part, index, parts) => ( {currentPath.split('/').map((part, index, parts) => (
<BreadcrumbItem <BreadcrumbItem
key={part} key={part}
@@ -376,280 +348,86 @@ export default function FileManagerPage() {
</BreadcrumbItem> </BreadcrumbItem>
))} ))}
</Breadcrumbs> </Breadcrumbs>
<Input
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="ml-auto w-64"
/>
</div> </div>
<Table <FileTable
aria-label="文件列表" files={files}
currentPath={currentPath}
loading={loading}
sortDescriptor={sortDescriptor} sortDescriptor={sortDescriptor}
onSortChange={handleSortChange} onSortChange={handleSortChange}
selectedFiles={selectedFiles}
onSelectionChange={setSelectedFiles} onSelectionChange={setSelectedFiles}
defaultSelectedKeys={[]} onDirectoryClick={handleDirectoryClick}
selectedKeys={selectedFiles} onEdit={handleEdit}
selectionMode="multiple" onRenameRequest={(name) => {
> setRenamingFile(name)
<TableHeader> setNewFileName(name)
<TableColumn key="name" allowsSorting> setIsRenameModalOpen(true)
}}
</TableColumn> onMoveRequest={handleMoveClick}
<TableColumn key="type" allowsSorting> onCopyPath={handleCopyPath}
onDelete={handleDelete}
</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>
{/* 文件编辑对话框 */} <FileEditModal
<Modal
size="full"
isOpen={!!editingFile} isOpen={!!editingFile}
file={editingFile}
onClose={() => setEditingFile(null)} onClose={() => setEditingFile(null)}
> onSave={handleSave}
<ModalContent> onContentChange={(newContent) =>
<ModalHeader className="flex items-center gap-2 bg-content2 bg-opacity-50"> setEditingFile((prev) =>
<span></span> prev ? { ...prev, content: newContent ?? '' } : null
<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>
{/* 新建文件/目录对话框 */} <CreateFileModal
<Modal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
fileType={fileType}
newFileName={newFileName}
onTypeChange={setFileType}
onNameChange={(e) => setNewFileName(e.target.value)}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
> onCreate={handleCreate}
<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>
{/* 重命名对话框 */} <RenameModal
<Modal
isOpen={isRenameModalOpen} isOpen={isRenameModalOpen}
newFileName={newFileName}
onNameChange={(e) => setNewFileName(e.target.value)}
onClose={() => setIsRenameModalOpen(false)} onClose={() => setIsRenameModalOpen(false)}
> onRename={handleRename}
<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>
{/* 移动对话框 */} <MoveModal
<Modal isOpen={isMoveModalOpen} onClose={() => setIsMoveModalOpen(false)}> isOpen={isMoveModalOpen}
<ModalContent> moveTargetPath={moveTargetPath}
<ModalHeader></ModalHeader> selectionInfo={
<ModalBody> selectedFiles instanceof Set && selectedFiles.size > 0
<div className="flex flex-col gap-4"> ? `${selectedFiles.size} 个项目`
<Input : renamingFile
label="目标路径" }
value={moveTargetPath} onClose={() => setIsMoveModalOpen(false)}
onChange={(e) => setMoveTargetPath(e.target.value)} onMove={() =>
placeholder="请输入完整目标路径" selectedFiles instanceof Set && selectedFiles.size > 0
/> ? handleBatchMove()
<p className="text-sm text-gray-500"> : handleMove(renamingFile)
}
{selectedFiles === 'all' || onSelect={(dir) => setMoveTargetPath(dir)} // 替换原有 onTargetChange
(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> </div>
) )
} }

View File

@@ -28,6 +28,10 @@ const getRootDirs = async (): Promise<string[]> => {
// 规范化路径 // 规范化路径
const normalizePath = (inputPath: string): string => { const normalizePath = (inputPath: string): string => {
if (!inputPath) return isWindows ? 'C:\\' : '/'; if (!inputPath) return isWindows ? 'C:\\' : '/';
// 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
return inputPath.slice(0, 2) + '\\';
}
return path.normalize(inputPath); 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 SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
// 检查文件或目录是否存在
const checkExists = async (pathToCheck: string): Promise<boolean> => {
try {
await fs.access(pathToCheck);
return true;
} catch {
return false;
}
};
// 检查同类型的文件或目录是否存在 // 检查同类型的文件或目录是否存在
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => { const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
try { try {
@@ -67,6 +61,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
try { try {
const requestPath = (req.query.path as string) || (isWindows ? 'C:\\' : '/'); const requestPath = (req.query.path as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath); const normalizedPath = normalizePath(requestPath);
const onlyDirectory = req.query.onlyDirectory === 'true';
// 如果是根路径且在Windows系统上返回盘符列表 // 如果是根路径且在Windows系统上返回盘符列表
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) { if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
@@ -95,7 +90,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
} }
const files = await fs.readdir(normalizedPath); const files = await fs.readdir(normalizedPath);
const fileInfos: FileInfo[] = []; let fileInfos: FileInfo[] = [];
for (const file of files) { 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); return sendSuccess(res, fileInfos);
} catch (error) { } catch (error) {
// console.error('读取目录失败:', error); console.error('读取目录失败:', error);
return sendError(res, '读取目录失败'); return sendError(res, '读取目录失败');
} }
}; };