PHP、HTML、原生JS实现大文件切片上传

PHP、HTML、原生JS实现大文件切片上传

index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件切片上传</title>
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
    <div class="container">
        <h1>文件切片上传</h1>
        <div class="upload-box">
            <div class="file-select">
                <input type="file" id="fileInput" />
                <label for="fileInput" class="file-label">选择文件</label>
            </div>
            
            <div class="upload-info" id="uploadInfo" style="display: none;">
                <p>文件名: <span id="fileName"></span></p>
                <p>文件大小: <span id="fileSize"></span></p>
                <p>上传进度: <span id="progressPercent">0%</span></p>
                
                <div class="progress-bar">
                    <div class="progress" id="progressBar"></div>
                </div>
                
                <div class="buttons">
                    <button id="uploadBtn" class="btn upload-btn">开始上传</button>
                    <button id="cancelBtn" class="btn cancel-btn">取消上传</button>
                </div>
            </div>
            
            <div id="uploadResult" class="upload-result"></div>
        </div>
    </div>

    <script src="js/upload.js"></script>
</body>
</html>

server:

ChunkUploader.php

<?php
/**
 * 文件切片上传类
 */
class ChunkUploader {
    /**
     * 配置选项
     */
    private $config = [
        'tempDir' => '../temp',    // 临时文件目录
        'uploadDir' => '../uploads', // 上传文件目录
        'allowedTypes' => [],      // 允许的文件类型,空数组表示允许所有
        'maxFileSize' => 0,        // 最大文件大小,0表示不限制
        'overwrite' => false       // 是否覆盖同名文件
    ];

    /**
     * 构造函数
     * @param array $config 配置选项
     */
    public function __construct(array $config = []) {
        // 合并配置
        $this->config = array_merge($this->config, $config);
        
        // 确保目录存在
        $this->ensureDirectoryExists($this->config['tempDir']);
        $this->ensureDirectoryExists($this->config['uploadDir']);
    }

    /**
     * 处理切片上传
     * @return array 处理结果
     */
    public function handleChunkUpload() {
        try {
            // 检查请求
            if (!isset($_FILES['chunk']) || !isset($_POST['fileId']) || 
                !isset($_POST['chunkIndex']) || !isset($_POST['totalChunks']) || 
                !isset($_POST['fileName'])) {
                return $this->error('参数不完整');
            }

            $fileId = $_POST['fileId'];
            $chunkIndex = (int)$_POST['chunkIndex'];
            $totalChunks = (int)$_POST['totalChunks'];
            $fileName = $_POST['fileName'];

            // 检查上传的文件
            if ($_FILES['chunk']['error'] !== UPLOAD_ERR_OK) {
                return $this->error('文件上传错误: ' . $this->getUploadErrorMessage($_FILES['chunk']['error']));
            }

            // 创建文件ID目录
            $fileIdDir = $this->config['tempDir'] . DIRECTORY_SEPARATOR . $fileId;
            $this->ensureDirectoryExists($fileIdDir);

            // 保存切片
            $chunkPath = $fileIdDir . DIRECTORY_SEPARATOR . $chunkIndex;
            if (!move_uploaded_file($_FILES['chunk']['tmp_name'], $chunkPath)) {
                return $this->error('无法保存切片文件');
            }

            return [
                'success' => true,
                'message' => '切片上传成功',
                'data' => [
                    'fileId' => $fileId,
                    'chunkIndex' => $chunkIndex,
                    'totalChunks' => $totalChunks
                ]
            ];
        } catch (Exception $e) {
            return $this->error('上传处理异常: ' . $e->getMessage());
        }
    }

