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>

261
src/webui/src/api/File.ts Normal file
View File

@@ -0,0 +1,261 @@
import type { RequestHandler } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
const isWindows = os.platform() === 'win32';
// 获取系统根目录列表Windows返回盘符列表其他系统返回['/']
const getRootDirs = async (): Promise<string[]> => {
if (!isWindows) return ['/'];
// Windows 驱动器字母 (A-Z)
const drives: string[] = [];
for (let i = 65; i <= 90; i++) {
const driveLetter = String.fromCharCode(i);
try {
await fs.access(`${driveLetter}:\\`);
drives.push(`${driveLetter}:`);
} catch {
// 如果驱动器不存在或无法访问,跳过
continue;
}
}
return drives.length > 0 ? drives : ['C:'];
};
// 规范化路径
const normalizePath = (inputPath: string): string => {
if (!inputPath) return isWindows ? 'C:\\' : '/';
return path.normalize(inputPath);
};
interface FileInfo {
name: string;
isDirectory: boolean;
size: number;
mtime: Date;
}
// 添加系统文件黑名单
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> => {
try {
const stat = await fs.stat(pathToCheck);
// 只有当类型相同时才认为是冲突
return stat.isDirectory() === isDirectory;
} catch {
return false;
}
};
// 获取目录内容
export const ListFilesHandler: RequestHandler = async (req, res) => {
try {
const requestPath = (req.query.path as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath);
// 如果是根路径且在Windows系统上返回盘符列表
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
const drives = await getRootDirs();
const driveInfos: FileInfo[] = await Promise.all(
drives.map(async (drive) => {
try {
const stat = await fs.stat(`${drive}\\`);
return {
name: drive,
isDirectory: true,
size: 0,
mtime: stat.mtime,
};
} catch {
return {
name: drive,
isDirectory: true,
size: 0,
mtime: new Date(),
};
}
})
);
return sendSuccess(res, driveInfos);
}
const files = await fs.readdir(normalizedPath);
const fileInfos: FileInfo[] = [];
for (const file of files) {
// 跳过系统文件
if (SYSTEM_FILES.has(file)) continue;
try {
const fullPath = path.join(normalizedPath, file);
const stat = await fs.stat(fullPath);
fileInfos.push({
name: file,
isDirectory: stat.isDirectory(),
size: stat.size,
mtime: stat.mtime,
});
} catch (error) {
// 忽略无法访问的文件
// console.warn(`无法访问文件 ${file}:`, error);
continue;
}
}
return sendSuccess(res, fileInfos);
} catch (error) {
// console.error('读取目录失败:', error);
return sendError(res, '读取目录失败');
}
};
// 创建目录
export const CreateDirHandler: RequestHandler = async (req, res) => {
try {
const { path: dirPath } = req.body;
const normalizedPath = normalizePath(dirPath);
// 检查是否已存在同类型(目录)
if (await checkSameTypeExists(normalizedPath, true)) {
return sendError(res, '同名目录已存在');
}
await fs.mkdir(normalizedPath, { recursive: true });
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建目录失败');
}
};
// 删除文件/目录
export const DeleteHandler: RequestHandler = async (req, res) => {
try {
const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath);
const stat = await fs.stat(normalizedPath);
if (stat.isDirectory()) {
await fs.rm(normalizedPath, { recursive: true });
} else {
await fs.unlink(normalizedPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '删除失败');
}
};
// 批量删除文件/目录
export const BatchDeleteHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body;
for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath);
const stat = await fs.stat(normalizedPath);
if (stat.isDirectory()) {
await fs.rm(normalizedPath, { recursive: true });
} else {
await fs.unlink(normalizedPath);
}
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量删除失败');
}
};
// 读取文件内容
export const ReadFileHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query.path as string);
const content = await fs.readFile(filePath, 'utf-8');
return sendSuccess(res, content);
} catch (error) {
return sendError(res, '读取文件失败');
}
};
// 写入文件内容
export const WriteFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath, content } = req.body;
const normalizedPath = normalizePath(filePath);
await fs.writeFile(normalizedPath, content, 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '写入文件失败');
}
};
// 创建新文件
export const CreateFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath } = req.body;
const normalizedPath = normalizePath(filePath);
// 检查是否已存在同类型(文件)
if (await checkSameTypeExists(normalizedPath, false)) {
return sendError(res, '同名文件已存在');
}
await fs.writeFile(normalizedPath, '', 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建文件失败');
}
};
// 重命名文件/目录
export const RenameHandler: RequestHandler = async (req, res) => {
try {
const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
await fs.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '重命名失败');
}
};
// 移动文件/目录
export const MoveHandler: RequestHandler = async (req, res) => {
try {
const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fs.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '移动失败');
}
};
// 批量移动
export const BatchMoveHandler: RequestHandler = async (req, res) => {
try {
const { items } = req.body;
for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fs.rename(normalizedSourcePath, normalizedTargetPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量移动失败');
}
};

View File

@@ -0,0 +1,28 @@
import { Router } from 'express';
import {
ListFilesHandler,
CreateDirHandler,
DeleteHandler,
ReadFileHandler,
WriteFileHandler,
CreateFileHandler,
BatchDeleteHandler, // 添加这一行
RenameHandler,
MoveHandler,
BatchMoveHandler,
} from '../api/File';
const router = Router();
router.get('/list', ListFilesHandler);
router.post('/mkdir', CreateDirHandler);
router.post('/delete', DeleteHandler);
router.get('/read', ReadFileHandler);
router.post('/write', WriteFileHandler);
router.post('/create', CreateFileHandler);
router.post('/batchDelete', BatchDeleteHandler); // 添加这一行
router.post('/rename', RenameHandler);
router.post('/move', MoveHandler);
router.post('/batchMove', BatchMoveHandler);
export { router as FileRouter };

View File

@@ -12,6 +12,7 @@ import { QQLoginRouter } from '@webapi/router/QQLogin';
import { AuthRouter } from '@webapi/router/auth';
import { LogRouter } from '@webapi/router/Log';
import { BaseRouter } from '@webapi/router/Base';
import { FileRouter } from './File';
const router = Router();
@@ -32,5 +33,7 @@ router.use('/QQLogin', QQLoginRouter);
router.use('/OB11Config', OB11ConfigRouter);
// router:日志相关路由
router.use('/Log', LogRouter);
// file:文件相关路由
router.use('/File', FileRouter);
export { router as ALLRouter };