傳統 `
<!-- upload.html --> <div id="dropzone" class="dropzone"> 拖曳檔案到此,或 <input type="file" id="fileInput" multiple hidden><label for="fileInput">點選檔案</label> </div> <div id="fileList"></div> <button id="btnUpload" disabled>開始上傳</button> <div id="progressWrap" style="display:none"> <progress id="overallProgress" max="100" value="0"></progress> <span id="progressText">0%</span> </div> <style> .dropzone { border: 2px dashed #ccc; padding: 40px; text-align: center; cursor: pointer; } .dropzone.dragover { border-color: #007bff; background: #f0f8ff; } #fileList .file-item { display: flex; align-items: center; gap: 10px; padding: 8px; border-bottom: 1px solid #eee; } #fileList .file-item .preview { width: 40px; height: 40px; object-fit: cover; } #fileList .file-item .bar { flex: 1; height: 4px; background: #eee; border-radius: 2px; overflow: hidden; } #fileList .file-item .bar > div { height: 100%; background: #007bff; width: 0%; transition: width 0.3s; } </style> <script> const dropzone = document.getElementById('dropzone'); const fileInput = document.getElementById('fileInput'); const fileList = document.getElementById('fileList'); const btnUpload = document.getElementById('btnUpload'); const overallProgress = document.getElementById('overallProgress'); const progressText = document.getElementById('progressText'); const progressWrap = document.getElementById('progressWrap'); let filesQueue = []; // {file, xhr, abortController, status: 'pending'|'uploading'|'done'|'error'} ['dragenter', 'dragover'].forEach(e => dropzone.addEventListener(e, ev => { ev.preventDefault(); dropzone.classList.add('dragover'); })); ['dragleave', 'drop'].forEach(e => dropzone.addEventListener(e, ev => { ev.preventDefault(); dropzone.classList.remove('dragover'); })); dropzone.addEventListener('drop', ev => { addFiles(ev.dataTransfer.files); }); fileInput.addEventListener('change', ev => { addFiles(ev.target.files); ev.target.value = ''; // 允許重複選同檔 }); function addFiles(fileListObj) { [...fileListObj].forEach(file => { if (filesQueue.some(f => f.file.name === file.name && f.file.size === file.size)) return; filesQueue.push({file, status: 'pending'}); renderFileItem(file, filesQueue.length - 1); }); btnUpload.disabled = filesQueue.length === 0; } function renderFileItem(file, index) { const div = document.createElement('div'); div.className = 'file-item'; div.dataset.index = index; const isImg = file.type.startsWith('image/'); div.innerHTML = ` ${isImg ? `` : '📄'} ${file.name} ${(file.size/1024).toFixed(1)} KB 待上傳 × `; fileList.appendChild(div); } fileList.addEventListener('click', ev => { if (ev.target.matches('.remove')) { const i = +ev.target.dataset.index; filesQueue.splice(i, 1); fileList.innerHTML = ''; filesQueue.forEach((f, idx) => renderFileItem(f.file, idx)); btnUpload.disabled = filesQueue.length === 0; } }); btnUpload.addEventListener('click', async () => { progressWrap.style.display = 'block'; btnUpload.disabled = true; let done = 0; for (let i = 0; i < filesQueue.length; i++) { const item = filesQueue[i]; if (item.status !== 'pending') continue; item.status = 'uploading'; updateStatus(i, '上傳中...'); try { await uploadFile(item.file, (pct) => { updateProgress(i, pct); }); item.status = 'done'; updateStatus(i, '完成 ✓'); } catch (e) { item.status = 'error'; updateStatus(i, '失敗:' + e.message); } done++; overallProgress.value = Math.round(done / filesQueue.length * 100); progressText.textContent = overallProgress.value + '%'; } btnUpload.disabled = false; alert('全部完成'); }); function uploadFile(file, onProgress) { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', file); // 可加其他欄位 // formData.append('folder', 'uploads'); const xhr = new XMLHttpRequest(); xhr.open('POST', 'upload.php', true); xhr.upload.addEventListener('progress', ev => { if (ev.lengthComputable) { onProgress(Math.round(ev.loaded / ev.total * 100)); } }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { try { const resp = JSON.parse(xhr.responseText); if (resp.success) resolve(resp); else reject(new Error(resp.message || '伺服器錯誤')); } catch { reject(new Error('回應格式錯誤')); } } else { reject(new Error('HTTP ' + xhr.status)); } }); xhr.addEventListener('error', () => reject(new Error('網路錯誤'))); xhr.addEventListener('abort', () => reject(new Error('已取消'))); xhr.send(formData); }); } function updateProgress(index, pct) { const item = fileList.querySelector(`[data-index="${index}"]`); if (item) { item.querySelector('.fill').style.width = pct + '%'; item.querySelector('.status').textContent = pct + '%'; } } function updateStatus(index, text) { const item = fileList.querySelector(`[data-index="${index}"]`); if (item) item.querySelector('.status').textContent = text; } </script>
<?php // upload.php header('Content-Type: application/json; charset=utf-8'); // 設定 $allowTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; $maxSize = 10 * 1024 * 1024; // 10MB $uploadDir = __DIR__ . '/uploads/'; $baseUrl = '/uploads/'; if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } // 檢查請求 if ($_SERVER['REQUEST_METHOD'] !== 'POST') { jsonOut(false, '僅允許 POST'); } if (empty($_FILES['file'])) { jsonOut(false, '未收到檔案'); } $file = $_FILES['file']; // 錯誤代碼檢查 if ($file['error'] !== UPLOAD_ERR_OK) { $msg = match ($file['error']) { UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => '檔案過大', UPLOAD_ERR_PARTIAL => '上傳中斷', UPLOAD_ERR_NO_FILE => '未選檔案', UPLOAD_ERR_NO_TMP_DIR => '伺服器暫存目錄缺失', UPLOAD_ERR_CANT_WRITE => '寫入失敗', UPLOAD_ERR_EXTENSION => '擴充套件阻擋', default => '未知錯誤', }; jsonOut(false, $msg); } // 型別驗證(不信任瀏覽器傳來的 type,用 finfo 偵測) $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($file['tmp_name']); if (!in_array($mime, $allowTypes, true)) { jsonOut(false, '檔案類型不允許:' . $mime); } // 大小驗證 if ($file['size'] > $maxSize) { jsonOut(false, '檔案超過 ' . ($maxSize/1024/1024) . 'MB'); } // 產生安全檔名:隨機 + 原副檔名 $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $safeName = bin2hex(random_bytes(16)) . '.' . $ext; $destPath = $uploadDir . $safeName; // 移動檔案 if (!move_uploaded_file($file['tmp_name'], $destPath)) { jsonOut(false, '儲存失敗'); } // 可選:建立縮圖、掃毒、記錄資料庫... // $db->prepare('INSERT INTO uploads (original, stored, mime, size) VALUES (?,?,?,?)') // ->execute([$file['name'], $safeName, $mime, $file['size']]); jsonOut(true, '上傳成功', [ 'url' => $baseUrl . $safeName, 'name' => $file['name'], 'size' => $file['size'], 'mime' => $mime, ]); function jsonOut($success, $message, $data = null) { echo json_encode(['success' => $success, 'message' => $message, 'data' => $data], JSON_UNESCAPED_UNICODE); exit; }
原理:動態建立隱藏 `` + `` 模擬非同步。不支援進度事件、無法取得真正的 XHR 回應,現代專案請用方案一。
<!-- 舊版範例,僅供參考 --> <script src="jquery.min.js"></script> <script src="jquery.ajaxfileupload.js"></script> <script> $.ajaxFileUpload({ url: 'upload_legacy.php', secureuri: false, fileElementId: 'file1', dataType: 'json', success: function(data) { if (data.success) alert('上傳成功:' + data.data.url); else alert('失敗:' + data.message); }, error: function() { alert('網路錯誤'); } }); </script> <input type="file" id="file1" name="file1"> <button onclick="$.ajaxFileUpload({...})">上傳</button>
finfo
php_flag engine off
location ~ \.php$ { deny all; }
getimagesize()
imagick
upload_max_filesize
post_max_size
basename
random_bytes
前端將檔案切片(如 5MB/片),每片上傳帶上 chunkIndex、totalChunks、fileHash(SparkMD5 計算)。後端合併:
chunkIndex
totalChunks
fileHash
<?php // merge_chunks.php $hash = $_POST['fileHash']; $total = (int)$_POST['totalChunks']; $dir = "/tmp/chunks/$hash/"; $final = "/var/www/uploads/$hash." . $_POST['ext']; for ($i = 0; $i < $total; $i++) { $chunk = "$dir/$i"; if (!file_exists($chunk)) { http_response_code(400); exit('缺少分片'); } // 追加合併 $fp = fopen($final, 'ab'); fwrite($fp, file_get_contents($chunk)); fclose($fp); unlink($chunk); } rmdir($dir); echo json_encode(['success' => true, 'url' => '/uploads/' . basename($final)]);
掌握完整上傳流程與安全防護,即能建立健壯、使用者體驗良好的檔案上傳功能。
WordPress 登錄後關閉登錄頁並隱藏自定義欄目:兩大後
官方文檔定義 require() 與 include()
背景數據 微信紅包峰值 8.1 億次/分鐘(除夕),總發送量
ThinkPHP 靜態快取配置與使用完整教學 ThinkPH