    /**
     * 合并切片
     * @return array 处理结果
     */
    public function mergeChunks() {
        try {
            // 检查请求
            if (!isset($_POST['fileId']) || !isset($_POST['fileName']) || !isset($_POST['totalChunks'])) {
                return $this->error('参数不完整');
            }

            $fileId = $_POST['fileId'];
            $fileName = $_POST['fileName'];
            $totalChunks = (int)$_POST['totalChunks'];

            // 检查文件ID目录
            $fileIdDir = $this->config['tempDir'] . DIRECTORY_SEPARATOR . $fileId;
            if (!is_dir($fileIdDir)) {
                return $this->error('找不到切片文件目录');
            }

            // 检查所有切片是否存在
            for ($i = 0; $i < $totalChunks; $i++) {
                $chunkPath = $fileIdDir . DIRECTORY_SEPARATOR . $i;
                if (!file_exists($chunkPath)) {
                    return $this->error("切片 {$i} 不存在");
                }
            }

            // 生成目标文件路径
            $targetPath = $this->config['uploadDir'] . DIRECTORY_SEPARATOR . $fileName;
            
            // 检查是否允许覆盖
            if (file_exists($targetPath) && !$this->config['overwrite']) {
                $fileInfo = pathinfo($fileName);
                $baseName = $fileInfo['filename'];
                $extension = isset($fileInfo['extension']) ? '.' . $fileInfo['extension'] : '';
                $targetPath = $this->config['uploadDir'] . DIRECTORY_SEPARATOR . $baseName . '_' . time() . $extension;
            }

            // 合并文件
            $targetFile = fopen($targetPath, 'wb');
            if (!$targetFile) {
                return $this->error('无法创建目标文件');
            }

            for ($i = 0; $i < $totalChunks; $i++) {
                $chunkPath = $fileIdDir . DIRECTORY_SEPARATOR . $i;
                $chunkContent = file_get_contents($chunkPath);
                fwrite($targetFile, $chunkContent);
                unlink($chunkPath); // 删除已合并的切片
            }

            fclose($targetFile);

            // 删除临时目录
            rmdir($fileIdDir);

            return [
                'success' => true,
                'message' => '文件合并成功',
                'data' => [
                    'fileName' => basename($targetPath),
                    'filePath' => $targetPath,
                    'fileSize' => filesize($targetPath)
                ]
            ];
        } catch (Exception $e) {
            return $this->error('合并处理异常: ' . $e->getMessage());
        }
    }

    /**
     * 确保目录存在
     * @param string $dir 目录路径
     */
    private function ensureDirectoryExists($dir) {
        if (!file_exists($dir)) {
            if (!mkdir($dir, 0777, true)) {
                throw new Exception("无法创建目录: {$dir}");
            }
        }
    }

    /**
     * 获取上传错误信息
     * @param int $errorCode 错误代码
     * @return string 错误信息
     */
    private function getUploadErrorMessage($errorCode) {
        switch ($errorCode) {
            case UPLOAD_ERR_INI_SIZE:
                return '上传的文件超过了 php.ini 中 upload_max_filesize 选项限制的值';
            case UPLOAD_ERR_FORM_SIZE:
                return '上传文件的大小超过了 HTML 表单中 MAX_FILE_SIZE 选项指定的值';
            case UPLOAD_ERR_PARTIAL:
                return '文件只有部分被上传';
            case UPLOAD_ERR_NO_FILE:
                return '没有文件被上传';
            case UPLOAD_ERR_NO_TMP_DIR:
                return '找不到临时文件夹';
            case UPLOAD_ERR_CANT_WRITE:
                return '文件写入失败';
            case UPLOAD_ERR_EXTENSION:
                return '文件上传被PHP扩展程序中断';
            default:
                return '未知上传错误';
        }
    }

    /**
     * 返回错误信息
     * @param string $message 错误信息
     * @return array 错误结果
     */
    private function error($message) {
        return [
            'success' => false,
            'message' => $message
        ];
    }

    /**
     * 验证文件类型
     * @param string $fileName 文件名
     * @return bool 是否允许
     */
    private function validateFileType($fileName) {
        // 如果没有设置允许的类型,则允许所有
        if (empty($this->config['allowedTypes'])) {
            return true;
        }

        $extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
        return in_array($extension, $this->config['allowedTypes']);
    }

    /**
     * 验证文件大小
     * @param int $fileSize 文件大小
     * @return bool 是否允许
     */
    private function validateFileSize($fileSize) {
        // 如果没有设置最大大小,则允许所有
        if ($this->config['maxFileSize'] <= 0) {
            return true;
        }

        return $fileSize <= $this->config['maxFileSize'];
    }

    /**
     * 清理过期的临时文件
     * @param int $maxAge 最大保留时间(秒)
     */
    public function cleanupTempFiles($maxAge = 86400) {
        $tempDir = $this->config['tempDir'];
        if (!is_dir($tempDir)) {
            return;
        }

        $now = time();
        $dirs = glob($tempDir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR);
        
        foreach ($dirs as $dir) {
            // 检查目录修改时间
            $modTime = filemtime($dir);
            if (($now - $modTime) > $maxAge) {
                $this->removeDirectory($dir);
            }
        }
    }

    /**
     * 递归删除目录
     * @param string $dir 目录路径
     */
    private function removeDirectory($dir) {
        if (!is_dir($dir)) {
            return;
        }

        $files = array_diff(scandir($dir), ['.', '..']);
        foreach ($files as $file) {
            $path = $dir . DIRECTORY_SEPARATOR . $file;
            is_dir($path) ? $this->removeDirectory($path) : unlink($path);
        }
        
        rmdir($dir);
    }
}

