文件夹拖放上传系统(保持文件结构)
下面是一个完整的HTML5+CSS3+AJAX+PHP实现,支持拖放文件夹上传并保持原有文件结构的解决方案。
前端部分 (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><style>* {box-sizing: border-box;font-family: Arial, sans-serif;}body {margin: 0;padding: 20px;background-color: #f5f5f5;}.container {max-width: 800px;margin: 0 auto;background: white;padding: 20px;border-radius: 8px;box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);}h1 {text-align: center;color: #333;}.upload-area {border: 2px dashed #ccc;border-radius: 8px;padding: 40px;text-align: center;margin: 20px 0;transition: all 0.3s;background-color: #fafafa;}.upload-area.highlight {border-color: #4CAF50;background-color: #e8f5e9;}.upload-area p {margin: 0;font-size: 18px;color: #666;}.upload-area .icon {font-size: 48px;color: #4CAF50;margin-bottom: 15px;}.progress-container {margin-top: 20px;display: none;}.progress-bar {height: 20px;background-color: #e0e0e0;border-radius: 4px;margin-bottom: 10px;overflow: hidden;}.progress {height: 100%;background-color: #4CAF50;width: 0%;transition: width 0.3s;}.status {font-size: 14px;color: #666;}.file-list {margin-top: 20px;border-top: 1px solid #eee;padding-top: 20px;}.file-list h3 {margin-top: 0;color: #333;}.file-list ul {list-style: none;padding: 0;max-height: 300px;overflow-y: auto;}.file-list li {padding: 8px 0;border-bottom: 1px solid #eee;color: #555;font-family: monospace;}.btn {background-color: #4CAF50;color: white;border: none;padding: 10px 20px;text-align: center;text-decoration: none;display: inline-block;font-size: 16px;margin: 10px 2px;cursor: pointer;border-radius: 4px;transition: background-color 0.3s;}.btn:hover {background-color: #45a049;}.btn:disabled {background-color: #cccccc;cursor: not-allowed;}</style>
</head>
<body><div class="container"><h1>文件夹拖放上传</h1><div class="upload-area" id="dropArea"><div class="icon">📁</div><p>拖放文件夹到此处</p><p><small>或</small></p><button id="browseBtn" class="btn">选择文件夹</button><input type="file" id="folderInput" webkitdirectory directory multiple style="display: none;"></div><div class="progress-container" id="progressContainer"><div class="progress-bar"><div class="progress" id="progressBar"></div></div><div class="status" id="statusText">准备上传...</div></div><div class="file-list"><h3>文件结构预览</h3><ul id="fileList"></ul></div><button id="uploadBtn" class="btn" disabled>开始上传</button></div><script>document.addEventListener('DOMContentLoaded', function() {const dropArea = document.getElementById('dropArea');const folderInput = document.getElementById('folderInput');const browseBtn = document.getElementById('browseBtn');const uploadBtn = document.getElementById('uploadBtn');const progressContainer = document.getElementById('progressContainer');const progressBar = document.getElementById('progressBar');const statusText = document.getElementById('statusText');const fileList = document.getElementById('fileList');let files = [];let fileStructure = {};// 阻止默认拖放行为['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {dropArea.addEventListener(eventName, preventDefaults, false);document.body.addEventListener(eventName, preventDefaults, false);});// 高亮显示拖放区域['dragenter', 'dragover'].forEach(eventName => {dropArea.addEventListener(eventName, highlight, false);});['dragleave', 'drop'].forEach(eventName => {dropArea.addEventListener(eventName, unhighlight, false);});// 处理文件放置dropArea.addEventListener('drop', handleDrop, false);// 浏览文件夹按钮browseBtn.addEventListener('click', () => folderInput.click());// 文件夹选择变化folderInput.addEventListener('change', handleFolderSelect, false);// 上传按钮uploadBtn.addEventListener('click', startUpload);function preventDefaults(e) {e.preventDefault();e.stopPropagation();}function highlight() {dropArea.classList.add('highlight');}function unhighlight() {dropArea.classList.remove('highlight');}function handleDrop(e) {const dt = e.dataTransfer;const items = dt.items;files = [];fileStructure = {};fileList.innerHTML = '';// 检查是否支持目录上传if (items && items.length && 'webkitGetAsEntry' in items[0]) {processItems(items);} else {statusText.textContent = '您的浏览器不支持文件夹上传,请使用选择文件夹按钮';}}function handleFolderSelect(e) {files = [];fileStructure = {};fileList.innerHTML = '';if (e.target.files.length) {processFileList(e.target.files);}}function processItems(items) {let remaining = items.length;for (let i = 0; i < items.length; i++) {const item = items[i].webkitGetAsEntry();if (item) {scanEntry(item);} else {remaining--;if (remaining === 0) {updateUI();}}}}function scanEntry(entry, path = '') {if (entry.isFile) {entry.file(file => {file.relativePath = path + file.name;files.push(file);// 构建文件结构const pathParts = file.relativePath.split('/');let currentLevel = fileStructure;for (let i = 0; i < pathParts.length - 1; i++) {const part = pathParts[i];if (!currentLevel[part]) {currentLevel[part] = {};}currentLevel = currentLevel[part];}currentLevel[pathParts[pathParts.length - 1]] = 'file';if (--remainingFiles === 0) {updateUI();}});} else if (entry.isDirectory) {const dirReader = entry.createReader();let entries = [];const readEntries = () => {dirReader.readEntries(results => {if (results.length) {entries = entries.concat(Array.from(results));readEntries();} else {// 构建目录结构const pathParts = (path + entry.name).split('/');let currentLevel = fileStructure;for (let i = 0; i < pathParts.length; i++) {const part = pathParts[i];if (!currentLevel[part]) {currentLevel[part] = {};}currentLevel = currentLevel[part];}remainingDirs += entries.length;remainingFiles += entries.length;for (let i = 0; i < entries.length; i++) {const newPath = path + entry.name + '/';scanEntry(entries[i], newPath);}if (--remainingDirs === 0 && remainingFiles === 0) {updateUI();}}});};readEntries();}}let remainingFiles = 0;let remainingDirs = 0;function processFileList(fileList) {files = Array.from(fileList);fileStructure = {};for (const file of files) {const pathParts = file.webkitRelativePath.split('/');let currentLevel = fileStructure;for (let i = 0; i < pathParts.length - 1; i++) {const part = pathParts[i];if (!currentLevel[part]) {currentLevel[part] = {};}currentLevel = currentLevel[part];}currentLevel[pathParts[pathParts.length - 1]] = 'file';}updateUI();}function updateUI() {// 显示文件结构renderFileStructure(fileStructure, '');if (files.length > 0) {uploadBtn.disabled = false;statusText.textContent = `准备上传 ${files.length} 个文件`;} else {uploadBtn.disabled = true;statusText.textContent = '没有可上传的文件';}}function renderFileStructure(structure, path, indent = 0) {for (const key in structure) {const li = document.createElement('li');li.style.paddingLeft = `${indent * 20}px`;if (structure[key] === 'file') {li.textContent = `📄 ${key}`;fileList.appendChild(li);} else {li.textContent = `📁 ${key}`;fileList.appendChild(li);renderFileStructure(structure[key], path + key + '/', indent + 1);}}}function startUpload() {if (files.length === 0) return;progressContainer.style.display = 'block';uploadBtn.disabled = true;const totalFiles = files.length;let uploaded = 0;statusText.textContent = `上传中: 0/${totalFiles}`;function uploadNext() {if (uploaded >= totalFiles) {statusText.textContent = `上传完成! 共上传 ${totalFiles} 个文件`;uploadBtn.disabled = false;return;}const file = files[uploaded];const formData = new FormData();formData.append('file', file);formData.append('path', file.webkitRelativePath || file.relativePath);const xhr = new XMLHttpRequest();xhr.open('POST', 'upload.php', true);xhr.upload.onprogress = function(e) {if (e.lengthComputable) {const fileProgress = (e.loaded / e.total) * (100 / totalFiles);const totalProgress = (uploaded / totalFiles) * 100 + fileProgress;progressBar.style.width = `${totalProgress}%`;}};xhr.onload = function() {if (xhr.status === 200) {uploaded++;statusText.textContent = `上传中: ${uploaded}/${totalFiles}`;uploadNext();} else {statusText.textContent = `上传失败: ${xhr.responseText}`;uploadBtn.disabled = false;}};xhr.onerror = function() {statusText.textContent = '上传出错,请重试';uploadBtn.disabled = false;};xhr.send(formData);}uploadNext();}});</script>
</body>
</html>
后端部分 (upload.php)
<?php
header('Content-Type: application/json');// 配置上传目录
$uploadDir = __DIR__ . '/uploads/';// 确保上传目录存在
if (!file_exists($uploadDir)) {mkdir($uploadDir, 0777, true);
}// 检查是否有文件上传
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {http_response_code(400);echo json_encode(['error' => '没有文件被上传或上传出错']);exit;
}// 获取文件路径信息
$relativePath = isset($_POST['path']) ? $_POST['path'] : '';
$relativePath = ltrim($relativePath, '/');// 防止目录遍历攻击
if (strpos($relativePath, '..') !== false) {http_response_code(400);echo json_encode(['error' => '非法路径']);exit;
}// 创建完整的目录结构
$fullPath = $uploadDir . $relativePath;
$directory = dirname($fullPath);if (!file_exists($directory)) {if (!mkdir($directory, 0777, true)) {http_response_code(500);echo json_encode(['error' => '无法创建目录']);exit;}
}// 移动上传的文件
if (move_uploaded_file($_FILES['file']['tmp_name'], $fullPath)) {echo json_encode(['success' => true, 'path' => $relativePath]);
} else {http_response_code(500);echo json_encode(['error' => '文件移动失败']);
}
功能说明
-
前端功能:
- 支持拖放文件夹上传
- 支持通过按钮选择文件夹
- 实时显示文件结构预览
- 显示上传进度条
- 保持原始文件目录结构
-
后端功能:
- 接收上传的文件
- 根据相对路径创建目录结构
- 防止目录遍历攻击
- 返回上传状态
部署说明
- 将这两个文件放在同一目录下
- 确保PHP有写入权限(需要创建
uploads
目录) - 确保服务器支持PHP文件上传
- 现代浏览器访问index.html即可使用
注意事项
- 文件夹上传需要现代浏览器支持(Chrome、Edge、Firefox等)
- 大文件上传可能需要调整PHP配置(upload_max_filesize, post_max_size等)
- 生产环境应考虑添加更多安全措施,如文件类型检查、用户认证等
- 对于超大文件或大量文件,可能需要分块上传实现
这个实现完全使用原生技术,不依赖任何第三方库,保持了原始文件目录结构。