本文目录导读:

我来为你实现一个完整的PHP分片上传带进度条系统。
前端HTML + JavaScript
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">PHP分片上传 - 进度条</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft YaHei', sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 600px;
margin: 50px auto;
background: #fff;
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h2 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.upload-area {
border: 2px dashed #ddd;
border-radius: 8px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 20px;
}
.upload-area:hover {
border-color: #007bff;
background: #f8f9fa;
}
.upload-area.dragover {
border-color: #007bff;
background: #e7f1ff;
}
.upload-area i {
font-size: 48px;
color: #007bff;
margin-bottom: 10px;
}
.file-info {
color: #666;
font-size: 14px;
margin-top: 10px;
}
.progress-container {
display: none;
margin-top: 30px;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 14px;
}
.progress-bar-container {
background: #e9ecef;
border-radius: 4px;
height: 20px;
overflow: hidden;
}
.progress-bar {
background: linear-gradient(90deg, #007bff, #00bcd4);
height: 100%;
width: 0%;
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
min-width: 30px;
}
.progress-text {
color: #fff;
font-size: 12px;
font-weight: bold;
}
.upload-controls {
margin-top: 20px;
text-align: center;
}
.btn {
padding: 10px 30px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
margin: 0 10px;
}
.btn-start {
background: #007bff;
color: #fff;
}
.btn-start:hover {
background: #0056b3;
}
.btn-pause {
background: #ffc107;
color: #333;
}
.btn-reset {
background: #dc3545;
color: #fff;
}
.status {
text-align: center;
margin-top: 20px;
padding: 10px;
border-radius: 5px;
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.chunk-info {
font-size: 12px;
color: #666;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="container">
<h2>📁 文件分片上传</h2>
<div class="upload-area" id="uploadArea">
<div class="upload-icon">📦</div>
<div>点击选择或拖拽文件到此处</div>
<div class="file-info" id="fileInfo"></div>
</div>
<input type="file" id="fileInput" style="display:none">
<div class="progress-container" id="progressContainer">
<div class="progress-info">
<span>上传进度:</span>
<span id="progressPercent">0%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" id="progressBar">
<span class="progress-text" id="progressText">0%</span>
</div>
</div>
<div class="chunk-info" id="chunkInfo"></div>
</div>
<div class="upload-controls">
<button class="btn btn-start" id="startBtn" disabled>开始上传</button>
<button class="btn btn-pause" id="pauseBtn" disabled>暂停</button>
<button class="btn btn-reset" id="resetBtn" disabled>重置</button>
</div>
<div class="status" id="status"></div>
</div>
<script>
class ChunkUploader {
constructor(options) {
this.uploadUrl = options.uploadUrl;
this.mergeUrl = options.mergeUrl;
this.checkUrl = options.checkUrl;
this.chunkSize = options.chunkSize || 1024 * 1024; // 1MB
this.concurrent = options.concurrent || 3; // 并发数
this.file = null;
this.chunks = [];
this.uploadedChunks = new Set();
this.paused = false;
this.initProcess = 0;
this.elements = {
uploadArea: document.getElementById('uploadArea'),
fileInput: document.getElementById('fileInput'),
progressContainer: document.getElementById('progressContainer'),
progressBar: document.getElementById('progressBar'),
progressText: document.getElementById('progressText'),
progressPercent: document.getElementById('progressPercent'),
chunkInfo: document.getElementById('chunkInfo'),
startBtn: document.getElementById('startBtn'),
pauseBtn: document.getElementById('pauseBtn'),
resetBtn: document.getElementById('resetBtn'),
status: document.getElementById('status'),
fileInfo: document.getElementById('fileInfo')
};
this.bindEvents();
}
bindEvents() {
// 拖拽上传
this.elements.uploadArea.addEventListener('click', () => {
this.elements.fileInput.click();
});
this.elements.uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
this.elements.uploadArea.classList.add('dragover');
});
this.elements.uploadArea.addEventListener('dragleave', () => {
this.elements.uploadArea.classList.remove('dragover');
});
this.elements.uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
this.elements.uploadArea.classList.remove('dragover');
this.handleFileSelect(e.dataTransfer.files[0]);
});
// 文件选择
this.elements.fileInput.addEventListener('change', (e) => {
this.handleFileSelect(e.target.files[0]);
});
// 开始上传
this.elements.startBtn.addEventListener('click', () => {
this.upload();
});
// 暂停
this.elements.pauseBtn.addEventListener('click', () => {
this.pause();
});
// 重置
this.elements.resetBtn.addEventListener('click', () => {
this.reset();
});
}
handleFileSelect(file) {
if (!file) return;
this.file = file;
this.elements.fileInfo.textContent = `${file.name} (${this.formatSize(file.size)})`;
this.elements.uploadArea.style.display = 'none';
this.elements.progressContainer.style.display = 'block';
this.elements.startBtn.disabled = false;
this.elements.resetBtn.disabled = false;
this.elements.pauseBtn.disabled = true;
this.initChunks();
this.updateProgress(0);
}
initChunks() {
const chunkCount = Math.ceil(this.file.size / this.chunkSize);
this.chunks = [];
for (let i = 0; i < chunkCount; i++) {
this.chunks.push(i);
}
this.uploadedChunks.clear();
}
async upload() {
if (!this.file) {
this.elements.status.innerHTML = '⚠️ 请先选择文件';
this.elements.status.className = 'status error';
return;
}
this.paused = false;
this.elements.startBtn.style.display = 'none';
this.elements.pauseBtn.disabled = false;
const fileId = this.getFileId();
try {
// 检查已上传的分片
const checkResult = await this.checkUploadedChunks(fileId);
if (checkResult.uploaded) {
this.uploadedChunks = new Set(checkResult.chunks);
}
// 获取未上传的分片
const pendingChunks = this.chunks.filter(chunk =>
!this.uploadedChunks.has(chunk)
);
if (pendingChunks.length === 0) {
await this.mergeChunks(fileId);
return;
}
// 并发上传
await this.uploadChunks(pendingChunks, fileId);
// 所有分片上传完成,合并
await this.mergeChunks(fileId);
} catch (error) {
console.error('Upload error:', error);
this.elements.status.innerHTML = '❌ 上传失败:' + error.message;
this.elements.status.className = 'status error';
this.elements.startBtn.style.display = 'inline-block';
this.elements.pauseBtn.disabled = true;
}
}
async uploadChunks(chunks, fileId) {
let index = 0;
async function worker() {
while (index < chunks.length && !this.paused) {
const currentIndex = index++;
const chunkIndex = chunks[currentIndex];
if (this.uploadedChunks.has(chunkIndex)) {
continue;
}
const chunk = this.file.slice(
chunkIndex * this.chunkSize,
Math.min((chunkIndex + 1) * this.chunkSize, this.file.size)
);
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileId', fileId);
formData.append('chunkIndex', chunkIndex);
formData.append('chunkCount', this.chunks.length);
try {
await this.uploadSingleChunk(formData);
this.uploadedChunks.add(chunkIndex);
// 更新进度
const progress = (this.uploadedChunks.size / this.chunks.length) * 100;
this.updateProgress(progress);
} catch (error) {
console.error(`Chunk ${chunkIndex} upload failed:`, error);
// 重试一次
try {
await this.uploadSingleChunk(formData);
this.uploadedChunks.add(chunkIndex);
} catch (retryError) {
throw new Error(`分片 ${chunkIndex} 上传失败`);
}
}
}
}
// 启动并发worker
const workers = [];
for (let i = 0; i < this.concurrent; i++) {
workers.push(worker.call(this));
}
await Promise.all(workers);
}
uploadSingleChunk(formData) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', this.uploadUrl);
xhr.timeout = 60000;
xhr.onload = () => {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
resolve(response);
} else {
reject(new Error('上传失败'));
}
} catch (e) {
reject(e);
}
} else {
reject(new Error('上传错误'));
}
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.ontimeout = () => reject(new Error('上传超时'));
xhr.send(formData);
});
}
async checkUploadedChunks(fileId) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', `${this.checkUrl}?fileId=${fileId}`);
xhr.onload = () => {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response);
} catch (e) {
resolve({ uploaded: false, chunks: [] });
}
} else {
resolve({ uploaded: false, chunks: [] });
}
};
xhr.onerror = () => resolve({ uploaded: false, chunks: [] });
xhr.send();
});
}
async mergeChunks(fileId) {
this.elements.status.innerHTML = '🔄 正在合并文件...';
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('fileName', this.file.name);
formData.append('fileSize', this.file.size);
formData.append('chunkCount', this.chunks.length);
const xhr = new XMLHttpRequest();
xhr.open('POST', this.mergeUrl);
return new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
this.updateProgress(100);
this.elements.status.innerHTML = '✅ 文件上传成功!';
this.elements.status.className = 'status success';
this.elements.startBtn.style.display = 'inline-block';
this.elements.pauseBtn.disabled = true;
resolve(response);
} else {
reject(new Error(response.message || '合并失败'));
}
} catch (e) {
reject(e);
}
} else {
reject(new Error('合并失败'));
}
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.send(formData);
});
}
pause() {
this.paused = true;
this.elements.startBtn.style.display = 'inline-block';
this.elements.pauseBtn.disabled = true;
this.elements.status.innerHTML = '⏸️ 上传已暂停';
this.elements.status.className = 'status';
}
reset() {
this.paused = true;
this.file = null;
this.chunks = [];
this.uploadedChunks.clear();
this.updateProgress(0);
this.elements.uploadArea.style.display = 'block';
this.elements.progressContainer.style.display = 'none';
this.elements.fileInfo.textContent = '';
this.elements.fileInput.value = '';
this.elements.startBtn.disabled = true;
this.elements.pauseBtn.disabled = true;
this.elements.resetBtn.disabled = true;
this.elements.startBtn.style.display = 'inline-block';
this.elements.status.innerHTML = '';
}
updateProgress(percent) {
this.elements.progressBar.style.width = percent + '%';
this.elements.progressText.textContent = percent.toFixed(1) + '%';
this.elements.progressPercent.textContent = percent.toFixed(1) + '%';
const uploadedSize = Math.round((this.uploadedChunks.size / this.chunks.length) * this.file?.size || 0);
this.elements.chunkInfo.innerHTML = `
已上传:${this.uploadedChunks.size}/${this.chunks.length} 分片<br>
已上传:${this.formatSize(uploadedSize)} / ${this.formatSize(this.file?.size || 0)}
`;
}
getFileId() {
// 生成文件唯一标识
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 10);
return `${timestamp}_${random}`;
}
formatSize(bytes) {
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 uploader = new ChunkUploader({
uploadUrl: 'upload_chunk.php',
mergeUrl: 'merge_chunks.php',
checkUrl: 'check_chunks.php',
chunkSize: 1024 * 1024, // 1MB
concurrent: 3
});
</script>
</body>
</html>
PHP后端处理
upload_chunk.php (分片上传)
<?php
header('Content-Type: application/json');
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new Exception('非法请求方法');
}
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
throw new Exception('文件上传失败');
}
// 配置
$uploadDir = 'uploads/';
$tempDir = $uploadDir . 'temp/';
// 创建目录
if (!file_exists($tempDir)) {
mkdir($tempDir, 0777, true);
}
$fileId = $_POST['fileId'] ?? '';
$chunkIndex = (int)($_POST['chunkIndex'] ?? 0);
$chunkCount = (int)($_POST['chunkCount'] ?? 0);
if (empty($fileId) || $chunkCount <= 0 || $chunkIndex < 0) {
throw new Exception('参数不完整');
}
// 校验分片数据
$chunkPath = $tempDir . $fileId . '_' . $chunkIndex;
// 保存分片
if (!move_uploaded_file($_FILES['file']['tmp_name'], $chunkPath)) {
throw new Exception('保存分片失败');
}
// 检查是否所有分片都已上传
$completedChunks = 0;
$allChunksCompleted = true;
for ($i = 0; $i < $chunkCount; $i++) {
$tempFile = $tempDir . $fileId . '_' . $i;
if (file_exists($tempFile)) {
$completedChunks++;
} else {
$allChunksCompleted = false;
}
}
echo json_encode([
'success' => true,
'message' => '分片 ' . $chunkIndex . ' 上传成功',
'completedChunks' => $completedChunks,
'allCompleted' => $allChunksCompleted
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
}
check_chunks.php (检查已上传分片)
<?php
header('Content-Type: application/json');
$fileId = $_GET['fileId'] ?? '';
$uploadedChunks = [];
if (!empty($fileId)) {
$tempDir = 'uploads/temp/';
if (file_exists($tempDir)) {
$pattern = $tempDir . $fileId . '_*';
$files = glob($pattern);
if ($files) {
foreach ($files as $file) {
$basename = basename($file);
$parts = explode('_', $basename);
$chunkIndex = (int)end($parts);
$uploadedChunks[] = $chunkIndex;
}
}
}
}
echo json_encode([
'uploaded' => !empty($uploadedChunks),
'chunks' => $uploadedChunks
]);
merge_chunks.php (合并分片)
<?php
header('Content-Type: application/json');
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new Exception('非法请求方法');
}
$fileId = $_POST['fileId'] ?? '';
$fileName = $_POST['fileName'] ?? '';
$fileSize = (int)($_POST['fileSize'] ?? 0);
$chunkCount = (int)($_POST['chunkCount'] ?? 0);
if (empty($fileId) || empty($fileName) || $chunkCount <= 0) {
throw new Exception('参数不完整');
}
// 配置
$uploadDir = 'uploads/';
$tempDir = $uploadDir . 'temp/';
$finalDir = $uploadDir . 'final/';
// 确保目录存在
if (!file_exists($finalDir)) {
mkdir($finalDir, 0777, true);
}
// 生成安全的文件名
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
$newFileName = date('YmdHis') . '_' . uniqid() . '.' . $extension;
$finalPath = $finalDir . $newFileName;
// 合并文件
$finalFile = fopen($finalPath, 'wb');
if (!$finalFile) {
throw new Exception('无法创建最终文件');
}
$completedSize = 0;
for ($i = 0; $i < $chunkCount; $i++) {
$chunkPath = $tempDir . $fileId . '_' . $i;
if (!file_exists($chunkPath)) {
fclose($finalFile);
unlink($finalPath);
throw new Exception("分片 {$i} 不存在");
}
$chunk = file_get_contents($chunkPath);
if ($chunk === false) {
fclose($finalFile);
unlink($finalPath);
throw new Exception("读取分片 {$i} 失败");
}
fwrite($finalFile, $chunk);
$completedSize += strlen($chunk);
// 删除分片文件
unlink($chunkPath);
}
fclose($finalFile);
// 验证文件大小
if ($completedSize !== $fileSize && $fileSize > 0) {
unlink($finalPath);
throw new Exception('文件大小不匹配');
}
// 清理空目录
if (is_dir($tempDir) && count(glob($tempDir . '*')) === 0) {
rmdir($tempDir);
}
echo json_encode([
'success' => true,
'message' => '文件合并成功',
'filename' => $newFileName,
'size' => $completedSize
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
}
目录结构
project/
├── index.html # 前端页面
├── upload_chunk.php # 分片上传接口
├── check_chunks.php # 检查分片接口
├── merge_chunks.php # 合并分片接口
└── uploads/ # 上传文件目录
├── temp/ # 分片临时目录
└── final/ # 最终文件目录
使用说明
- 配置:在HTML中设置分片大小(默认1MB)和并发数(默认3)
- 选择文件:点击或拖拽文件到上传区域
- 开始上传:点击"开始上传"按钮
- 暂停/继续:随时可暂停和继续上传
- 断点续传:重新上传时会检查已上传的分片
功能特点
- ✅ 分片上传大文件
- ✅ 实时进度条显示
- ✅ 支持暂停/继续
- ✅ 断点续传
- ✅ 并发上传(提升速度)
- ✅ 拖拽文件上传
- ✅ 错误重试机制
这个系统可以处理GB级别的大文件上传,并提供流畅的用户体验。