1. 前端UploadFile.vue上传组件
<template><div class="upload-container"><!-- 上传 --><el-upload class="upload-demo" :action="action" :auto-upload="false" :on-change="handleFileChange":on-success="handleSuccess" :on-error="handleError" :on-progress="handleProgress"><i class="el-icon-upload"></i><div class="upload-text">{{ uploadText }}</div></el-upload><div v-if="progress !== null" class="progress-container"><div class="progress-bar"><div class="progress-fill" :style="{ width: progress + '%' }"></div></div><span class="progress-text">{{ progress }}%</span></div><!-- 调试信息 --><div class="debug-info"><p>上传地址: {{ props.action }}</p><p>当前文件: {{ currentFile ? currentFile.name : '无' }}</p><p>进度: {{ progress !== null ? progress + '%' : '未开始' }}</p><p>上传状态: {{ isUploading ? '上传中' : '空闲' }}</p><p>已上传分片: {{ uploadedChunks.size }}/{{ totalChunks }}</p><button @click="testUpload" class="test-btn">测试上传功能</button><button @click="resetUpload" class="test-btn" style="background-color: #f56c6c; margin-left: 10px;">重置上传</button></div></div>
</template><script setup>
import { ref } from 'vue';const props = defineProps({action: {type: String,default: 'https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15',},uploadText: {type: String,default: '点击或拖拽文件上传',},
});const fileList = ref([]);
const progress = ref(null);
let fileChunks = [];
let totalChunks = 0;
let currentFile = null;
let uploadedChunks = new Set(); // 记录已上传的分片
let isUploading = false; // 上传状态const handleFileChange = (file, fileList) => {console.log('文件选择成功:', file.name, file.size);console.log('文件列表:', fileList);// 如果正在上传,不允许选择新文件if (isUploading) {console.log('正在上传中,请等待完成后再选择新文件');return;}// 重置状态progress.value = 0;uploadedChunks.clear();// 保存文件currentFile = file.raw || file; // 处理 Element Plus 的文件对象checkUploadStatus(currentFile);
};// 检查上传状态(断点续传)
const checkUploadStatus = async (file) => {console.log('检查文件上传状态:', file.name);const chunkSize = 5 * 1024 * 1024; // 每个文件片的大小 5MBtotalChunks = Math.ceil(file.size / chunkSize);try {const response = await fetch(`${props.action.replace('/upload', '/check-upload')}`, {method: 'POST',headers: {'Content-Type': 'application/json',},body: JSON.stringify({fileName: file.name,totalChunks: totalChunks})});const data = await response.json();console.log('上传状态检查结果:', data);if (data.success) {// 记录已上传的分片uploadedChunks = new Set(data.uploadedChunks || []);if (data.isComplete) {console.log('文件已完全上传');progress.value = 100;return;}// 计算当前进度const currentProgress = Math.round((uploadedChunks.size / totalChunks) * 100);progress.value = currentProgress;console.log(`断点续传:已上传 ${uploadedChunks.size}/${totalChunks} 个分片,进度: ${currentProgress}%`);// 开始分片处理chunkFile(file);} else {console.log('检查上传状态失败,开始全新上传');chunkFile(file);}} catch (error) {console.error('检查上传状态出错:', error);console.log('开始全新上传');chunkFile(file);}
};const chunkFile = (file) => {console.log('开始分片处理:', file.name, '文件大小:', file.size);const chunkSize = 5 * 1024 * 1024; // 每个文件片的大小 5MBtotalChunks = Math.ceil(file.size / chunkSize);fileChunks = [];console.log('文件将分为', totalChunks, '个分片');for (let i = 0; i < totalChunks; i++) {const start = i * chunkSize;const end = Math.min(start + chunkSize, file.size);const chunk = file.slice(start, end);fileChunks.push(chunk);}console.log('分片完成,开始上传');// 开始上传每个分片uploadChunks();
};const uploadChunks = () => {isUploading = true;const uploadNextChunk = (chunkIndex) => {// 跳过已上传的分片while (chunkIndex < fileChunks.length && uploadedChunks.has(chunkIndex)) {console.log(`分片 ${chunkIndex} 已上传,跳过`);chunkIndex++;}if (chunkIndex >= fileChunks.length) {console.log('文件上传完成');progress.value = 100;isUploading = false;return;}console.log(`开始上传第 ${chunkIndex + 1}/${totalChunks} 个分片`);const chunk = fileChunks[chunkIndex];const formData = new FormData();formData.append('file', chunk);formData.append('fileName', currentFile.name);formData.append('chunkIndex', chunkIndex);formData.append('totalChunks', totalChunks);console.log('上传地址:', props.action);// 使用 fetch API 上传文件切片fetch(props.action, {method: 'POST',body: formData,}).then((response) => {console.log('服务器响应状态:', response.status);if (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`);}return response.json();}).then((data) => {console.log('服务器响应数据:', data);if (data.success) {// 标记分片为已上传uploadedChunks.add(chunkIndex);// 更新进度progress.value = Math.round((uploadedChunks.size / totalChunks) * 100);console.log(`第 ${chunkIndex + 1} 个分片上传成功,进度: ${progress.value}%`);// 检查是否完成if (data.isComplete) {console.log('所有分片上传完成');progress.value = 100;isUploading = false;return;}uploadNextChunk(chunkIndex + 1); // 上传下一个切片} else {console.error('服务器返回失败:', data);isUploading = false;}}).catch((error) => {console.error('上传失败:', error);isUploading = false;// 不重置进度,保持断点续传状态});};// 从第一个未上传的分片开始let startIndex = 0;while (startIndex < fileChunks.length && uploadedChunks.has(startIndex)) {startIndex++;}if (startIndex < fileChunks.length) {uploadNextChunk(startIndex);} else {console.log('所有分片都已上传完成');progress.value = 100;isUploading = false;}
};const handleError = (error, file, fileList) => {console.log('上传失败', error);
};const handleProgress = (event, file, fileList) => {console.log(`文件 ${file.name} 上传进度:${event.percent}%`);
};const handleSuccess = (response, file, fileList) => {console.log('上传成功', response);
};// 测试函数
const testUpload = () => {console.log('测试上传功能');console.log('Props:', props);console.log('File:', currentFile);console.log('Progress:', progress.value);console.log('Uploaded chunks:', Array.from(uploadedChunks));console.log('Is uploading:', isUploading);
};// 重置上传
const resetUpload = () => {console.log('重置上传状态');isUploading = false;progress.value = null;uploadedChunks.clear();currentFile = null;fileChunks = [];totalChunks = 0;
};</script><style scoped>
.upload-container {padding: 20px;text-align: center;border: 1px solid #000;
}.upload-text {margin-top: 10px;
}.progress-container {margin-top: 20px;display: flex;align-items: center;gap: 10px;justify-content: center;
}.progress-bar {width: 200px;height: 8px;background-color: #f0f0f0;border-radius: 4px;overflow: hidden;
}.progress-fill {height: 100%;background-color: #409eff;transition: width 0.3s ease;
}.progress-text {font-size: 14px;color: #666;min-width: 40px;
}.debug-info {margin-top: 20px;padding: 10px;background-color: #f5f5f5;border-radius: 4px;text-align: left;
}.debug-info p {margin: 5px 0;font-size: 12px;color: #666;
}.test-btn {margin-top: 10px;padding: 5px 10px;background-color: #409eff;color: white;border: none;border-radius: 4px;cursor: pointer;font-size: 12px;
}.test-btn:hover {background-color: #337ecc;
}
</style>
2. 后端本地启动的express服务
2.1 初始化文件夹和安装依赖命令
npm init -y
npm i cors
npm i express
npm i multer
2.2 server.js代码
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const cors = require('cors');const app = express();// 重要:CORS配置必须在路由之前
app.use(cors({origin: ['http://localhost:5173', 'http://localhost:3000'], // 允许的前端地址credentials: true,methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],allowedHeaders: ['Content-Type', 'Authorization']
}));// 解析JSON和URL编码的请求体
app.use(express.json());
app.use(express.urlencoded({ extended: true }));const upload = multer({ dest: 'public/uploads/' });const UPLOAD_DIR = './public/uploads/';// 确保上传目录存在
if (!fs.existsSync(UPLOAD_DIR)) {fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}// 检查文件上传状态(断点续传)
app.post('/check-upload', (req, res) => {const { fileName, totalChunks } = req.body;console.log(`检查文件上传状态: ${fileName}`);const chunkDir = path.join(UPLOAD_DIR, fileName);const uploadedChunks = [];if (fs.existsSync(chunkDir)) {for (let i = 0; i < totalChunks; i++) {const chunkPath = path.join(chunkDir, `chunk-${i}`);if (fs.existsSync(chunkPath)) {uploadedChunks.push(i);}}}console.log(`已上传分片: ${uploadedChunks.join(', ')}`);res.json({ success: true, uploadedChunks,isComplete: uploadedChunks.length === totalChunks});
});// 接收文件分片
app.post('/upload', upload.single('file'), (req, res) => {console.log('收到上传请求:', req.body);const { file, body } = req;const { fileName, chunkIndex, totalChunks } = body;if (!file) {return res.status(400).json({ success: false, error: 'No file received' });}const chunkDir = path.join(UPLOAD_DIR, fileName);if (!fs.existsSync(chunkDir)) {fs.mkdirSync(chunkDir, { recursive: true });}const chunkPath = path.join(chunkDir, `chunk-${chunkIndex}`);fs.renameSync(file.path, chunkPath);console.log(`Received chunk ${chunkIndex} of ${totalChunks} for file: ${fileName}`);// 检查是否所有分片都已上传完成const uploadedChunks = [];for (let i = 0; i < totalChunks; i++) {const checkChunkPath = path.join(chunkDir, `chunk-${i}`);if (fs.existsSync(checkChunkPath)) {uploadedChunks.push(i);}}// 合并所有分片(在上传完成后进行)if (uploadedChunks.length === totalChunks) {console.log(`所有分片上传完成,开始合并: ${fileName}`);mergeChunks(fileName, totalChunks);}res.json({ success: true, message: `Chunk ${chunkIndex} uploaded successfully`,uploadedChunks,isComplete: uploadedChunks.length === totalChunks});
});// 合并文件分片
function mergeChunks(fileName, totalChunks) {const chunkDir = path.join(UPLOAD_DIR, fileName);const filePath = path.join(UPLOAD_DIR, fileName);console.log(`开始合并文件: ${fileName}, 分片数: ${totalChunks}`);console.log(`分片目录: ${chunkDir}`);console.log(`目标文件: ${filePath}`);// 如果只有一个分片,直接重命名if (totalChunks === 1) {const chunkPath = path.join(chunkDir, 'chunk-0');if (fs.existsSync(chunkPath)) {fs.renameSync(chunkPath, filePath);fs.rmdirSync(chunkDir);console.log(`单分片文件 ${fileName} 合并完成`);} else {console.error(`分片文件不存在: ${chunkPath}`);}return;}// 多个分片需要合并const writeStream = fs.createWriteStream(filePath);let completedChunks = 0;for (let i = 0; i < totalChunks; i++) {const chunkPath = path.join(chunkDir, `chunk-${i}`);if (!fs.existsSync(chunkPath)) {console.error(`分片文件不存在: ${chunkPath}`);continue;}const chunkStream = fs.createReadStream(chunkPath);chunkStream.pipe(writeStream, { end: false });chunkStream.on('end', () => {completedChunks++;fs.unlinkSync(chunkPath); // 删除分片文件console.log(`分片 ${i} 合并完成`);if (completedChunks === totalChunks) {writeStream.end();console.log(`文件 ${fileName} 上传完成`);// 删除分片目录try {fs.rmdirSync(chunkDir);console.log(`分片目录已删除: ${chunkDir}`);} catch (error) {console.error(`删除分片目录失败: ${error.message}`);}}});chunkStream.on('error', (error) => {console.error(`读取分片 ${i} 失败:`, error);});}writeStream.on('finish', () => {console.log(`文件 ${fileName} 合并完成`);});writeStream.on('error', (error) => {console.error(`写入文件 ${fileName} 失败:`, error);});
}// 添加根路径路由
app.get('/', (req, res) => {res.json({ message: 'File upload server is running!',endpoints: {upload: 'POST /upload - Upload file chunks'}});
});// 错误处理中间件
app.use((error, req, res, next) => {console.error('Server error:', error);res.status(500).json({ success: false, error: 'Internal server error' });
});app.listen(3000, () => {console.log('Server running on port 3000');console.log('CORS enabled for:', ['http://localhost:5173', 'http://localhost:3000']);
});