merge.php

<?php
// 设置无限执行时间
set_time_limit(0);

// 引入上传类
require_once 'ChunkUploader.php';

// 创建上传实例
$uploader = new ChunkUploader([
    'tempDir' => '../temp',
    'uploadDir' => '../uploads'
]);

// 处理合并请求
$result = $uploader->mergeChunks();

// 返回JSON结果
header('Content-Type: application/json');
echo json_encode($result);

upload.php

<?php
// 设置无限执行时间
set_time_limit(0);

// 引入上传类
require_once 'ChunkUploader.php';

// 创建上传实例
$uploader = new ChunkUploader([
    'tempDir' => '../temp',
    'uploadDir' => '../uploads'
]);

// 处理上传请求
$result = $uploader->handleChunkUpload();

// 返回JSON结果
header('Content-Type: application/json');
echo json_encode($result);

js:

upload.js

class ChunkUploader {
    constructor(options) {
        this.file = null;
        this.chunkSize = options.chunkSize || 2 * 1024 * 1024; // 默认2MB一片
        this.uploadUrl = options.uploadUrl || 'upload.php';
        this.mergeUrl = options.mergeUrl || 'merge.php';
        this.onProgress = options.onProgress || function() {};
        this.onSuccess = options.onSuccess || function() {};
        this.onError = options.onError || function() {};
        this.onFileSelected = options.onFileSelected || function() {};
        
        this.chunks = [];
        this.uploadedChunks = 0;
        this.totalChunks = 0;
        this.isUploading = false;
        this.uploadController = null;
    }
    
    // 选择文件
    selectFile(file) {
        this.file = file;
        this.chunks = this.createChunks(file);
        this.totalChunks = this.chunks.length;
        this.uploadedChunks = 0;
        this.onFileSelected({
            name: file.name,
            size: this.formatFileSize(file.size),
            totalChunks: this.totalChunks
        });
    }
    
    // 创建文件切片
    createChunks(file) {
        const chunks = [];
        const totalChunks = Math.ceil(file.size / this.chunkSize);
        
        for (let i = 0; i < totalChunks; i++) {
            const start = i * this.chunkSize;
            const end = Math.min(file.size, start + this.chunkSize);
            const chunk = file.slice(start, end);
            chunks.push(chunk);
        }
        
        return chunks;
    }
    
    // 开始上传
    async startUpload() {
        if (!this.file || this.isUploading) return;
        
        this.isUploading = true;
        this.uploadController = new AbortController();
        
        try {
            // 生成唯一文件标识
            const fileId = this.generateFileId(this.file);
            
            // 上传所有切片
            const uploadPromises = this.chunks.map((chunk, index) => 
                this.uploadChunk(chunk, index, fileId, this.uploadController.signal)
            );
            
            await Promise.all(uploadPromises);
            
            // 所有切片上传完成,通知服务器合并
            if (this.isUploading) {
                await this.mergeChunks(fileId, this.file.name, this.totalChunks);
                this.onSuccess({
                    fileName: this.file.name,
                    fileId: fileId
                });
            }
        } catch (error) {
            if (error.name !== 'AbortError') {
                this.onError({
                    message: '上传失败: ' + error.message
                });
            }
        } finally {
            this.isUploading = false;
            this.uploadController = null;
        }
    }
    
    // 上传单个切片
    async uploadChunk(chunk, index, fileId, signal) {
        const formData = new FormData();
        formData.append('chunk', chunk, `${index}`);
        formData.append('fileId', fileId);
        formData.append('chunkIndex', index);
        formData.append('totalChunks', this.totalChunks);
        formData.append('fileName', this.file.name);
        
        const response = await fetch(this.uploadUrl, {
            method: 'POST',
            body: formData,
            signal: signal
        });
        
        if (!response.ok) {
            throw new Error(`切片 ${index} 上传失败: ${response.statusText}`);
        }
        
        this.uploadedChunks++;
        const progress = Math.floor((this.uploadedChunks / this.totalChunks) * 100);
        
        this.onProgress({
            progress: progress,
            uploadedChunks: this.uploadedChunks,
            totalChunks: this.totalChunks
        });
        
        return await response.json();
    }
    
    // 通知服务器合并切片
    async mergeChunks(fileId, fileName, totalChunks) {
        const formData = new FormData();
        formData.append('fileId', fileId);
        formData.append('fileName', fileName);
        formData.append('totalChunks', totalChunks);
        
        const response = await fetch(this.mergeUrl, {
            method: 'POST',
            body: formData
        });
        
        if (!response.ok) {
            throw new Error('文件合并失败: ' + response.statusText);
        }
        
        return await response.json();
    }
    
