From b32f9fa397a025a4e1db1eff3201b5521294b6b6 Mon Sep 17 00:00:00 2001
From: bietiaop <1527109126@qq.com>
Date: Mon, 3 Feb 2025 19:56:33 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD/?=
=?UTF-8?q?=E4=B8=8A=E4=BC=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
napcat.webui/package.json | 2 +
.../file_manage/file_preview_modal.tsx | 85 ++++++++
.../src/components/file_manage/file_table.tsx | 129 +++++++++----
napcat.webui/src/controllers/file_manager.ts | 101 ++++++++++
.../src/pages/dashboard/file_manager.tsx | 115 ++++++++++-
napcat.webui/src/utils/request.ts | 4 +
package.json | 13 +-
src/webui/src/api/File.ts | 181 +++++++++++++++---
src/webui/src/router/File.ts | 7 +-
9 files changed, 565 insertions(+), 72 deletions(-)
create mode 100644 napcat.webui/src/components/file_manage/file_preview_modal.tsx
diff --git a/napcat.webui/package.json b/napcat.webui/package.json
index 0ef7e2db..5b38b017 100644
--- a/napcat.webui/package.json
+++ b/napcat.webui/package.json
@@ -29,6 +29,7 @@
"@heroui/listbox": "2.3.10",
"@heroui/modal": "2.2.8",
"@heroui/navbar": "2.2.9",
+ "@heroui/pagination": "^2.2.9",
"@heroui/popover": "2.3.10",
"@heroui/select": "2.4.10",
"@heroui/slider": "2.4.8",
@@ -63,6 +64,7 @@
"quill": "^2.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-dropzone": "^14.3.5",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1",
diff --git a/napcat.webui/src/components/file_manage/file_preview_modal.tsx b/napcat.webui/src/components/file_manage/file_preview_modal.tsx
new file mode 100644
index 00000000..be18f696
--- /dev/null
+++ b/napcat.webui/src/components/file_manage/file_preview_modal.tsx
@@ -0,0 +1,85 @@
+import { Button } from '@heroui/button'
+import {
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader
+} from '@heroui/modal'
+import { Spinner } from '@heroui/spinner'
+import { useRequest } from 'ahooks'
+import path from 'path-browserify'
+
+import FileManager from '@/controllers/file_manager'
+
+interface FilePreviewModalProps {
+ isOpen: boolean
+ filePath: string
+ onClose: () => void
+}
+
+const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
+const videoExts = ['.mp4', '.webm']
+const audioExts = ['.mp3', '.wav']
+
+const supportedPreviewExts = [...imageExts, ...videoExts, ...audioExts]
+
+export default function FilePreviewModal({
+ isOpen,
+ filePath,
+ onClose
+}: FilePreviewModalProps) {
+ const ext = path.extname(filePath).toLowerCase()
+ const { data, loading, error, run } = useRequest(
+ async (path: string) => FileManager.downloadToURL(path),
+ {
+ refreshDeps: [filePath],
+ refreshDepsAction: () => {
+ const ext = path.extname(filePath).toLowerCase()
+ if (!filePath || !supportedPreviewExts.includes(ext)) {
+ return
+ }
+ run(filePath)
+ }
+ }
+ )
+
+ let contentElement = null
+ if (!supportedPreviewExts.includes(ext)) {
+ contentElement =
暂不支持预览此文件类型
+ } else if (error) {
+ contentElement = 读取文件失败
+ } else if (loading || !data) {
+ contentElement = (
+
+
+
+ )
+ } else if (imageExts.includes(ext)) {
+ contentElement = (
+
+ )
+ } else if (videoExts.includes(ext)) {
+ contentElement = (
+
+ )
+ } else if (audioExts.includes(ext)) {
+ contentElement =
+ }
+
+ return (
+
+
+ 文件预览
+
+ {contentElement}
+
+
+
+
+
+
+ )
+}
diff --git a/napcat.webui/src/components/file_manage/file_table.tsx b/napcat.webui/src/components/file_manage/file_table.tsx
index 22cccb26..bf7ad03f 100644
--- a/napcat.webui/src/components/file_manage/file_table.tsx
+++ b/napcat.webui/src/components/file_manage/file_table.tsx
@@ -1,4 +1,5 @@
import { Button, ButtonGroup } from '@heroui/button'
+import { Pagination } from '@heroui/pagination'
import { Spinner } from '@heroui/spinner'
import {
type Selection,
@@ -10,10 +11,10 @@ import {
TableHeader,
TableRow
} from '@heroui/table'
-import { Tooltip } from '@heroui/tooltip'
import path from 'path-browserify'
+import { useState } from 'react'
import { BiRename } from 'react-icons/bi'
-import { FiCopy, FiMove, FiTrash2 } from 'react-icons/fi'
+import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi'
import FileIcon from '@/components/file_icon'
@@ -29,12 +30,16 @@ interface FileTableProps {
onSelectionChange: (selected: Selection) => void
onDirectoryClick: (dirPath: string) => void
onEdit: (filePath: string) => void
+ onPreview: (filePath: string) => void
onRenameRequest: (name: string) => void
onMoveRequest: (name: string) => void
onCopyPath: (fileName: string) => void
onDelete: (filePath: string) => void
+ onDownload: (filePath: string) => void
}
+const PAGE_SIZE = 20
+
export default function FileTable({
files,
currentPath,
@@ -45,11 +50,18 @@ export default function FileTable({
onSelectionChange,
onDirectoryClick,
onEdit,
+ onPreview,
onRenameRequest,
onMoveRequest,
onCopyPath,
- onDelete
+ onDelete,
+ onDownload
}: FileTableProps) {
+ const [page, setPage] = useState(1)
+ const pages = Math.ceil(files.length / PAGE_SIZE)
+ const start = (page - 1) * PAGE_SIZE
+ const end = start + PAGE_SIZE
+ const displayFiles = files.slice(start, end)
return (
+ setPage(page)}
+ />
+
+ }
>
@@ -82,34 +107,52 @@ export default function FileTable({
}
- items={files}
+ items={displayFiles}
>
- {(file: FileInfo) => (
-
-
-
-
- {file.isDirectory ? '目录' : '文件'}
-
- {isNaN(file.size) || file.isDirectory ? '-' : `${file.size} 字节`}
-
- {new Date(file.mtime).toLocaleString()}
-
-
-
+ {(file: FileInfo) => {
+ const filePath = path.join(currentPath, file.name)
+ // 判断预览类型
+ const ext = path.extname(file.name).toLowerCase()
+ const previewable = [
+ '.png',
+ '.jpg',
+ '.jpeg',
+ '.gif',
+ '.bmp',
+ '.mp4',
+ '.webm',
+ '.mp3',
+ '.wav'
+ ].includes(ext)
+ return (
+
+
+
+
+ {file.isDirectory ? '目录' : '文件'}
+
+ {isNaN(file.size) || file.isDirectory
+ ? '-'
+ : `${file.size} 字节`}
+
+ {new Date(file.mtime).toLocaleString()}
+
+
-
-
-
-
-
-
+
-
-
-
-
- )}
+
+
+
+ )
+ }}
)
diff --git a/napcat.webui/src/controllers/file_manager.ts b/napcat.webui/src/controllers/file_manager.ts
index 77d8599f..69637815 100644
--- a/napcat.webui/src/controllers/file_manager.ts
+++ b/napcat.webui/src/controllers/file_manager.ts
@@ -1,3 +1,5 @@
+import toast from 'react-hot-toast'
+
import { serverRequest } from '@/utils/request'
export interface FileInfo {
@@ -95,4 +97,103 @@ export default class FileManager {
)
return data.data
}
+
+ public static download(path: string) {
+ const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
+ toast
+ .promise(
+ serverRequest
+ .post(downloadUrl, void 0, {
+ responseType: 'blob'
+ })
+ .catch((e) => {
+ console.error(e)
+ throw new Error('下载失败')
+ }),
+ {
+ loading: '正在下载文件...',
+ success: '下载成功',
+ error: '下载失败'
+ }
+ )
+ .then((response) => {
+ const url = window.URL.createObjectURL(new Blob([response.data]))
+ const link = document.createElement('a')
+ link.href = url
+ let fileName = path.split('/').pop() || ''
+ if (path.split('.').length === 1) {
+ fileName += '.zip'
+ }
+ link.setAttribute('download', fileName)
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ })
+ .catch((e) => {
+ console.error(e)
+ })
+ }
+
+ public static async batchDownload(paths: string[]) {
+ const downloadUrl = `/File/batchDownload`
+ toast
+ .promise(
+ serverRequest
+ .post(
+ downloadUrl,
+ { paths },
+ {
+ responseType: 'blob'
+ }
+ )
+ .catch((e) => {
+ console.error(e)
+ throw new Error('下载失败')
+ }),
+ {
+ loading: '正在下载文件...',
+ success: '下载成功',
+ error: '下载失败'
+ }
+ )
+ .then((response) => {
+ const url = window.URL.createObjectURL(new Blob([response.data]))
+ const link = document.createElement('a')
+ link.href = url
+ const fileName = 'files.zip'
+ link.setAttribute('download', fileName)
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ })
+ .catch((e) => {
+ console.error(e)
+ })
+ }
+
+ public static async downloadToURL(path: string) {
+ const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
+ const response = await serverRequest.post(downloadUrl, void 0, {
+ responseType: 'blob'
+ })
+ return window.URL.createObjectURL(new Blob([response.data]))
+ }
+
+ public static async upload(path: string, files: File[]) {
+ const formData = new FormData()
+ files.forEach((file) => {
+ formData.append('files', file)
+ })
+
+ const { data } = await serverRequest.post>(
+ `/File/upload?path=${encodeURIComponent(path)}`,
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ }
+ }
+ )
+ return data.data
+ }
}
diff --git a/napcat.webui/src/pages/dashboard/file_manager.tsx b/napcat.webui/src/pages/dashboard/file_manager.tsx
index 98ac4497..0770667c 100644
--- a/napcat.webui/src/pages/dashboard/file_manager.tsx
+++ b/napcat.webui/src/pages/dashboard/file_manager.tsx
@@ -2,10 +2,13 @@ import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import type { Selection, SortDescriptor } from '@react-types/shared'
+import clsx from 'clsx'
+import { motion } from 'motion/react'
import path from 'path-browserify'
import { useEffect, useState } from 'react'
+import { useDropzone } from 'react-dropzone'
import toast from 'react-hot-toast'
-import { FiMove, FiPlus } from 'react-icons/fi'
+import { FiDownload, FiMove, FiPlus, FiUpload } from 'react-icons/fi'
import { MdRefresh } from 'react-icons/md'
import { TbTrash } from 'react-icons/tb'
import { TiArrowBack } from 'react-icons/ti'
@@ -13,6 +16,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import CreateFileModal from '@/components/file_manage/create_file_modal'
import FileEditModal from '@/components/file_manage/file_edit_modal'
+import FilePreviewModal from '@/components/file_manage/file_preview_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'
@@ -49,6 +53,8 @@ export default function FileManagerPage() {
const [renamingFile, setRenamingFile] = useState('')
const [moveTargetPath, setMoveTargetPath] = useState('')
const [jumpPath, setJumpPath] = useState('')
+ const [previewFile, setPreviewFile] = useState('')
+ const [showUpload, setShowUpload] = useState(false)
const sortFiles = (files: FileInfo[], descriptor: typeof sortDescriptor) => {
return [...files].sort((a, b) => {
@@ -266,6 +272,62 @@ export default function FileManagerPage() {
setIsMoveModalOpen(true)
}
+ const handleDownload = (filePath: string) => {
+ FileManager.download(filePath)
+ }
+
+ const handleBatchDownload = async () => {
+ const selectedArray =
+ selectedFiles instanceof Set
+ ? Array.from(selectedFiles)
+ : files.map((f) => f.name)
+ if (selectedArray.length === 0) return
+ const paths = selectedArray.map((key) =>
+ path.join(currentPath, key.toString())
+ )
+ await FileManager.batchDownload(paths)
+ }
+
+ const handlePreview = (filePath: string) => {
+ setPreviewFile(filePath)
+ }
+
+ const onDrop = async (acceptedFiles: File[]) => {
+ try {
+ // 遍历处理文件,保持文件夹结构
+ const processedFiles = acceptedFiles.map((file) => {
+ const relativePath = file.webkitRelativePath || file.name
+ // 不需要额外的编码处理,浏览器会自动处理
+ return new File([file], relativePath, {
+ type: file.type,
+ lastModified: file.lastModified
+ })
+ })
+
+ toast
+ .promise(FileManager.upload(currentPath, processedFiles), {
+ loading: '正在上传文件...',
+ success: '上传成功',
+ error: '上传失败'
+ })
+ .then(() => {
+ loadFiles()
+ })
+ } catch (error) {
+ toast.error('上传失败')
+ }
+ }
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ noClick: true,
+ onDragOver: (e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ },
+ useFsAccessApi: false // 添加此选项以避免某些浏览器的文件系统API问题
+ })
+
return (
@@ -302,6 +364,17 @@ export default function FileManagerPage() {
>
+
+
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
selectedFiles === 'all') && (
<>
@@ -332,6 +405,18 @@ export default function FileManagerPage() {
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
+ }
+ >
+ (
+ {selectedFiles instanceof Set ? selectedFiles.size : files.length}
+ )
+
>
)}
@@ -362,6 +447,26 @@ export default function FileManagerPage() {
/>
+
{
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ >
+
+
+
拖拽文件或文件夹到此处上传,或点击选择文件
+
+
+
{
setRenamingFile(name)
setNewFileName(name)
@@ -380,6 +486,7 @@ export default function FileManagerPage() {
onMoveRequest={handleMoveClick}
onCopyPath={handleCopyPath}
onDelete={handleDelete}
+ onDownload={handleDownload}
/>
+ setPreviewFile('')}
+ />
+
{
})
serverRequest.interceptors.response.use((response) => {
+ // 如果是流式传输的文件
+ if (response.headers['content-type'] === 'application/octet-stream') {
+ return response
+ }
if (response.data.code !== 0) {
if (response.data.message === 'Unauthorized') {
const token = localStorage.getItem(key.token)
diff --git a/package.json b/package.json
index d63c61da..62d7295f 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,8 @@
"@eslint/compat": "^1.2.2",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
+ "@ffmpeg.wasm/main": "^0.13.1",
+ "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@log4js-node/log4js-api": "^1.0.2",
"@napneko/nap-proto-core": "^0.0.4",
"@rollup/plugin-node-resolve": "^16.0.0",
@@ -28,6 +30,7 @@
"@sinclair/typebox": "^0.34.9",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
+ "@types/multer": "^1.4.12",
"@types/node": "^22.0.1",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.5.12",
@@ -41,25 +44,25 @@
"eslint": "^9.14.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
+ "express-rate-limit": "^7.5.0",
"fast-xml-parser": "^4.3.6",
"file-type": "^20.0.0",
"globals": "^15.12.0",
"image-size": "^1.1.1",
"json5": "^2.2.3",
+ "multer": "^1.4.5-lts.1",
"typescript": "^5.3.3",
"typescript-eslint": "^8.13.0",
"vite": "^6.0.1",
"vite-plugin-cp": "^4.0.8",
"vite-tsconfig-paths": "^5.1.0",
- "winston": "^3.17.0",
- "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
- "@ffmpeg.wasm/main": "^0.13.1",
- "express-rate-limit": "^7.5.0"
+ "winston": "^3.17.0"
},
"dependencies": {
- "piscina": "^4.7.0",
"@ffmpeg.wasm/core-mt": "^0.13.2",
+ "compressing": "^1.10.1",
"express": "^5.0.0",
+ "piscina": "^4.7.0",
"silk-wasm": "^3.6.1",
"ws": "^8.18.0"
}
diff --git a/src/webui/src/api/File.ts b/src/webui/src/api/File.ts
index 1da78820..98183f4f 100644
--- a/src/webui/src/api/File.ts
+++ b/src/webui/src/api/File.ts
@@ -1,8 +1,13 @@
-import type { RequestHandler } from 'express';
+import type { RequestHandler, Request } from 'express';
import { sendError, sendSuccess } from '../utils/response';
-import fs from 'fs/promises';
+import fsProm from 'fs/promises';
+import fs from 'fs';
import path from 'path';
import os from 'os';
+import compressing from 'compressing';
+import { PassThrough } from 'stream';
+import multer from 'multer';
+import { randomUUID } from 'crypto';
const isWindows = os.platform() === 'win32';
@@ -15,7 +20,7 @@ const getRootDirs = async (): Promise => {
for (let i = 65; i <= 90; i++) {
const driveLetter = String.fromCharCode(i);
try {
- await fs.access(`${driveLetter}:\\`);
+ await fsProm.access(`${driveLetter}:\\`);
drives.push(`${driveLetter}:`);
} catch {
// 如果驱动器不存在或无法访问,跳过
@@ -48,7 +53,7 @@ const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'S
// 检查同类型的文件或目录是否存在
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise => {
try {
- const stat = await fs.stat(pathToCheck);
+ const stat = await fsProm.stat(pathToCheck);
// 只有当类型相同时才认为是冲突
return stat.isDirectory() === isDirectory;
} catch {
@@ -59,9 +64,9 @@ const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): P
// 获取目录内容
export const ListFilesHandler: RequestHandler = async (req, res) => {
try {
- const requestPath = (req.query.path as string) || (isWindows ? 'C:\\' : '/');
+ const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath);
- const onlyDirectory = req.query.onlyDirectory === 'true';
+ const onlyDirectory = req.query['onlyDirectory'] === 'true';
// 如果是根路径且在Windows系统上,返回盘符列表
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
@@ -69,7 +74,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
const driveInfos: FileInfo[] = await Promise.all(
drives.map(async (drive) => {
try {
- const stat = await fs.stat(`${drive}\\`);
+ const stat = await fsProm.stat(`${drive}\\`);
return {
name: drive,
isDirectory: true,
@@ -89,7 +94,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
return sendSuccess(res, driveInfos);
}
- const files = await fs.readdir(normalizedPath);
+ const files = await fsProm.readdir(normalizedPath);
let fileInfos: FileInfo[] = [];
for (const file of files) {
@@ -98,7 +103,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
try {
const fullPath = path.join(normalizedPath, file);
- const stat = await fs.stat(fullPath);
+ const stat = await fsProm.stat(fullPath);
fileInfos.push({
name: file,
isDirectory: stat.isDirectory(),
@@ -135,7 +140,7 @@ export const CreateDirHandler: RequestHandler = async (req, res) => {
return sendError(res, '同名目录已存在');
}
- await fs.mkdir(normalizedPath, { recursive: true });
+ await fsProm.mkdir(normalizedPath, { recursive: true });
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建目录失败');
@@ -147,11 +152,11 @@ export const DeleteHandler: RequestHandler = async (req, res) => {
try {
const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath);
- const stat = await fs.stat(normalizedPath);
+ const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
- await fs.rm(normalizedPath, { recursive: true });
+ await fsProm.rm(normalizedPath, { recursive: true });
} else {
- await fs.unlink(normalizedPath);
+ await fsProm.unlink(normalizedPath);
}
return sendSuccess(res, true);
} catch (error) {
@@ -165,11 +170,11 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
const { paths } = req.body;
for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath);
- const stat = await fs.stat(normalizedPath);
+ const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
- await fs.rm(normalizedPath, { recursive: true });
+ await fsProm.rm(normalizedPath, { recursive: true });
} else {
- await fs.unlink(normalizedPath);
+ await fsProm.unlink(normalizedPath);
}
}
return sendSuccess(res, true);
@@ -181,8 +186,8 @@ export const BatchDeleteHandler: RequestHandler = async (req, 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');
+ const filePath = normalizePath(req.query['path'] as string);
+ const content = await fsProm.readFile(filePath, 'utf-8');
return sendSuccess(res, content);
} catch (error) {
return sendError(res, '读取文件失败');
@@ -194,7 +199,7 @@ 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');
+ await fsProm.writeFile(normalizedPath, content, 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '写入文件失败');
@@ -212,7 +217,7 @@ export const CreateFileHandler: RequestHandler = async (req, res) => {
return sendError(res, '同名文件已存在');
}
- await fs.writeFile(normalizedPath, '', 'utf-8');
+ await fsProm.writeFile(normalizedPath, '', 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建文件失败');
@@ -225,7 +230,7 @@ export const RenameHandler: RequestHandler = async (req, res) => {
const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
- await fs.rename(normalizedOldPath, normalizedNewPath);
+ await fsProm.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '重命名失败');
@@ -238,7 +243,7 @@ export const MoveHandler: RequestHandler = async (req, res) => {
const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
- await fs.rename(normalizedSourcePath, normalizedTargetPath);
+ await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '移动失败');
@@ -252,10 +257,140 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
- await fs.rename(normalizedSourcePath, normalizedTargetPath);
+ await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量移动失败');
}
};
+
+// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
+export const DownloadHandler: RequestHandler = async (req, res) => {
+ try {
+ const filePath = normalizePath(req.query['path'] as string);
+ const stat = await fsProm.stat(filePath);
+
+ res.setHeader('Content-Type', 'application/octet-stream');
+ let filename = path.basename(filePath);
+ if (stat.isDirectory()) {
+ filename = path.basename(filePath) + '.zip';
+ res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
+ const zipStream = new PassThrough();
+ compressing.zip.compressDir(filePath, zipStream as unknown as fs.WriteStream).catch((err) => {
+ console.error('压缩目录失败:', err);
+ res.end();
+ });
+ zipStream.pipe(res);
+ return;
+ }
+ res.setHeader('Content-Length', stat.size);
+ res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
+ const stream = fs.createReadStream(filePath);
+ stream.pipe(res);
+ } catch (error) {
+ return sendError(res, '下载失败');
+ }
+};
+
+// 批量下载:将多个文件/目录打包为 zip 文件下载
+export const BatchDownloadHandler: RequestHandler = async (req, res) => {
+ try {
+ const { paths } = req.body as { paths: string[] };
+ if (!paths || !Array.isArray(paths) || paths.length === 0) {
+ return sendError(res, '参数错误');
+ }
+ res.setHeader('Content-Type', 'application/octet-stream');
+ res.setHeader('Content-Disposition', 'attachment; filename=files.zip');
+
+ const zipStream = new compressing.zip.Stream();
+ // 修改:根据文件类型设置 relativePath
+ for (const filePath of paths) {
+ const normalizedPath = normalizePath(filePath);
+ const stat = await fsProm.stat(normalizedPath);
+ if (stat.isDirectory()) {
+ zipStream.addEntry(normalizedPath, { relativePath: '' });
+ } else {
+ zipStream.addEntry(normalizedPath, { relativePath: path.basename(normalizedPath) });
+ }
+ }
+ zipStream.pipe(res);
+ res.on('finish', () => {
+ zipStream.destroy();
+ });
+ } catch (error) {
+ return sendError(res, '下载失败');
+ }
+};
+
+// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题
+const decodeFileName = (fileName: string): string => {
+ try {
+ return Buffer.from(fileName, 'binary').toString('utf8');
+ } catch {
+ return fileName;
+ }
+};
+
+// 修改上传处理方法
+export const UploadHandler: RequestHandler = (req, res) => {
+ const uploadPath = (req.query['path'] || '') as string;
+
+ const storage = multer.diskStorage({
+ destination: (
+ _: Request,
+ file: Express.Multer.File,
+ cb: (error: Error | null, destination: string) => void
+ ) => {
+ try {
+ const decodedName = decodeFileName(file.originalname);
+
+ if (!uploadPath) {
+ return cb(new Error('上传路径不能为空'), '');
+ }
+
+ if (isWindows && uploadPath === '\\') {
+ return cb(new Error('根目录不允许上传文件'), '');
+ }
+
+ // 处理文件夹上传的情况
+ if (decodedName.includes('/') || decodedName.includes('\\')) {
+ const fullPath = path.join(uploadPath, path.dirname(decodedName));
+ fs.mkdirSync(fullPath, { recursive: true });
+ cb(null, fullPath);
+ } else {
+ cb(null, uploadPath);
+ }
+ } catch (error) {
+ cb(error as Error, '');
+ }
+ },
+ filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
+ try {
+ const decodedName = decodeFileName(file.originalname);
+ const fileName = path.basename(decodedName);
+
+ // 检查文件是否存在
+ const fullPath = path.join(uploadPath, decodedName);
+ if (fs.existsSync(fullPath)) {
+ const ext = path.extname(fileName);
+ const name = path.basename(fileName, ext);
+ cb(null, `${name}-${randomUUID()}${ext}`);
+ } else {
+ cb(null, fileName);
+ }
+ } catch (error) {
+ cb(error as Error, '');
+ }
+ },
+ });
+
+ const upload = multer({ storage }).array('files');
+
+ upload(req, res, (err: any) => {
+ if (err) {
+ return sendError(res, err.message || '文件上传失败');
+ }
+ return sendSuccess(res, true);
+ });
+};
diff --git a/src/webui/src/router/File.ts b/src/webui/src/router/File.ts
index c282d229..d59d3652 100644
--- a/src/webui/src/router/File.ts
+++ b/src/webui/src/router/File.ts
@@ -11,6 +11,9 @@ import {
RenameHandler,
MoveHandler,
BatchMoveHandler,
+ DownloadHandler,
+ BatchDownloadHandler, // 新增下载处理方法
+ UploadHandler, // 添加上传处理器
} from '../api/File';
const router = Router();
@@ -32,5 +35,7 @@ router.post('/batchDelete', BatchDeleteHandler);
router.post('/rename', RenameHandler);
router.post('/move', MoveHandler);
router.post('/batchMove', BatchMoveHandler);
-
+router.post('/download', DownloadHandler);
+router.post('/batchDownload', BatchDownloadHandler);
+router.post('/upload', UploadHandler); // 添加上传处理路由
export { router as FileRouter };