<input type="file"> 的侷限性<input type="file"> 保證功能完整性。opacity: 0、position: absolute、z-index: 1 使其不可見但可點擊。<label> 或自定義 <button> + <span> 構建美觀 UI,關聯 for 屬性或 JS 觸發 input.click()。change 事件讀取 files 列表,實時顯示文件名/預覽圖/進度條。<style>
.file-wrapper {
position: relative;
display: inline-block;
width: 200px;
height: 36px;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
background: #f5f5f5;
}
.file-wrapper input[type="file"] {
position: absolute;
left: 0; top: 0;
width: 100%; height: 100%;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.file-wrapper .btn {
display: block;
width: 100%; height: 100%;
line-height: 36px;
text-align: center;
color: #333;
background: linear-gradient(to bottom, #fff, #eaeaea);
pointer-events: none; /* 讓點擊穿透到 input */
}
.file-wrapper .filename {
position: absolute;
left: 80px; right: 10px; top: 0;
line-height: 36px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #666;
font-size: 14px;
pointer-events: none;
}
</style>
<div class="file-wrapper">
<input type="file" id="file1" name="file" onchange="showName(this)">
<span class="btn">選擇文件</span>
<span class="filename">未選擇任何文件</span>
</div>
<script>
function showName(input) {
const name = input.files[0]?.name || '未選擇任何文件';
input.nextElementSibling.nextElementSibling.textContent = name;
}
</script>
<style>
.custom-file-upload {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: 1px solid #007bff;
border-radius: 4px;
background: #007bff;
color: #fff;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.custom-file-upload:hover { background: #0056b3; }
.custom-file-upload:active { background: #004085; }
.custom-file-upload input { display: none; } /* 完全隱藏原生 */
.custom-file-upload .filename {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #e0e0e0;
font-weight: normal;
}
</style>
<label class="custom-file-upload">
<input type="file" id="file2" name="file" onchange="updateName(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
&svg>
<span>上傳文件</span>
<span class="filename">未選擇文件</span>
</label>
<script>
function updateName(input) {
const label = input.closest('label');
const name = input.files[0]?.name || '未選擇文件';
label.querySelector('.filename').textContent = name;
}
</script>
<div class="uploader" id="uploader">
<input type="file" id="multiFile" multiple hidden>
<button type="button" class="btn-primary" onclick="document.getElementById('multiFile').click()">
<svg class="icon">...</svg> 選擇多個文件
</button>
<div class="file-list" id="fileList"></div>
</div>
<style>
.uploader { max-width: 600px; font-family: system-ui; }
.file-list { margin-top: 16px; }
.file-item {
display: flex; align-items: center; gap: 12px;
padding: 12px; border: 1px solid #eee; border-radius: 6px;
margin-bottom: 8px; background: #fafafa;
}
.file-item img { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; }
.file-item .info { flex: 1; min-width: 0; }
.file-item .name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-item .size { color: #888; font-size: 12px; }
.file-item .progress { width: 100px; height: 4px; background: #eee; border-radius: 2px; overflow: hidden; }
.file-item .progress-bar { height: 100%; background: #007bff; transition: width 0.3s; }
.file-item .btn-del { color: #dc3545; background: none; border: none; cursor: pointer; }
</style>
<script>
const input = document.getElementById('multiFile');
const list = document.getElementById('fileList');
input.addEventListener('change', () => {
list.innerHTML = ''; // 清空舊列表
Array.from(input.files).forEach((file, idx) => {
const item = document.createElement('div');
item.className = 'file-item';
item.dataset.index = idx;
// 圖片預覽
let preview = '';
if (file.type.startsWith('image/')) {
const url = URL.createObjectURL(file);
preview = `<img src="${url}" alt="">`;
} else {
preview = `<div class="icon-file">📄</div>`;
}
item.innerHTML = `
${preview}
<div class="info">
<div class="name" title="${file.name}">${file.name}</div>
<div class="size">${formatSize(file.size)}</div>
</div>
<div class="progress"><div class="progress-bar" style="width:0%"></div></div>
<button class="btn-del" onclick="removeFile(${idx})">×</button>
`;
list.appendChild(item);
// 模擬上傳進度 (實際對接 XMLHttpRequest/fetch upload 事件)
simulateUpload(item.querySelector('.progress-bar'));
});
input.value = ''; // 重置允許重複選擇同一文件
});
function removeFile(idx) {
// 實際需操作 FileList (不可變) → 轉為 DataTransfer 或陣列管理
console.log('移除索引', idx);
// 簡單演示:移除 DOM
document.querySelector(`.file-item[data-index="${idx}"]`)?.remove();
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/1024/1024).toFixed(1) + ' MB';
}
function simulateUpload(bar) {
let w = 0;
const timer = setInterval(() => {
w += Math.random() * 15;
if (w >= 100) { w = 100; clearInterval(timer); }
bar.style.width = w + '%';
}, 200);
}
</script>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" required>
<button type="submit">上傳</button>
</form>
後端 (Spring MVC 示例):
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) return "error";
Files.copy(file.getInputStream(), Paths.get("/data/uploads/" + file.getOriginalFilename()));
return "success";
}
async function uploadFiles(files) {
const formData = new FormData();
files.forEach(f => formData.append('files', f));
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// headers: {'X-CSRF-Token': token} // 如有需要
});
return response.json();
}
// 進度監聽 (XMLHttpRequest 原生支持 upload.onprogress)
function uploadWithProgress(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => onProgress(e.loaded / e.total);
xhr.onload = () => resolve(xhr.response);
xhr.onerror = () => reject(xhr.statusText);
xhr.open('POST', '/api/upload');
const fd = new FormData();
fd.append('file', file);
xhr.send(fd);
});
}
<label> 關聯 input (方案二) 或 aria-label (方案一/三)。aria-busy、aria-live="polite" 宣告上傳進度。accept、file.size),減少無效請求。| 風險 | 防範措施 |
|---|---|
| 惡意文件上傳 (腳本、可執行文件) | 後端嚴格校驗 MIME 類型、魔數、副檔名;存儲路徑非 Web 可訪問目錄;隨機重命名;掃毒。 |
| 大文件佔用內存/超時 | 分片上傳 (切片 + 斷點續傳);流式寫入磁盤;Nginx client_max_body_size 限制。 |
| CSRF 攻擊 | 同源策略 + CSRF Token / SameSite Cookie。 |
| XSS (文件名注入) | 前端顯示文件名時轉義;後端存儲/下載時設置 Content-Disposition: attachment; filename*=UTF-8''...。 |