    // 取消上传
    cancelUpload() {
        if (this.isUploading && this.uploadController) {
            this.uploadController.abort();
            this.isUploading = false;
            return true;
        }
        return false;
    }
    
    // 生成文件唯一标识
    generateFileId(file) {
        return `${file.name}_${file.size}_${new Date().getTime()}`;
    }
    
    // 格式化文件大小
    formatFileSize(bytes) {
        if (bytes === 0) return '0 Bytes';
        
        const k = 1024;
        const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        
        return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    }
}

// 页面加载完成后初始化上传器
document.addEventListener('DOMContentLoaded', function() {
    const fileInput = document.getElementById('fileInput');
    const uploadBtn = document.getElementById('uploadBtn');
    const cancelBtn = document.getElementById('cancelBtn');
    const uploadInfo = document.getElementById('uploadInfo');
    const fileName = document.getElementById('fileName');
    const fileSize = document.getElementById('fileSize');
    const progressBar = document.getElementById('progressBar');
    const progressPercent = document.getElementById('progressPercent');
    const uploadResult = document.getElementById('uploadResult');
    
    // 初始化上传器
    const uploader = new ChunkUploader({
        chunkSize: 2 * 1024 * 1024, // 2MB一片
        uploadUrl: 'server/upload.php',
        mergeUrl: 'server/merge.php',
        onFileSelected: function(data) {
            fileName.textContent = data.name;
            fileSize.textContent = data.size;
            uploadInfo.style.display = 'block';
            progressBar.style.width = '0%';
            progressPercent.textContent = '0%';
            uploadResult.style.display = 'none';
        },
        onProgress: function(data) {
            progressBar.style.width = data.progress + '%';
            progressPercent.textContent = data.progress + '%';
        },
        onSuccess: function(data) {
            uploadResult.textContent = `文件 ${data.fileName} 上传成功!`;
            uploadResult.className = 'upload-result success';
            uploadResult.style.display = 'block';
        },
        onError: function(error) {
            uploadResult.textContent = error.message;
            uploadResult.className = 'upload-result error';
            uploadResult.style.display = 'block';
        }
    });
    
    // 文件选择事件
    fileInput.addEventListener('change', function(e) {
        if (this.files.length > 0) {
            uploader.selectFile(this.files[0]);
        }
    });
    
    // 开始上传
    uploadBtn.addEventListener('click', function() {
        uploader.startUpload();
    });
    
    // 取消上传
    cancelBtn.addEventListener('click', function() {
        if (uploader.cancelUpload()) {
            uploadResult.textContent = '上传已取消';
            uploadResult.className = 'upload-result';
            uploadResult.style.display = 'block';
        }
    });
});

css:

style.css

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Microsoft YaHei', Arial, sans-serif;
    background-color: #f5f5f5;
    color: #333;
    line-height: 1.6;
}

.container {
    max-width: 800px;
    margin: 50px auto;
    padding: 20px;
    background-color: #fff;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

h1 {
    text-align: center;
    margin-bottom: 30px;
    color: #2c3e50;
}

.upload-box {
    padding: 20px;
}

.file-select {
    margin-bottom: 20px;
    text-align: center;
}

input[type="file"] {
    display: none;
}

.file-label {
    display: inline-block;
    padding: 12px 24px;
    background-color: #3498db;
    color: white;
    border-radius: 4px;
    cursor: pointer;
    transition: background-color 0.3s;
}

.file-label:hover {
    background-color: #2980b9;
}

.upload-info {
    margin-top: 20px;
}

.progress-bar {
    height: 20px;
    background-color: #e0e0e0;
    border-radius: 10px;
    margin: 15px 0;
    overflow: hidden;
}

.progress {
    height: 100%;
    background-color: #2ecc71;
    width: 0;
    transition: width 0.3s ease;
}

.buttons {
    display: flex;
    justify-content: center;
    gap: 15px;
    margin-top: 20px;
}

.btn {
    padding: 10px 20px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-weight: bold;
    transition: background-color 0.3s;
}

.upload-btn {
    background-color: #2ecc71;
    color: white;
}

.upload-btn:hover {
    background-color: #27ae60;
}

.cancel-btn {
    background-color: #e74c3c;
    color: white;
}

.cancel-btn:hover {
    background-color: #c0392b;
}

.upload-result {
    margin-top: 20px;
    padding: 15px;
    border-radius: 4px;
    display: none;
}

.success {
    background-color: #d4edda;
    color: #155724;
    border: 1px solid #c3e6cb;
}

.error {
    background-color: #f8d7da;
    color: #721c24;
    border: 1px solid #f5c6cb;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值