feat: 文件下载/上传

This commit is contained in:
bietiaop
2025-02-03 19:56:33 +08:00
parent 80593730ae
commit b32f9fa397
9 changed files with 565 additions and 72 deletions

View File

@@ -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",

View File

@@ -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 = <div></div>
} else if (error) {
contentElement = <div></div>
} else if (loading || !data) {
contentElement = (
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
)
} else if (imageExts.includes(ext)) {
contentElement = (
<img src={data} alt="预览" className="max-w-full max-h-96" />
)
} else if (videoExts.includes(ext)) {
contentElement = (
<video src={data} controls className="max-w-full max-h-96" />
)
} else if (audioExts.includes(ext)) {
contentElement = <audio src={data} controls className="w-full" />
}
return (
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside">
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody className="flex justify-center items-center">
{contentElement}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -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 (
<Table
aria-label="文件列表"
@@ -59,6 +71,19 @@ export default function FileTable({
defaultSelectedKeys={[]}
selectedKeys={selectedFiles}
selectionMode="multiple"
bottomContent={
<div className="flex w-full justify-center">
<Pagination
isCompact
showControls
showShadow
color="danger"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>
</div>
}
>
<TableHeader>
<TableColumn key="name" allowsSorting>
@@ -82,34 +107,52 @@ export default function FileTable({
<Spinner />
</div>
}
items={files}
items={displayFiles}
>
{(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="重命名">
{(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 (
<TableRow key={file.name}>
<TableCell>
<Button
variant="light"
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: previewable
? onPreview(filePath)
: onEdit(filePath)
}
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">
<Button
isIconOnly
color="danger"
@@ -118,8 +161,6 @@ export default function FileTable({
>
<BiRename />
</Button>
</Tooltip>
<Tooltip content="移动">
<Button
isIconOnly
color="danger"
@@ -128,8 +169,6 @@ export default function FileTable({
>
<FiMove />
</Button>
</Tooltip>
<Tooltip content="复制路径">
<Button
isIconOnly
color="danger"
@@ -138,21 +177,27 @@ export default function FileTable({
>
<FiCopy />
</Button>
</Tooltip>
<Tooltip content="删除">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDelete(path.join(currentPath, file.name))}
onPress={() => onDownload(filePath)}
>
<FiDownload />
</Button>
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDelete(filePath)}
>
<FiTrash2 />
</Button>
</Tooltip>
</ButtonGroup>
</TableCell>
</TableRow>
)}
</ButtonGroup>
</TableCell>
</TableRow>
)
}}
</TableBody>
</Table>
)

View File

@@ -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<ServerResponse<boolean>>(
`/File/upload?path=${encodeURIComponent(path)}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
)
return data.data
}
}

View File

@@ -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<string>('')
const [moveTargetPath, setMoveTargetPath] = useState('')
const [jumpPath, setJumpPath] = useState('')
const [previewFile, setPreviewFile] = useState<string>('')
const [showUpload, setShowUpload] = useState<boolean>(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 (
<div className="p-4">
<div className="mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1">
@@ -302,6 +364,17 @@ export default function FileManagerPage() {
>
<MdRefresh />
</Button>
<Button
color="danger"
size="sm"
isIconOnly
variant="flat"
onPress={() => setShowUpload((prev) => !prev)}
className="text-lg"
>
<FiUpload />
</Button>
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
selectedFiles === 'all') && (
<>
@@ -332,6 +405,18 @@ export default function FileManagerPage() {
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={handleBatchDownload}
className="text-sm"
startContent={<FiDownload className="text-lg" />}
>
(
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
</>
)}
<Breadcrumbs className="flex-1 shadow-small px-2 py-2 rounded-lg">
@@ -362,6 +447,26 @@ export default function FileManagerPage() {
/>
</div>
<motion.div
initial={{ height: 0 }}
animate={{ height: showUpload ? 'auto' : 0 }}
transition={{ duration: 0.2 }}
className={clsx(
'border-dashed rounded-lg text-center',
isDragActive ? 'border-primary bg-primary/10' : 'border-default-300',
showUpload ? 'mb-4 border-2' : 'border-none'
)}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<div {...getRootProps()} className="w-full h-full p-4">
<input {...getInputProps()} multiple />
<p></p>
</div>
</motion.div>
<FileTable
files={files}
currentPath={currentPath}
@@ -372,6 +477,7 @@ export default function FileManagerPage() {
onSelectionChange={setSelectedFiles}
onDirectoryClick={handleDirectoryClick}
onEdit={handleEdit}
onPreview={handlePreview}
onRenameRequest={(name) => {
setRenamingFile(name)
setNewFileName(name)
@@ -380,6 +486,7 @@ export default function FileManagerPage() {
onMoveRequest={handleMoveClick}
onCopyPath={handleCopyPath}
onDelete={handleDelete}
onDownload={handleDownload}
/>
<FileEditModal
@@ -394,6 +501,12 @@ export default function FileManagerPage() {
}
/>
<FilePreviewModal
isOpen={!!previewFile}
filePath={previewFile}
onClose={() => setPreviewFile('')}
/>
<CreateFileModal
isOpen={isCreateModalOpen}
fileType={fileType}

View File

@@ -45,6 +45,10 @@ serverRequest.interceptors.request.use((config) => {
})
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)