diff --git a/src/main/java/com/filesystem/controller/FileController.java b/src/main/java/com/filesystem/controller/FileController.java index 1197a4e..8e1d3ab 100644 --- a/src/main/java/com/filesystem/controller/FileController.java +++ b/src/main/java/com/filesystem/controller/FileController.java @@ -5,6 +5,7 @@ import com.filesystem.entity.FileShare; import com.filesystem.security.UserPrincipal; import com.filesystem.service.FileService; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -250,5 +251,37 @@ public class FileController { return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); } } -} + + @PostMapping("/batchDownload") + public ResponseEntity batchDownload( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody Map> request, + HttpServletResponse response) throws IOException { + List ids = request.get("ids"); + if (ids == null || ids.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "请选择要下载的文件")); + } + + try { + byte[] zipBytes = fileService.createZipArchive(ids, principal.getUserId()); + + response.setContentType("application/zip"); + response.setHeader("Content-Disposition", "attachment; filename=\"download.zip\""); + response.setContentLength(zipBytes.length); + response.getOutputStream().write(zipBytes); + response.getOutputStream().flush(); + + return ResponseEntity.ok().build(); + } catch (RuntimeException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + @GetMapping("/movableFolders") + public ResponseEntity getMovableFolders( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(required = false) List excludeIds) { + List folders = fileService.getMovableFolders(principal.getUserId(), excludeIds); + return ResponseEntity.ok(Map.of("data", folders)); + } } diff --git a/src/main/java/com/filesystem/service/FileService.java b/src/main/java/com/filesystem/service/FileService.java index 1dd830e..13798c5 100644 --- a/src/main/java/com/filesystem/service/FileService.java +++ b/src/main/java/com/filesystem/service/FileService.java @@ -499,4 +499,75 @@ public class FileService { file.setFolderId(targetFolderId); fileMapper.updateById(file); } + + public byte[] createZipArchive(List fileIds, Long userId) throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos); + + for (Long fileId : fileIds) { + FileEntity file = fileMapper.selectById(fileId); + if (file == null || !file.getUserId().equals(userId) || file.getIsDeleted() == 1) { + continue; + } + + if (file.getIsFolder() == 1) { + // 递归添加文件夹内容 + addFolderToZip(zos, file, "", userId); + } else { + // 添加单个文件 + addFileToZip(zos, file, ""); + } + } + + zos.close(); + return baos.toByteArray(); + } + + private void addFolderToZip(java.util.zip.ZipOutputStream zos, FileEntity folder, String parentPath, Long userId) throws IOException { + String folderPath = parentPath + folder.getName() + "/"; + + // 获取文件夹下的所有内容 + List children = fileMapper.selectList( + new LambdaQueryWrapper() + .eq(FileEntity::getUserId, userId) + .eq(FileEntity::getFolderId, folder.getId()) + .eq(FileEntity::getIsDeleted, 0) + ); + + for (FileEntity child : children) { + if (child.getIsFolder() == 1) { + addFolderToZip(zos, child, folderPath, userId); + } else { + addFileToZip(zos, child, folderPath); + } + } + } + + private void addFileToZip(java.util.zip.ZipOutputStream zos, FileEntity file, String parentPath) throws IOException { + if (file.getPath() == null) return; + + Path filePath = Paths.get(storagePath).toAbsolutePath().resolve("files").resolve(file.getPath()); + if (!Files.exists(filePath)) return; + + String zipEntryName = parentPath + file.getName(); + java.util.zip.ZipEntry zipEntry = new java.util.zip.ZipEntry(zipEntryName); + zos.putNextEntry(zipEntry); + + byte[] fileBytes = Files.readAllBytes(filePath); + zos.write(fileBytes); + zos.closeEntry(); + } + + public List getMovableFolders(Long userId, List excludeIds) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(FileEntity::getUserId, userId) + .eq(FileEntity::getIsDeleted, 0) + .eq(FileEntity::getIsFolder, 1); + + if (excludeIds != null && !excludeIds.isEmpty()) { + wrapper.notIn(FileEntity::getId, excludeIds); + } + + return fileMapper.selectList(wrapper); + } } diff --git a/web-vue/src/api/file.js b/web-vue/src/api/file.js index c12b757..24db905 100644 --- a/web-vue/src/api/file.js +++ b/web-vue/src/api/file.js @@ -25,4 +25,6 @@ export const shareFileApi = (id, data) => request.post(`/files/${id}/shareFile`, export const cancelShare = (id) => request.delete(`/files/${id}/cancelShare`) export const renameFile = (id, name) => request.put(`/files/${id}/rename`, { name }) export const moveFile = (id, folderId) => request.put(`/files/${id}/move`, { folderId }) +export const batchDownload = (ids) => request.post('/files/batchDownload', { ids }, { responseType: 'blob' }) +export const getMovableFolders = (excludeIds) => request.get('/files/movableFolders', { params: { excludeIds } }) export const getFilePreview = (id) => request.get(`/files/${id}/preview`, { responseType: 'blob' }) diff --git a/web-vue/src/views/files/index.vue b/web-vue/src/views/files/index.vue index 867f177..9ffb876 100644 --- a/web-vue/src/views/files/index.vue +++ b/web-vue/src/views/files/index.vue @@ -28,22 +28,17 @@ /> -
- 已选择 {{ selectedFiles.length }} 项 -
- +
+ + 批量下载 - + 批量移动 - - - 取消选择 - -
+
@@ -165,7 +160,7 @@ import { getFiles, getTrashFiles, getSharedByMe, getSharedByMeFolderFiles, getSharedToMe, getSharedFolderFiles, uploadFiles, downloadFile, deleteFile, restoreFile, deletePermanently, emptyTrash, createFolder, - shareFileApi, cancelShare, getFilePreview, renameFile, moveFile + shareFileApi, cancelShare, getFilePreview, renameFile, moveFile, batchDownload, getMovableFolders } from '@/api/file' import { getCurrentUser } from '@/api/auth' import { useUserStore } from '@/store/user' @@ -501,31 +496,33 @@ const clearSelection = () => { const handleBatchDownload = async () => { const filesToDownload = selectedFiles.value.filter(f => f.type !== 'folder') - if (filesToDownload.length === 0) { + if (filesToDownload.length === 0 && selectedFiles.value.length > 0) { ElMessage.warning('请选择要下载的文件(文件夹不支持批量下载)') return } - ElMessage.info(`开始下载 ${filesToDownload.length} 个文件...`) - - for (const file of filesToDownload) { - try { - const blob = await downloadFile(file.id) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = file.name - a.click() - URL.revokeObjectURL(url) - // 添加小延迟避免浏览器阻塞 - await new Promise(resolve => setTimeout(resolve, 200)) - } catch (e) { - ElMessage.error(`下载 ${file.name} 失败`) - } + if (selectedFiles.value.length === 0) { + ElMessage.warning('请选择要下载的文件') + return } - ElMessage.success('批量下载完成') - selectedFiles.value = [] + try { + ElMessage.info('正在打包下载...') + const ids = selectedFiles.value.map(f => f.id) + const blob = await batchDownload(ids) + + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `download_${new Date().getTime()}.zip` + a.click() + URL.revokeObjectURL(url) + + ElMessage.success('下载完成') + selectedFiles.value = [] + } catch (e) { + ElMessage.error('下载失败') + } } const handleBatchMove = async () => { @@ -534,20 +531,16 @@ const handleBatchMove = async () => { return } - // 获取当前用户的文件夹列表 + // 获取可移动的文件夹列表 try { - const res = await getFiles({ folderId: null }) - const allFiles = res.data || [] - const folders = allFiles.filter(f => f.type === 'folder' && f.isFolder === 1) - - // 过滤掉选中的文件夹本身(不能移动到自己) const selectedIds = selectedFiles.value.map(f => f.id) - const availableFolders = folders.filter(f => !selectedIds.includes(f.id)) + const res = await getMovableFolders(selectedIds) + const folders = res.data || [] // 构建选项 const options = [ { label: '根目录', value: null }, - ...availableFolders.map(f => ({ label: f.name, value: f.id })) + ...folders.map(f => ({ label: f.name, value: f.id })) ] // 使用 ElMessageBox.prompt 的自定义方式 @@ -625,16 +618,16 @@ onMounted(async () => { .batch-toolbar { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; + gap: 16px; padding: 12px 16px; - background: #f0f9ff; border-bottom: 1px solid #e4e7ed; + background: #fff; } .batch-count { font-size: 14px; - color: #409eff; - font-weight: 500; + color: #606266; } .batch-actions {