在企业内部,文件管理是一个至关重要的需求。传统的文件服务器往往存在扩展性差、安全性不足等问题。本文将详细介绍如何使用MinIO搭建一套完整的本地局域网文件管理系统,包括前端Vue+TypeScript和后端Java Spring Boot的全栈解决方案。
系统架构设计
核心组件
1. MinIO - 对象存储服务
- 角色: 核心存储引擎
- 优势: S3兼容、高性能、易扩展
- 功能: 文件上传下载、目录管理、权限控制
2. Spring Boot后端
- 技术栈: Java 17 + Spring Boot 3.0
- 功能:
RESTful API接口
文件元数据管理
用户权限控制
文件操作业务逻辑
3. Vue 3前端
- 技术栈: Vue 3 + TypeScript + Element Plus
- 功能:
文件浏览器界面
拖拽上传
文件预览
用户管理界面
4. MySQL数据库
- 用途: 存储文件元数据、用户信息、操作日志
- 优势: 关系型数据保证数据一致性
架构图
1 2 3 4 5 6 7 8 9 10 11 12
| ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Vue 3 + TS │ │ Spring Boot API │ │ MySQL │ │ 前端界面 │◄──►│ 业务逻辑层 │◄──►│ 元数据存储 │ │ │ │ │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ 文件上传 │ │ MinIO Client │ │ 文件存储 │ │ 进度显示 │◄──►│ SDK集成 │◄──►│ 对象存储 │ │ │ │ │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘
|
后端设计 (Java + Spring Boot)
项目结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| file-management-system/ ├── src/main/java/com/filemanager/ │ ├── config/ # 配置类 │ │ ├── MinioConfig.java │ │ └── SecurityConfig.java │ ├── controller/ # REST控制器 │ │ ├── FileController.java │ │ └── UserController.java │ ├── service/ # 业务逻辑层 │ │ ├── FileService.java │ │ ├── UserService.java │ │ └── MinioService.java │ ├── entity/ # 实体类 │ │ ├── FileInfo.java │ │ └── User.java │ ├── repository/ # 数据访问层 │ │ ├── FileRepository.java │ │ └── UserRepository.java │ ├── dto/ # 数据传输对象 │ │ ├── FileUploadDTO.java │ │ └── FileInfoDTO.java │ └── exception/ # 异常处理 │ └── GlobalExceptionHandler.java
|
核心配置类
MinIO配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @Configuration public class MinioConfig {
@Value("${minio.endpoint}") private String endpoint;
@Value("${minio.access-key}") private String accessKey;
@Value("${minio.secret-key}") private String secretKey;
@Value("${minio.bucket-name}") private String bucketName;
@Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); }
@Bean public String bucketName() { return bucketName; } }
|
安全配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @Configuration @EnableWebSecurity public class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeHttpRequests(authz -> authz .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/files/**").authenticated() .anyRequest().authenticated() ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build(); }
@Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter(); } }
|
核心业务逻辑
文件服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
| @Service @Slf4j public class FileService {
@Autowired private MinioClient minioClient;
@Autowired private String bucketName;
@Autowired private FileRepository fileRepository;
public FileInfoDTO uploadFile(MultipartFile file, String userId, String folder) throws Exception { String originalFilename = file.getOriginalFilename(); String fileExtension = getFileExtension(originalFilename); String uniqueFilename = generateUniqueFilename(originalFilename, fileExtension);
String objectName = StringUtils.isNotBlank(folder) ? folder + "/" + uniqueFilename : uniqueFilename;
minioClient.putObject( PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() );
FileInfo fileInfo = new FileInfo(); fileInfo.setOriginalName(originalFilename); fileInfo.setUniqueName(uniqueFilename); fileInfo.setObjectName(objectName); fileInfo.setFileSize(file.getSize()); fileInfo.setContentType(file.getContentType()); fileInfo.setUserId(userId); fileInfo.setFolder(folder); fileInfo.setUploadTime(LocalDateTime.now());
fileRepository.save(fileInfo);
return convertToDTO(fileInfo); }
public InputStreamResource downloadFile(String fileId) throws Exception { FileInfo fileInfo = fileRepository.findById(fileId) .orElseThrow(() -> new RuntimeException("文件不存在"));
GetObjectResponse response = minioClient.getObject( GetObjectArgs.builder() .bucket(bucketName) .object(fileInfo.getObjectName()) .build() );
return new InputStreamResource(response); }
public void deleteFile(String fileId) throws Exception { FileInfo fileInfo = fileRepository.findById(fileId) .orElseThrow(() -> new RuntimeException("文件不存在"));
minioClient.removeObject( RemoveObjectArgs.builder() .bucket(bucketName) .object(fileInfo.getObjectName()) .build() );
fileRepository.delete(fileInfo); }
public Page<FileInfoDTO> getFileList(String userId, String folder, Pageable pageable) { Page<FileInfo> files = fileRepository.findByUserIdAndFolder( userId, folder, pageable); return files.map(this::convertToDTO); }
private String generateUniqueFilename(String originalFilename, String extension) { String timestamp = String.valueOf(System.currentTimeMillis()); String random = UUID.randomUUID().toString().substring(0, 8); return timestamp + "_" + random + "." + extension; }
private String getFileExtension(String filename) { return filename.substring(filename.lastIndexOf(".") + 1); }
private FileInfoDTO convertToDTO(FileInfo fileInfo) { FileInfoDTO dto = new FileInfoDTO(); dto.setId(fileInfo.getId()); dto.setOriginalName(fileInfo.getOriginalName()); dto.setFileSize(fileInfo.getFileSize()); dto.setContentType(fileInfo.getContentType()); dto.setUploadTime(fileInfo.getUploadTime()); dto.setFolder(fileInfo.getFolder()); return dto; } }
|
MinIO服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| @Service @Slf4j public class MinioService {
@Autowired private MinioClient minioClient;
@Autowired private String bucketName;
@PostConstruct public void initBucket() { try { boolean exists = minioClient.bucketExists( BucketExistsArgs.builder().bucket(bucketName).build()); if (!exists) { minioClient.makeBucket( MakeBucketArgs.builder().bucket(bucketName).build()); log.info("创建存储桶: {}", bucketName); } } catch (Exception e) { log.error("初始化存储桶失败", e); } }
public void createFolder(String folderName) throws Exception { String objectName = folderName.endsWith("/") ? folderName : folderName + "/";
minioClient.putObject( PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(new ByteArrayInputStream(new byte[]{}), 0, -1) .build() ); }
public String getFileUrl(String objectName, int expirySeconds) throws Exception { return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucketName) .object(objectName) .expiry(expirySeconds) .build() ); }
public void copyFile(String sourceObjectName, String destObjectName) throws Exception { minioClient.copyObject( CopyObjectArgs.builder() .bucket(bucketName) .object(destObjectName) .source(CopySource.builder() .bucket(bucketName) .object(sourceObjectName) .build()) .build() ); } }
|
REST API接口
文件控制器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
| @RestController @RequestMapping("/api/files") @CrossOrigin public class FileController {
@Autowired private FileService fileService;
@PostMapping("/upload") public ResponseEntity<ApiResponse<FileInfoDTO>> uploadFile( @RequestParam("file") MultipartFile file, @RequestParam(value = "folder", required = false) String folder, @RequestHeader("Authorization") String token) {
try { String userId = extractUserIdFromToken(token); FileInfoDTO result = fileService.uploadFile(file, userId, folder); return ResponseEntity.ok(ApiResponse.success(result)); } catch (Exception e) { return ResponseEntity.badRequest() .body(ApiResponse.error("文件上传失败: " + e.getMessage())); } }
@GetMapping("/download/{fileId}") public ResponseEntity<InputStreamResource> downloadFile(@PathVariable String fileId) { try { InputStreamResource resource = fileService.downloadFile(fileId); FileInfo fileInfo = fileService.getFileInfo(fileId);
return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileInfo.getOriginalName() + "\"") .contentType(MediaType.parseMediaType(fileInfo.getContentType())) .body(resource); } catch (Exception e) { return ResponseEntity.notFound().build(); } }
@GetMapping("/list") public ResponseEntity<ApiResponse<Page<FileInfoDTO>>> getFileList( @RequestParam(value = "folder", required = false) String folder, @RequestParam(value = "page", defaultValue = "0") int page, @RequestParam(value = "size", defaultValue = "20") int size, @RequestHeader("Authorization") String token) {
try { String userId = extractUserIdFromToken(token); Pageable pageable = PageRequest.of(page, size, Sort.by("uploadTime").descending()); Page<FileInfoDTO> files = fileService.getFileList(userId, folder, pageable);
return ResponseEntity.ok(ApiResponse.success(files)); } catch (Exception e) { return ResponseEntity.badRequest() .body(ApiResponse.error("获取文件列表失败: " + e.getMessage())); } }
@DeleteMapping("/{fileId}") public ResponseEntity<ApiResponse<Void>> deleteFile( @PathVariable String fileId, @RequestHeader("Authorization") String token) {
try { fileService.deleteFile(fileId); return ResponseEntity.ok(ApiResponse.success(null)); } catch (Exception e) { return ResponseEntity.badRequest() .body(ApiResponse.error("删除文件失败: " + e.getMessage())); } }
@PostMapping("/folder") public ResponseEntity<ApiResponse<Void>> createFolder( @RequestParam("folderName") String folderName, @RequestHeader("Authorization") String token) {
try { fileService.createFolder(folderName); return ResponseEntity.ok(ApiResponse.success(null)); } catch (Exception e) { return ResponseEntity.badRequest() .body(ApiResponse.error("创建文件夹失败: " + e.getMessage())); } }
private String extractUserIdFromToken(String token) { return JwtUtils.extractUserId(token.replace("Bearer ", "")); } }
|
前端设计 (Vue 3 + TypeScript)
项目结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| file-manager-frontend/ ├── src/ │ ├── api/ # API接口 │ │ ├── file.ts │ │ └── auth.ts │ ├── components/ # 组件 │ │ ├── FileUpload.vue │ │ ├── FileList.vue │ │ ├── FilePreview.vue │ │ └── FolderTree.vue │ ├── views/ # 页面 │ │ ├── Home.vue │ │ ├── Login.vue │ │ └── FileManager.vue │ ├── types/ # 类型定义 │ │ ├── file.ts │ │ └── user.ts │ ├── utils/ # 工具函数 │ │ ├── upload.ts │ │ └── format.ts │ ├── stores/ # 状态管理 │ │ ├── file.ts │ │ └── user.ts │ ├── router/ # 路由配置 │ │ └── index.ts │ └── App.vue ├── public/ └── package.json
|
核心类型定义
文件类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| export interface FileInfo { id: string; originalName: string; fileSize: number; contentType: string; uploadTime: string; folder?: string; url?: string; }
export interface FileUploadDTO { file: File; folder?: string; }
export interface FileListResponse { content: FileInfo[]; totalElements: number; totalPages: number; size: number; number: number; }
export interface UploadProgress { loaded: number; total: number; percentage: number; }
|
API接口封装
文件API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| import axios from 'axios'; import type { FileInfo, FileUploadDTO, FileListResponse } from '@/types/file';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api';
class FileAPI { private getAuthHeaders() { const token = localStorage.getItem('token'); return { Authorization: `Bearer ${token}`, 'Content-Type': 'multipart/form-data', }; }
async uploadFile(fileData: FileUploadDTO): Promise<FileInfo> { const formData = new FormData(); formData.append('file', fileData.file); if (fileData.folder) { formData.append('folder', fileData.folder); }
const response = await axios.post(`${API_BASE_URL}/files/upload`, formData, { headers: this.getAuthHeaders(), });
return response.data.data; }
async getFileList(folder?: string, page = 0, size = 20): Promise<FileListResponse> { const params = new URLSearchParams({ page: page.toString(), size: size.toString(), });
if (folder) { params.append('folder', folder); }
const response = await axios.get(`${API_BASE_URL}/files/list?${params}`, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, }, });
return response.data.data; }
async downloadFile(fileId: string): Promise<Blob> { const response = await axios.get(`${API_BASE_URL}/files/download/${fileId}`, { headers: this.getAuthHeaders(), responseType: 'blob', });
return response.data; }
async deleteFile(fileId: string): Promise<void> { await axios.delete(`${API_BASE_URL}/files/${fileId}`, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, }, }); }
async createFolder(folderName: string): Promise<void> { const formData = new FormData(); formData.append('folderName', folderName);
await axios.post(`${API_BASE_URL}/files/folder`, formData, { headers: this.getAuthHeaders(), }); } }
export const fileAPI = new FileAPI();
|
核心组件
文件上传组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165
| <!-- src/components/FileUpload.vue --> <template> <div class="file-upload"> <div class="upload-area" :class="{ 'drag-over': isDragOver }" @drop="handleDrop" @dragover.prevent="isDragOver = true" @dragleave="isDragOver = false" > <input ref="fileInput" type="file" multiple @change="handleFileSelect" class="file-input" />
<div class="upload-content"> <i class="el-icon-upload upload-icon"></i> <p class="upload-text"> {{ isDragOver ? '释放鼠标上传文件' : '点击或拖拽文件到此处上传' }} </p> <el-button type="primary" @click="$refs.fileInput.click()"> 选择文件 </el-button> </div> </div>
<!-- 上传进度 --> <div v-if="uploadProgress.length > 0" class="upload-progress"> <div v-for="(progress, index) in uploadProgress" :key="index" class="progress-item" > <span class="file-name">{{ progress.fileName }}</span> <el-progress :percentage="progress.percentage" :show-text="false" /> <span class="progress-text">{{ progress.percentage }}%</span> </div> </div> </div> </template>
<script setup lang="ts"> import { ref, reactive } from 'vue'; import { ElMessage } from 'element-plus'; import { fileAPI } from '@/api/file'; import type { UploadProgress } from '@/types/file';
const fileInput = ref<HTMLInputElement>(); const isDragOver = ref(false); const uploadProgress = ref<UploadProgress[]>([]);
const emit = defineEmits<{ uploadSuccess: [file: any]; }>();
const handleFileSelect = async (event: Event) => { const target = event.target as HTMLInputElement; const files = target.files; if (files) { await uploadFiles(Array.from(files)); } };
const handleDrop = async (event: DragEvent) => { event.preventDefault(); isDragOver.value = false;
const files = event.dataTransfer?.files; if (files) { await uploadFiles(Array.from(files)); } };
const uploadFiles = async (files: File[]) => { for (const file of files) { const progress: UploadProgress = reactive({ fileName: file.name, loaded: 0, total: file.size, percentage: 0, });
uploadProgress.value.push(progress);
try { const result = await fileAPI.uploadFile({ file: file, folder: '', // 可以从当前文件夹获取 });
progress.percentage = 100; emit('uploadSuccess', result);
ElMessage.success(`${file.name} 上传成功`); } catch (error) { ElMessage.error(`${file.name} 上传失败`); } }
// 清除完成的上传 setTimeout(() => { uploadProgress.value = uploadProgress.value.filter(p => p.percentage < 100); }, 2000); }; </script>
<style scoped> .file-upload { width: 100%; }
.upload-area { border: 2px dashed #d9d9d9; border-radius: 6px; padding: 40px; text-align: center; cursor: pointer; transition: all 0.3s; }
.upload-area:hover, .upload-area.drag-over { border-color: #409eff; background-color: #f5f7fa; }
.upload-icon { font-size: 48px; color: #c0c4cc; margin-bottom: 16px; }
.upload-text { margin: 16px 0; color: #606266; }
.file-input { display: none; }
.upload-progress { margin-top: 20px; }
.progress-item { display: flex; align-items: center; margin-bottom: 10px; }
.file-name { flex: 1; margin-right: 10px; }
.progress-text { margin-left: 10px; width: 50px; text-align: right; } </style>
|
文件列表组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276
| <!-- src/components/FileList.vue --> <template> <div class="file-list"> <!-- 工具栏 --> <div class="toolbar"> <el-button type="primary" @click="showUploadDialog = true"> <i class="el-icon-upload"></i> 上传文件 </el-button> <el-button @click="showCreateFolderDialog = true"> <i class="el-icon-folder-add"></i> 新建文件夹 </el-button> <el-input v-model="searchKeyword" placeholder="搜索文件..." style="width: 200px" clearable > <template #prefix> <i class="el-icon-search"></i> </template> </el-input> </div>
<!-- 文件列表 --> <el-table :data="filteredFiles" style="width: 100%" @selection-change="handleSelectionChange" > <el-table-column type="selection" width="55" /> <el-table-column label="文件名" min-width="200"> <template #default="scope"> <div class="file-item"> <i :class="getFileIcon(scope.row.contentType)" class="file-icon"></i> <span class="file-name">{{ scope.row.originalName }}</span> </div> </template> </el-table-column> <el-table-column label="大小" width="120"> <template #default="scope"> {{ formatFileSize(scope.row.fileSize) }} </template> </el-table-column> <el-table-column label="类型" width="120"> <template #default="scope"> {{ scope.row.contentType }} </template> </el-table-column> <el-table-column label="上传时间" width="180"> <template #default="scope"> {{ formatDate(scope.row.uploadTime) }} </template> </el-table-column> <el-table-column label="操作" width="150"> <template #default="scope"> <el-button size="mini" @click="downloadFile(scope.row)">下载</el-button> <el-button size="mini" type="danger" @click="deleteFile(scope.row)">删除</el-button> </template> </el-table-column> </el-table>
<!-- 分页 --> <div class="pagination"> <el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :total="totalElements" layout="total, sizes, prev, pager, next, jumper" @size-change="handleSizeChange" @current-change="handleCurrentChange" /> </div>
<!-- 上传对话框 --> <el-dialog v-model="showUploadDialog" title="上传文件" width="600px" > <file-upload @upload-success="handleUploadSuccess" /> </el-dialog>
<!-- 创建文件夹对话框 --> <el-dialog v-model="showCreateFolderDialog" title="新建文件夹" width="400px" > <el-form :model="folderForm" label-width="80px"> <el-form-item label="文件夹名"> <el-input v-model="folderForm.name" placeholder="请输入文件夹名称" /> </el-form-item> </el-form> <template #footer> <el-button @click="showCreateFolderDialog = false">取消</el-button> <el-button type="primary" @click="createFolder">确定</el-button> </template> </el-dialog> </div> </template>
<script setup lang="ts"> import { ref, computed, onMounted } from 'vue'; import { ElMessage, ElMessageBox } from 'element-plus'; import { fileAPI } from '@/api/file'; import FileUpload from './FileUpload.vue'; import type { FileInfo, FileListResponse } from '@/types/file';
const files = ref<FileInfo[]>([]); const currentPage = ref(1); const pageSize = ref(20); const totalElements = ref(0); const searchKeyword = ref(''); const selectedFiles = ref<FileInfo[]>([]); const showUploadDialog = ref(false); const showCreateFolderDialog = ref(false); const folderForm = ref({ name: '' });
const filteredFiles = computed(() => { if (!searchKeyword.value) { return files.value; } return files.value.filter(file => file.originalName.toLowerCase().includes(searchKeyword.value.toLowerCase()) ); });
const loadFiles = async () => { try { const response: FileListResponse = await fileAPI.getFileList( '', // 当前文件夹 currentPage.value - 1, pageSize.value ); files.value = response.content; totalElements.value = response.totalElements; } catch (error) { ElMessage.error('加载文件列表失败'); } };
const handleSelectionChange = (selection: FileInfo[]) => { selectedFiles.value = selection; };
const downloadFile = async (file: FileInfo) => { try { const blob = await fileAPI.downloadFile(file.id); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.originalName; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); } catch (error) { ElMessage.error('下载失败'); } };
const deleteFile = async (file: FileInfo) => { try { await ElMessageBox.confirm('确定要删除这个文件吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning', });
await fileAPI.deleteFile(file.id); ElMessage.success('删除成功'); loadFiles(); } catch (error) { if (error !== 'cancel') { ElMessage.error('删除失败'); } } };
const handleUploadSuccess = (file: FileInfo) => { files.value.unshift(file); totalElements.value++; };
const createFolder = async () => { if (!folderForm.value.name.trim()) { ElMessage.error('请输入文件夹名称'); return; }
try { await fileAPI.createFolder(folderForm.value.name); ElMessage.success('创建文件夹成功'); showCreateFolderDialog.value = false; folderForm.value.name = ''; loadFiles(); } catch (error) { ElMessage.error('创建文件夹失败'); } };
const handleSizeChange = (size: number) => { pageSize.value = size; currentPage.value = 1; loadFiles(); };
const handleCurrentChange = (page: number) => { currentPage.value = page; loadFiles(); };
const getFileIcon = (contentType: string) => { if (contentType.startsWith('image/')) { return 'el-icon-picture'; } else if (contentType.startsWith('video/')) { return 'el-icon-video-camera'; } else if (contentType.includes('pdf')) { return 'el-icon-document'; } else if (contentType.includes('zip') || contentType.includes('rar')) { return 'el-icon-files'; } else { return 'el-icon-document-copy'; } };
const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; };
const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString(); };
onMounted(() => { loadFiles(); }); </script>
<style scoped> .file-list { padding: 20px; }
.toolbar { display: flex; gap: 10px; margin-bottom: 20px; align-items: center; }
.file-item { display: flex; align-items: center; gap: 8px; }
.file-icon { font-size: 20px; color: #606266; }
.file-name { font-weight: 500; }
.pagination { margin-top: 20px; text-align: center; } </style>
|
数据库设计
文件信息表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| CREATE TABLE file_info ( id VARCHAR(36) PRIMARY KEY COMMENT '文件ID', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', unique_name VARCHAR(255) NOT NULL COMMENT '唯一文件名', object_name VARCHAR(500) NOT NULL COMMENT 'MinIO对象名称', file_size BIGINT NOT NULL COMMENT '文件大小(字节)', content_type VARCHAR(100) COMMENT '文件类型', user_id VARCHAR(36) NOT NULL COMMENT '上传用户ID', folder VARCHAR(500) COMMENT '所属文件夹', upload_time DATETIME NOT NULL COMMENT '上传时间', download_count INT DEFAULT 0 COMMENT '下载次数', is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除', delete_time DATETIME COMMENT '删除时间', INDEX idx_user_id (user_id), INDEX idx_folder (folder), INDEX idx_upload_time (upload_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件信息表';
|
用户表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| CREATE TABLE user ( id VARCHAR(36) PRIMARY KEY COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', password VARCHAR(255) NOT NULL COMMENT '密码', email VARCHAR(100) COMMENT '邮箱', phone VARCHAR(20) COMMENT '手机号', real_name VARCHAR(50) COMMENT '真实姓名', role VARCHAR(20) DEFAULT 'USER' COMMENT '角色', status TINYINT(1) DEFAULT 1 COMMENT '状态: 1-正常, 0-禁用', create_time DATETIME NOT NULL COMMENT '创建时间', last_login_time DATETIME COMMENT '最后登录时间', INDEX idx_username (username), INDEX idx_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
操作日志表
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| CREATE TABLE operation_log ( id VARCHAR(36) PRIMARY KEY COMMENT '日志ID', user_id VARCHAR(36) NOT NULL COMMENT '操作用户ID', operation_type VARCHAR(50) NOT NULL COMMENT '操作类型', resource_type VARCHAR(50) NOT NULL COMMENT '资源类型', resource_id VARCHAR(36) COMMENT '资源ID', resource_name VARCHAR(255) COMMENT '资源名称', operation_time DATETIME NOT NULL COMMENT '操作时间', ip_address VARCHAR(50) COMMENT 'IP地址', user_agent TEXT COMMENT '用户代理', INDEX idx_user_id (user_id), INDEX idx_operation_time (operation_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
|
部署配置
Docker Compose配置
docker-compose.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| version: '3.8'
services: minio: image: minio/minio:latest container_name: filemanager-minio ports: - "9000:9000" - "9001:9001" environment: MINIO_ROOT_USER: admin MINIO_ROOT_PASSWORD: password123 MINIO_ADDRESS: ':9000' MINIO_CONSOLE_ADDRESS: ':9001' volumes: - ./data/minio:/data command: server /data --console-address ":9001" networks: - filemanager-network
mysql: image: mysql:8.0 container_name: filemanager-mysql ports: - "3306:3306" environment: MYSQL_ROOT_PASSWORD: rootpassword MYSQL_DATABASE: filemanager MYSQL_USER: fileuser MYSQL_PASSWORD: filepassword volumes: - ./data/mysql:/var/lib/mysql - ./init:/docker-entrypoint-initdb.d networks: - filemanager-network
app: build: . container_name: filemanager-app ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: docker MYSQL_HOST: mysql MYSQL_PORT: 3306 MYSQL_DATABASE: filemanager MYSQL_USERNAME: fileuser MYSQL_PASSWORD: filepassword MINIO_ENDPOINT: http://minio:9000 MINIO_ACCESS_KEY: admin MINIO_SECRET_KEY: password123 MINIO_BUCKET_NAME: files depends_on: - mysql - minio networks: - filemanager-network
networks: filemanager-network: driver: bridge
volumes: mysql_data: minio_data:
|
配置文件
application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| server: port: 8080
spring: profiles: active: dev
datasource: url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:filemanager}?useSSL=false&serverTimezone=Asia/Shanghai username: ${MYSQL_USERNAME:fileuser} password: ${MYSQL_PASSWORD:filepassword} driver-class-name: com.mysql.cj.jdbc.Driver
jpa: hibernate: ddl-auto: update show-sql: false
minio: endpoint: ${MINIO_ENDPOINT:http://localhost:9000} access-key: ${MINIO_ACCESS_KEY:admin} secret-key: ${MINIO_SECRET_KEY:password123} bucket-name: ${MINIO_BUCKET_NAME:files}
jwt: secret: mySecretKey123456789012345678901234567890 expiration: 86400000
logging: level: com.filemanager: INFO org.springframework.security: DEBUG
|
启动脚本
start.sh
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| #!/bin/bash
echo "🚀 启动文件管理系统..."
if ! command -v docker &> /dev/null; then echo "❌ Docker未安装,请先安装Docker" exit 1 fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then echo "❌ Docker Compose未安装,请先安装Docker Compose" exit 1 fi
echo "📁 创建数据目录..." mkdir -p data/mysql mkdir -p data/minio
echo "🏗️ 启动服务..." if command -v docker-compose &> /dev/null; then docker-compose up -d else docker compose up -d fi
echo "⏳ 等待服务启动..." sleep 30
echo "🔍 检查服务状态..." docker ps
echo "" echo "✅ 服务启动完成!" echo "" echo "📋 服务访问地址:" echo "MinIO Console: http://localhost:9001" echo "MinIO API: http://localhost:9000" echo "文件管理应用: http://localhost:8080" echo "MySQL: localhost:3306" echo "" echo "📋 默认账号:" echo "MinIO: admin / password123" echo "MySQL: fileuser / filepassword"
|
使用指南
1. 启动系统
1 2 3 4 5 6
| git clone https://github.com/yourusername/file-management-system.git cd file-management-system
./start.sh
|
2. 配置MinIO
- 访问 http://localhost:9001
- 使用默认账号登录: admin / password123
- 创建存储桶: files
- 设置存储桶权限为公开读取
3. 初始化数据库
1 2 3 4 5 6 7
| CREATE DATABASE filemanager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'fileuser'@'%' IDENTIFIED BY 'filepassword'; GRANT ALL PRIVILEGES ON filemanager.* TO 'fileuser'@'%'; FLUSH PRIVILEGES;
|
4. 访问应用
- 前端应用: http://localhost:8080
- 注册新用户或使用默认管理员账号登录
- 开始上传和管理文件
5. 常用功能
文件上传
- 支持拖拽上传
- 支持批量上传
- 实时显示上传进度
- 自动生成缩略图(图片文件)
文件管理
权限控制
总结
本文详细介绍了如何使用MinIO搭建完整的本地局域网文件管理系统,包括:
核心特性
- 高性能存储: MinIO提供S3兼容的对象存储,支持高并发访问
- 完整的前后端: Vue 3 + TypeScript前端,Spring Boot后端
- 文件管理功能: 上传、下载、删除、文件夹管理等
- 用户权限控制: 基于JWT的用户认证和授权
- 可扩展架构: 支持分布式部署和横向扩展
技术亮点
- 前后端分离: 现代化开发架构
- 类型安全: TypeScript提供完整的类型检查
- 响应式设计: Element Plus提供美观的用户界面
- 微服务架构: 模块化设计,便于维护和扩展
部署优势
- 容器化部署: Docker Compose一键部署
- 本地化存储: 数据完全存储在本地网络
- 高可用性: 支持多节点部署
- 易于维护: 完整的监控和日志系统
这个解决方案适合企业内部文件管理、个人NAS系统等多种应用场景。通过本文的指导,您可以快速搭建一套功能完善、性能优良的文件管理系统。
参考资料
- MinIO官方文档
- Spring Boot官方文档
- Vue 3官方文档
- Element Plus文档
- Docker Compose文档