mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
feat: 文件管理
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
56
napcat.webui/src/components/file_manage/file_edit_modal.tsx
Normal file
56
napcat.webui/src/components/file_manage/file_edit_modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
158
napcat.webui/src/components/file_manage/file_table.tsx
Normal file
158
napcat.webui/src/components/file_manage/file_table.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
168
napcat.webui/src/components/file_manage/move_modal.tsx
Normal file
168
napcat.webui/src/components/file_manage/move_modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
44
napcat.webui/src/components/file_manage/rename_modal.tsx
Normal file
44
napcat.webui/src/components/file_manage/rename_modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@@ -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',
|
||||||
|
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -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, '读取目录失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user