PHP分片上传进度条

wen PHP项目 3

本文目录导读:

PHP分片上传进度条

  1. 前端HTML + JavaScript
  2. PHP后端处理
  3. 使用说明
  4. 功能特点

我来为你实现一个完整的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/          # 最终文件目录

使用说明

  1. 配置:在HTML中设置分片大小(默认1MB)和并发数(默认3)
  2. 选择文件:点击或拖拽文件到上传区域
  3. 开始上传:点击"开始上传"按钮
  4. 暂停/继续:随时可暂停和继续上传
  5. 断点续传:重新上传时会检查已上传的分片

功能特点

  • ✅ 分片上传大文件
  • ✅ 实时进度条显示
  • ✅ 支持暂停/继续
  • ✅ 断点续传
  • ✅ 并发上传(提升速度)
  • ✅ 拖拽文件上传
  • ✅ 错误重试机制

这个系统可以处理GB级别的大文件上传,并提供流畅的用户体验。

抱歉,评论功能暂时关闭!