在企业内部,文件管理是一个至关重要的需求。传统的文件服务器往往存在扩展性差、安全性不足等问题。本文将详细介绍如何使用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;

// 上传到MinIO
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("文件不存在"));

// 从MinIO删除
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 {
// MinIO中文件夹通过空对象表示
String objectName = folderName.endsWith("/") ? folderName : folderName + "/";

minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(new ByteArrayInputStream(new byte[]{}), 0, -1)
.build()
);
}

/**
* 获取文件URL
*/
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) {
// JWT 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
// src/types/file.ts
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
// src/api/file.ts
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服务
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数据库
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 # 24小时

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 "🚀 启动文件管理系统..."

# 检查Docker是否安装
if ! command -v docker &> /dev/null; then
echo "❌ Docker未安装,请先安装Docker"
exit 1
fi

# 检查Docker Compose是否安装
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

  1. 访问 http://localhost:9001
  2. 使用默认账号登录: admin / password123
  3. 创建存储桶: files
  4. 设置存储桶权限为公开读取

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. 访问应用

  1. 前端应用: http://localhost:8080
  2. 注册新用户或使用默认管理员账号登录
  3. 开始上传和管理文件

5. 常用功能

文件上传

  • 支持拖拽上传
  • 支持批量上传
  • 实时显示上传进度
  • 自动生成缩略图(图片文件)

文件管理

  • 创建文件夹
  • 文件重命名
  • 文件移动/复制
  • 文件搜索

权限控制

  • 用户角色管理
  • 文件共享权限
  • 访问日志记录

总结

本文详细介绍了如何使用MinIO搭建完整的本地局域网文件管理系统,包括:

核心特性

  1. 高性能存储: MinIO提供S3兼容的对象存储,支持高并发访问
  2. 完整的前后端: Vue 3 + TypeScript前端,Spring Boot后端
  3. 文件管理功能: 上传、下载、删除、文件夹管理等
  4. 用户权限控制: 基于JWT的用户认证和授权
  5. 可扩展架构: 支持分布式部署和横向扩展

技术亮点

  1. 前后端分离: 现代化开发架构
  2. 类型安全: TypeScript提供完整的类型检查
  3. 响应式设计: Element Plus提供美观的用户界面
  4. 微服务架构: 模块化设计,便于维护和扩展

部署优势

  1. 容器化部署: Docker Compose一键部署
  2. 本地化存储: 数据完全存储在本地网络
  3. 高可用性: 支持多节点部署
  4. 易于维护: 完整的监控和日志系统

这个解决方案适合企业内部文件管理、个人NAS系统等多种应用场景。通过本文的指导,您可以快速搭建一套功能完善、性能优良的文件管理系统。

参考资料

  1. MinIO官方文档
  2. Spring Boot官方文档
  3. Vue 3官方文档
  4. Element Plus文档
  5. Docker Compose文档