494 lines
20 KiB
JavaScript
494 lines
20 KiB
JavaScript
/**
|
|
* 文件上传网站 - 前端交互逻辑
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ── 常量 ────────────────────────────────────────────────
|
|
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
|
|
const MAX_FILES = 9;
|
|
const ALLOWED_EXTENSIONS = ['.txt', '.doc', '.docx', '.pdf', '.rtf', '.odt', '.html', '.csv', '.xls', '.xlsx'];
|
|
|
|
// ── DOM 元素 ────────────────────────────────────────────
|
|
const $name = document.getElementById('name');
|
|
const $dropzone = document.getElementById('dropzone');
|
|
const $fileInput = document.getElementById('fileInput');
|
|
const $fileList = document.getElementById('fileList');
|
|
const $uploadBtn = document.getElementById('uploadBtn');
|
|
const $clearBtn = document.getElementById('clearBtn');
|
|
const $uploadForm = document.getElementById('uploadForm');
|
|
const $resultPanel = document.getElementById('resultPanel');
|
|
|
|
// 弹窗相关
|
|
const $modal = document.getElementById('captchaModal');
|
|
const $modalClose = document.getElementById('modalClose');
|
|
const $modalCancel = document.getElementById('modalCancel');
|
|
const $modalConfirm = document.getElementById('modalConfirm');
|
|
const $captchaImg = document.getElementById('captchaImg');
|
|
const $refreshCaptcha = document.getElementById('refreshCaptcha');
|
|
const $captchaInput = document.getElementById('captchaInput');
|
|
const $captchaError = document.getElementById('captchaError');
|
|
|
|
// ── 状态 ────────────────────────────────────────────────
|
|
let selectedFiles = []; // { file, id, status, error }
|
|
let fileIdCounter = 0;
|
|
let isUploading = false;
|
|
|
|
// ── 工具函数 ────────────────────────────────────────────
|
|
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 getFileExt(filename) {
|
|
const idx = filename.lastIndexOf('.');
|
|
return idx >= 0 ? filename.substring(idx).toLowerCase() : '';
|
|
}
|
|
|
|
function validateFile(file) {
|
|
const ext = getFileExt(file.name);
|
|
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
|
return '不支持的文件格式';
|
|
}
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
return '文件超过 20MB 限制';
|
|
}
|
|
if (file.size === 0) {
|
|
return '文件为空';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── 弹窗控制 ────────────────────────────────────────────
|
|
function openModal() {
|
|
$captchaInput.value = '';
|
|
$captchaError.textContent = '';
|
|
refreshCaptcha();
|
|
$modal.classList.add('active');
|
|
setTimeout(() => $captchaInput.focus(), 200);
|
|
}
|
|
|
|
function closeModal() {
|
|
$modal.classList.remove('active');
|
|
}
|
|
|
|
function refreshCaptcha() {
|
|
$captchaImg.src = '/api/captcha?' + Date.now();
|
|
$captchaInput.value = '';
|
|
$captchaError.textContent = '';
|
|
}
|
|
|
|
// 弹窗事件绑定
|
|
$modalClose.addEventListener('click', closeModal);
|
|
$modalCancel.addEventListener('click', closeModal);
|
|
$captchaImg.addEventListener('click', refreshCaptcha);
|
|
$refreshCaptcha.addEventListener('click', refreshCaptcha);
|
|
|
|
// 点击遮罩层关闭
|
|
$modal.addEventListener('click', function (e) {
|
|
if (e.target === $modal) closeModal();
|
|
});
|
|
|
|
// ESC 关闭弹窗
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape' && $modal.classList.contains('active')) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// 弹窗内回车 = 确认
|
|
$captchaInput.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
$modalConfirm.click();
|
|
}
|
|
});
|
|
|
|
// 确认上传按钮
|
|
$modalConfirm.addEventListener('click', function () {
|
|
const captchaVal = $captchaInput.value.trim();
|
|
if (!captchaVal) {
|
|
$captchaError.textContent = '请输入验证码';
|
|
shakeElement($captchaInput);
|
|
return;
|
|
}
|
|
|
|
const nameVal = $name.value.trim();
|
|
const validFiles = selectedFiles.filter(f => f.status !== 'error');
|
|
|
|
closeModal();
|
|
startUpload(nameVal, captchaVal, validFiles);
|
|
});
|
|
|
|
// ── 文件选择(点击) ────────────────────────────────────
|
|
$dropzone.addEventListener('click', function () {
|
|
if (!isUploading) {
|
|
$fileInput.click();
|
|
}
|
|
});
|
|
|
|
$fileInput.addEventListener('change', function () {
|
|
addFiles(Array.from($fileInput.files));
|
|
$fileInput.value = ''; // 清空以便重复选择同名文件
|
|
});
|
|
|
|
// ── 文件拖拽 ────────────────────────────────────────────
|
|
$dropzone.addEventListener('dragover', function (e) {
|
|
e.preventDefault();
|
|
if (!isUploading) {
|
|
$dropzone.classList.add('dragover');
|
|
}
|
|
});
|
|
|
|
$dropzone.addEventListener('dragleave', function (e) {
|
|
e.preventDefault();
|
|
$dropzone.classList.remove('dragover');
|
|
});
|
|
|
|
$dropzone.addEventListener('drop', function (e) {
|
|
e.preventDefault();
|
|
$dropzone.classList.remove('dragover');
|
|
if (!isUploading) {
|
|
addFiles(Array.from(e.dataTransfer.files));
|
|
}
|
|
});
|
|
|
|
// ── 添加文件到列表 ──────────────────────────────────────
|
|
function addFiles(files) {
|
|
if (isUploading) return;
|
|
|
|
for (const file of files) {
|
|
if (selectedFiles.length >= MAX_FILES) {
|
|
showToast(`单次最多上传 ${MAX_FILES} 个文件`);
|
|
break;
|
|
}
|
|
|
|
const error = validateFile(file);
|
|
const item = {
|
|
file: file,
|
|
id: ++fileIdCounter,
|
|
status: error ? 'error' : 'waiting',
|
|
error: error,
|
|
progress: 0
|
|
};
|
|
selectedFiles.push(item);
|
|
}
|
|
|
|
renderFileList();
|
|
updateButtonState();
|
|
}
|
|
|
|
// ── 渲染文件列表 ────────────────────────────────────────
|
|
function renderFileList() {
|
|
if (selectedFiles.length === 0) {
|
|
$fileList.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
$fileList.innerHTML = selectedFiles.map(item => {
|
|
const statusText = {
|
|
waiting: '等待中',
|
|
uploading: `${item.progress}%`,
|
|
done: '已完成',
|
|
error: item.error || '失败'
|
|
}[item.status];
|
|
|
|
const statusClass = {
|
|
waiting: 'status-waiting',
|
|
uploading: 'status-uploading',
|
|
done: 'status-done',
|
|
error: 'status-error'
|
|
}[item.status];
|
|
|
|
const iconSvg = item.status === 'done'
|
|
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0f766e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
|
|
: item.status === 'error'
|
|
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
|
|
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7a756e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
|
|
|
|
const progressBar = item.status === 'uploading'
|
|
? `<div class="progress-bar"><div class="progress-bar-fill" style="width:${item.progress}%"></div></div>`
|
|
: '';
|
|
|
|
const removeBtn = !isUploading
|
|
? `<button type="button" class="file-item-remove" data-id="${item.id}" title="移除">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>`
|
|
: '';
|
|
|
|
return `
|
|
<div class="file-item" data-id="${item.id}">
|
|
<span class="file-item-icon">${iconSvg}</span>
|
|
<div class="file-item-info">
|
|
<div class="file-item-name" title="${item.file.name}">${item.file.name}</div>
|
|
<div class="file-item-meta">${formatSize(item.file.size)}</div>
|
|
${progressBar}
|
|
</div>
|
|
<span class="file-item-status ${statusClass}">${statusText}</span>
|
|
${removeBtn}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// 绑定移除按钮
|
|
$fileList.querySelectorAll('.file-item-remove').forEach(btn => {
|
|
btn.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
const id = parseInt(this.dataset.id);
|
|
selectedFiles = selectedFiles.filter(f => f.id !== id);
|
|
renderFileList();
|
|
updateButtonState();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── 按钮状态 ────────────────────────────────────────────
|
|
function updateButtonState() {
|
|
const nameValid = $name.value.trim().length >= 2;
|
|
const hasFiles = selectedFiles.some(f => f.status !== 'error');
|
|
$uploadBtn.disabled = !nameValid || !hasFiles || isUploading;
|
|
}
|
|
|
|
$name.addEventListener('input', updateButtonState);
|
|
|
|
// ── 清空列表 ────────────────────────────────────────────
|
|
$clearBtn.addEventListener('click', function () {
|
|
if (isUploading) return;
|
|
selectedFiles = [];
|
|
renderFileList();
|
|
updateButtonState();
|
|
$resultPanel.innerHTML = '';
|
|
});
|
|
|
|
// ── 点击"开始上传" → 打开验证码弹窗 ─────────────────────
|
|
$uploadForm.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
if (isUploading) return;
|
|
|
|
// 前端校验姓名
|
|
const nameVal = $name.value.trim();
|
|
if (nameVal.length < 2) {
|
|
shakeElement($name);
|
|
return;
|
|
}
|
|
|
|
// 校验文件
|
|
const validFiles = selectedFiles.filter(f => f.status !== 'error');
|
|
if (validFiles.length === 0) {
|
|
showToast('没有可上传的文件');
|
|
return;
|
|
}
|
|
|
|
// 打开验证码弹窗
|
|
openModal();
|
|
});
|
|
|
|
// ── 执行上传 ────────────────────────────────────────────
|
|
function startUpload(name, captcha, files) {
|
|
isUploading = true;
|
|
$uploadBtn.disabled = true;
|
|
$clearBtn.disabled = true;
|
|
$resultPanel.innerHTML = '';
|
|
|
|
// 重置文件状态
|
|
files.forEach(f => {
|
|
f.status = 'uploading';
|
|
f.progress = 0;
|
|
});
|
|
renderFileList();
|
|
|
|
const formData = new FormData();
|
|
formData.append('name', name);
|
|
formData.append('captcha', captcha);
|
|
files.forEach(f => formData.append('files', f.file));
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
|
|
// 进度事件
|
|
xhr.upload.addEventListener('progress', function (e) {
|
|
if (e.lengthComputable) {
|
|
const pct = Math.round((e.loaded / e.total) * 100);
|
|
files.forEach(f => {
|
|
f.progress = pct;
|
|
});
|
|
renderFileList();
|
|
}
|
|
});
|
|
|
|
// 完成事件
|
|
xhr.addEventListener('load', function () {
|
|
isUploading = false;
|
|
$clearBtn.disabled = false;
|
|
|
|
if (xhr.status === 200) {
|
|
const data = JSON.parse(xhr.responseText);
|
|
handleUploadSuccess(data, files);
|
|
} else {
|
|
let errMsg = '上传失败,请重试';
|
|
try {
|
|
const errData = JSON.parse(xhr.responseText);
|
|
errMsg = errData.error || errMsg;
|
|
} catch (_) { /* ignore */ }
|
|
|
|
// 如果是验证码错误,重新弹窗让用户输入
|
|
if (errMsg.includes('验证码')) {
|
|
files.forEach(f => {
|
|
f.status = 'waiting';
|
|
f.progress = 0;
|
|
});
|
|
renderFileList();
|
|
updateButtonState();
|
|
showToast(errMsg);
|
|
// 延迟一下再弹窗,让用户看到 toast
|
|
setTimeout(openModal, 600);
|
|
} else {
|
|
handleUploadError(errMsg, files);
|
|
}
|
|
}
|
|
|
|
updateButtonState();
|
|
});
|
|
|
|
xhr.addEventListener('error', function () {
|
|
isUploading = false;
|
|
$clearBtn.disabled = false;
|
|
handleUploadError('网络错误,请检查连接后重试', files);
|
|
updateButtonState();
|
|
});
|
|
|
|
xhr.open('POST', '/api/upload');
|
|
xhr.send(formData);
|
|
}
|
|
|
|
// ── 上传成功处理 ────────────────────────────────────────
|
|
function handleUploadSuccess(data, files) {
|
|
// 更新每个文件的状态
|
|
data.results.forEach((result, idx) => {
|
|
if (idx < files.length) {
|
|
files[idx].status = result.success ? 'done' : 'error';
|
|
files[idx].error = result.success ? null : result.error;
|
|
files[idx].progress = 100;
|
|
}
|
|
});
|
|
renderFileList();
|
|
|
|
// 显示结果面板
|
|
const hasSuccess = data.success_count > 0;
|
|
const hasFail = data.fail_count > 0;
|
|
|
|
let resultHtml = `
|
|
<div class="result-card">
|
|
<div class="result-header">
|
|
<div class="result-icon ${hasSuccess ? 'icon-success' : 'icon-error'}">
|
|
${hasSuccess
|
|
? '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
|
|
: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
|
|
}
|
|
</div>
|
|
<span class="result-title ${hasSuccess ? 'text-success' : 'text-error'}">
|
|
${hasSuccess && !hasFail ? '上传成功' : hasSuccess ? '部分上传成功' : '上传失败'}
|
|
</span>
|
|
</div>
|
|
<div class="result-detail">
|
|
${hasSuccess ? `<p>已上传 <strong>${data.success_count}</strong> 个文件至 <strong>${data.storage_path}</strong></p>` : ''}
|
|
${hasFail ? `<p>${data.fail_count} 个文件上传失败</p>` : ''}
|
|
${hasSuccess ? `<p>该目录累计文件数:<strong>${data.total_files}</strong></p>` : ''}
|
|
</div>
|
|
`;
|
|
|
|
// 文件详情
|
|
if (data.results.length > 0) {
|
|
resultHtml += '<div class="result-files">';
|
|
data.results.forEach(r => {
|
|
if (r.success) {
|
|
resultHtml += `
|
|
<div class="result-file-item file-success">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
<span>${r.filename} → ${r.storage_filename}</span>
|
|
</div>`;
|
|
} else {
|
|
resultHtml += `
|
|
<div class="result-file-item file-error">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
|
<span>${r.filename} — ${r.error}</span>
|
|
</div>`;
|
|
}
|
|
});
|
|
resultHtml += '</div>';
|
|
}
|
|
|
|
resultHtml += '</div>';
|
|
$resultPanel.innerHTML = resultHtml;
|
|
}
|
|
|
|
// ── 上传失败处理 ────────────────────────────────────────
|
|
function handleUploadError(message, files) {
|
|
files.forEach(f => {
|
|
f.status = 'error';
|
|
f.error = message;
|
|
});
|
|
renderFileList();
|
|
|
|
$resultPanel.innerHTML = `
|
|
<div class="result-card">
|
|
<div class="result-header">
|
|
<div class="result-icon icon-error">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
|
</div>
|
|
<span class="result-title text-error">上传失败</span>
|
|
</div>
|
|
<div class="result-detail">
|
|
<p>${message}</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── 抖动效果 ────────────────────────────────────────────
|
|
function shakeElement(el) {
|
|
el.classList.add('shake', 'input-error');
|
|
setTimeout(() => {
|
|
el.classList.remove('shake');
|
|
}, 400);
|
|
setTimeout(() => {
|
|
el.classList.remove('input-error');
|
|
}, 2000);
|
|
}
|
|
|
|
// ── Toast 提示 ──────────────────────────────────────────
|
|
function showToast(message) {
|
|
const existing = document.querySelector('.toast');
|
|
if (existing) existing.remove();
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast';
|
|
toast.textContent = message;
|
|
toast.style.cssText = `
|
|
position: fixed;
|
|
top: 20px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: #1a1816;
|
|
color: white;
|
|
padding: 0.6rem 1.2rem;
|
|
border-radius: 8px;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
z-index: 1100;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
|
`;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
toast.style.transition = 'opacity 0.3s';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 2500);
|
|
}
|
|
|
|
// ── 初始化 ──────────────────────────────────────────────
|
|
updateButtonState();
|
|
|
|
})();
|