From 7bf66d02a420ca6f029a23a47dd17ac9ee6bb7fa Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Fri, 30 May 2025 21:32:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=99=E5=8A=A3=E7=9A=84=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=8F=91=E9=80=81=E5=92=8C=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/css/style.css | 320 +++++++++++++++++++++ client/js/chat.js | 29 +- client/js/util.file.js | 533 ++++++++++++++++++++++++++--------- client/js/util.fileUpload.js | 296 +++++++++++++++++++ 4 files changed, 1037 insertions(+), 141 deletions(-) create mode 100644 client/js/util.fileUpload.js diff --git a/client/css/style.css b/client/css/style.css index 0ff03ca..1087509 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -915,6 +915,12 @@ body { margin-bottom: 8px; } +.file-icon { + font-size: 20px; + margin-right: 10px; + flex-shrink: 0; +} + .file-details { flex: 1; min-width: 0; @@ -1020,3 +1026,317 @@ body { border-color: rgba(255, 193, 7, 0.3); } +/* File Upload Modal Styles */ +/* 文件上传模态框样式 */ +.file-upload-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; +} + +.file-upload-modal.show { + opacity: 1; + visibility: visible; +} + +.file-upload-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.file-upload-container { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: #fff; + border-radius: 12px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); + width: 90%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.file-upload-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid #e0e0e0; +} + +.file-upload-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #333; +} + +.file-upload-close { + background: none; + border: none; + font-size: 24px; + color: #666; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.file-upload-close:hover { + background: #f5f5f5; +} + +.file-upload-content { + flex: 1; + padding: 24px; + overflow-y: auto; +} + +.file-drop-zone { + border: 2px dashed #ddd; + border-radius: 8px; + padding: 40px 20px; + text-align: center; + transition: all 0.3s ease; + background: #fafafa; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; +} + +.file-drop-zone:hover, +.file-drop-zone.drag-over { + border-color: #007bff; + background: #f0f8ff; +} + +.file-drop-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.6; +} + +.file-drop-text p { + margin: 8px 0; + color: #666; +} + +.file-drop-text p:first-child { + font-size: 16px; + color: #333; +} + +.file-browse-btn { + background: none; + border: none; + color: #007bff; + cursor: pointer; + text-decoration: underline; + font-size: inherit; + padding: 0; +} + +.file-browse-btn:hover { + color: #0056b3; +} + +.file-list-container { + animation: fadeInUp 0.3s ease; +} + +.file-list-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #e0e0e0; +} + +.file-list-title { + font-weight: 600; + color: #333; +} + +.file-clear-all-btn { + background: none; + border: 1px solid #dc3545; + color: #dc3545; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; +} + +.file-clear-all-btn:hover { + background: #dc3545; + color: #fff; +} + +.file-list { + max-height: 300px; + overflow-y: auto; + margin-bottom: 16px; +} + +.file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: #f8f9fa; + border-radius: 6px; + margin-bottom: 8px; + transition: background-color 0.2s ease; +} + +.file-item:hover { + background: #e9ecef; +} + +.file-item-info { + flex: 1; + min-width: 0; +} + +.file-item-name { + font-weight: 500; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.file-item-size { + font-size: 12px; + color: #666; +} + +.file-item-remove { + background: none; + border: none; + color: #dc3545; + cursor: pointer; + padding: 4px; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: background-color 0.2s ease; + margin-left: 12px; +} + +.file-item-remove:hover { + background: rgba(220, 53, 69, 0.1); +} + +.file-list-summary { + padding: 12px; + background: #e3f2fd; + border-radius: 6px; + color: #1976d2; + font-size: 14px; + text-align: center; +} + +.file-upload-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 20px 24px; + border-top: 1px solid #e0e0e0; +} + +.file-upload-cancel-btn, +.file-upload-send-btn { + padding: 10px 24px; + border-radius: 6px; + border: none; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; +} + +.file-upload-cancel-btn { + background: #f8f9fa; + color: #6c757d; + border: 1px solid #dee2e6; +} + +.file-upload-cancel-btn:hover { + background: #e9ecef; +} + +.file-upload-send-btn { + background: #007bff; + color: #fff; +} + +.file-upload-send-btn:hover:not(:disabled) { + background: #0056b3; +} + +.file-upload-send-btn:disabled { + background: #6c757d; + cursor: not-allowed; + opacity: 0.6; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Responsive design for mobile */ +@media (max-width: 768px) { + .file-upload-container { + width: 95%; + max-height: 90vh; + } + + .file-upload-content { + padding: 16px; + } + + .file-drop-zone { + padding: 30px 15px; + min-height: 150px; + } + + .file-drop-icon { + font-size: 36px; + } +} + diff --git a/client/js/chat.js b/client/js/chat.js index 2575328..ae00943 100644 --- a/client/js/chat.js +++ b/client/js/chat.js @@ -268,10 +268,22 @@ function renderFileMessage(fileData, isSender) { fileId, fileName, originalSize, - totalVolumes + totalVolumes, + fileCount, + isArchive } = fileData; - const safeFileName = escapeHTML(fileName); - const formattedSize = formatFileSize(originalSize); + + // For archive files, show file count and total size + let displayName, displayMeta; + if (isArchive && fileCount) { + displayName = `${fileCount} files`; + displayMeta = `Total: ${formatFileSize(originalSize)}`; + } else { + displayName = fileName; + displayMeta = formatFileSize(originalSize); + } + + const safeDisplayName = escapeHTML(displayName); // Check actual file transfer status const transfer = window.fileTransfers ? window.fileTransfers.get(fileId) : null; @@ -306,13 +318,18 @@ function renderFileMessage(fileData, isSender) { downloadBtnStyle = 'display: inline-block;'; } + // Different icon for archives vs single files + const fileIcon = isArchive ? '📦' : '📄'; + const downloadText = isArchive ? 'Download All' : 'Download'; + return `
`; diff --git a/client/js/util.file.js b/client/js/util.file.js index 34d2b60..c21c35e 100644 --- a/client/js/util.file.js +++ b/client/js/util.file.js @@ -102,6 +102,100 @@ async function compressFileToVolumes(file, volumeSize = 96 * 1024) { // 96KB原 }); } +// Compress multiple files into a single archive with volumes +// 将多个文件压缩为单个分卷归档 +async function compressFilesToArchive(files, volumeSize = 96 * 1024) { try { + // Create a simple archive format: [file1_size][file1_name_length][file1_name][file1_data][file2_size]... + // 创建简单的归档格式 + const archiveData = []; + const fileManifest = []; + + for (const file of files) { + const fileBuffer = await readFileAsArrayBuffer(file); + const nameBytes = new TextEncoder().encode(file.name); + + // Add file metadata to manifest + fileManifest.push({ + name: file.name, + size: file.size, + offset: 0 // Will be calculated later + }); + + // File format: [name_length(4)][name][size(8)][data] + // Use separate arrays to avoid alignment issues + const nameLengthBytes = new Uint8Array(4); + const nameLengthView = new DataView(nameLengthBytes.buffer); + nameLengthView.setUint32(0, nameBytes.length, true); // little endian + + const fileSizeBytes = new Uint8Array(8); + const fileSizeView = new DataView(fileSizeBytes.buffer); + fileSizeView.setBigUint64(0, BigInt(file.size), true); // little endian + + archiveData.push( + nameLengthBytes, + nameBytes, + fileSizeBytes, + new Uint8Array(fileBuffer) + ); + } + // Combine all data + const totalLength = archiveData.reduce((sum, part) => sum + part.length, 0); + + const combinedData = new Uint8Array(totalLength); + let offset = 0; + + for (const part of archiveData) { + combinedData.set(part, offset); + offset += part.length; + } + + // Calculate hash of the entire archive + const archiveHash = await calculateHash(combinedData); + + // Compress the archive + return new Promise((resolve, reject) => { + deflate(combinedData, { + level: 6, + mem: 8 + }, (err, compressed) => { + if (err) { + reject(err); + return; + } + + // Split compressed data into volumes + const volumes = []; + for (let i = 0; i < compressed.length; i += volumeSize) { + const volume = compressed.slice(i, i + volumeSize); + volumes.push(arrayBufferToBase64(volume)); + } + + resolve({ + volumes, + originalSize: totalLength, + compressedSize: compressed.length, + archiveHash, + fileCount: files.length, + fileManifest + }); + }); + }); + } catch (error) { + throw new Error(`Archive compression failed: ${error.message}`); + } +} + +// Helper function to read file as array buffer +// 辅助函数:将文件读取为ArrayBuffer +function readFileAsArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(file); + }); +} + // Decompress volumes back to file // 将分卷解压回文件 async function decompressVolumesToFile(volumes, fileName, originalHash = null) { @@ -161,6 +255,94 @@ async function decompressVolumesToFile(volumes, fileName, originalHash = null) { } } +// Decompress archive volumes to multiple files +// 将归档分卷解压为多个文件 +async function decompressArchiveToFiles(volumes, fileManifest, archiveHash = null) { + try { + // Combine all volumes using base64 decoding + const combinedData = volumes.map(volume => { + return base64ToArrayBuffer(volume); + }); + + const totalLength = combinedData.reduce((sum, arr) => sum + arr.length, 0); + const compressed = new Uint8Array(totalLength); + let offset = 0; + + for (const data of combinedData) { + compressed.set(data, offset); + offset += data.length; + } + + // Decompress archive + return new Promise((resolve, reject) => { + inflate(compressed, async (err, decompressed) => { + if (err) { + reject(err); + return; + } + + // Verify archive hash if provided + if (archiveHash) { + try { + const calculatedHash = await calculateHash(decompressed); + if (calculatedHash !== archiveHash) { + reject(new Error('Archive integrity check failed: hash mismatch')); + return; + } + } catch (hashError) { + reject(new Error('Archive integrity check failed: ' + hashError.message)); + return; + } + } + // Extract files from archive + let dataOffset = 0; + const extractedFiles = []; + for (const fileInfo of fileManifest) { + // Read file metadata: [name_length(4)][name][size(8)][data] + const nameLengthBytes = decompressed.slice(dataOffset, dataOffset + 4); + const nameLengthView = new DataView(nameLengthBytes.buffer); + const nameLength = nameLengthView.getUint32(0, true); // little endian + dataOffset += 4; + + const nameBytes = decompressed.slice(dataOffset, dataOffset + nameLength); + const fileName = new TextDecoder().decode(nameBytes); + dataOffset += nameLength; + + // Use DataView to read BigUint64 safely + const fileSizeBytes = decompressed.slice(dataOffset, dataOffset + 8); + const fileSizeView = new DataView(fileSizeBytes.buffer); + const fileSize = Number(fileSizeView.getBigUint64(0, true)); // little endian + dataOffset += 8; + + const fileData = decompressed.slice(dataOffset, dataOffset + fileSize); + dataOffset += fileSize; + + // Create and download file + const blob = new Blob([fileData]); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + extractedFiles.push(fileName); + + // Add small delay between downloads to avoid overwhelming the browser + await new Promise(resolve => setTimeout(resolve, 100)); + } + + resolve(extractedFiles); + }); + }); + } catch (error) { + console.error('Archive decompression error:', error); + throw error; + } +} + // Setup file sending functionality // 设置文件发送功能 export function setupFileSend({ @@ -170,138 +352,193 @@ export function setupFileSend({ onSend }) { const attachBtn = document.querySelector(attachBtnSelector); - const fileInput = document.querySelector(fileInputSelector); - if (attachBtn && fileInput) { - // 移除文件类型限制,接受所有文件 - // Remove file type restriction, accept all files - fileInput.accept = '*/*'; - - // 点击附件按钮触发文件选择 - // Click attach button to trigger file selection + if (attachBtn) { + // 点击附件按钮显示文件上传模态框 + // Click attach button to show file upload modal attachBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - fileInput.click(); - }); - - // 处理文件选择 - // Handle file selection - fileInput.addEventListener('change', async (e) => { - const files = e.target.files; - if (files && files.length > 0) { - const file = files[0]; - const fileId = generateFileId(); try { - // 显示压缩进度 - let progressElement = null; - - // 创建一个简单的进度显示函数 - function showProgress(message) { - if (window.addSystemMsg) { - progressElement = window.addSystemMsg(message); - } - } - - function updateProgress(message) { - if (progressElement && progressElement.querySelector) { - const content = progressElement.querySelector('.bubble-content'); - if (content) { - content.innerHTML = message; - } - } - } - - showProgress(`Compressing ${file.name}...`); - // 使用 Web Worker 进行压缩以避免阻塞主线程 - const { volumes, originalSize, compressedSize, originalHash } = await compressFileToVolumes(file); - - updateProgress(`✓ Compressed ${file.name} (${volumes.length} volumes, ${formatFileSize(compressedSize)})`); - - // 创建文件传输状态 - const fileTransfer = { - fileId, - fileName: file.name, - originalSize, - compressedSize, - totalVolumes: volumes.length, - sentVolumes: 0, - status: 'sending', - originalHash - }; - - window.fileTransfers.set(fileId, fileTransfer); - - // 发送文件开始消息 - onSend({ - type: 'file_start', - fileId, - fileName: file.name, - originalSize, - compressedSize, - totalVolumes: volumes.length, - originalHash - }); - - // 使用批量发送来提高效率,避免页面切换时中断 - let currentVolume = 0; - const batchSize = 5; // 每批发送5个分卷 - - function sendNextBatch() { - if (currentVolume >= volumes.length) { - // 发送完成消息 - onSend({ - type: 'file_complete', - fileId - }); - - fileTransfer.status = 'completed'; - updateFileProgress(fileId); - updateProgress(`✓ Sent ${file.name} successfully`); - return; - } - - // 发送当前批次 - const batchEnd = Math.min(currentVolume + batchSize, volumes.length); - const batch = []; - - for (let i = currentVolume; i < batchEnd; i++) { - batch.push({ - type: 'file_volume', - fileId, - volumeIndex: i, - volumeData: volumes[i], - isLast: i === volumes.length - 1 - }); - } - - // 发送批次中的所有分卷 - batch.forEach(volumeMsg => onSend(volumeMsg)); - - // 更新发送进度 - fileTransfer.sentVolumes = batchEnd; - updateFileProgress(fileId); - - currentVolume = batchEnd; - - // 继续发送下一批,使用较短的延迟 - setTimeout(sendNextBatch, 100); - } - - // 开始发送 - sendNextBatch(); - - } catch (error) { - console.error('File compression error:', error); - window.addSystemMsg(`Failed to compress ${file.name}: ${error.message}`); - } - } - // 清空文件输入 - e.target.value = ''; + // Import and show upload modal + import('./util.fileUpload.js').then(({ showFileUploadModal }) => { + showFileUploadModal(async (files) => { + await handleFilesUpload(files, onSend); + }); + }); }); } } +// Handle files upload +// 处理文件上传 +async function handleFilesUpload(files, onSend) { + if (!files || files.length === 0) return; + + const fileId = generateFileId(); + + try { + // Show compression progress + let progressElement = null; + + function showProgress(message) { + if (window.addSystemMsg) { + progressElement = window.addSystemMsg(message); + } + } + + function updateProgress(message) { + if (progressElement && progressElement.querySelector) { + const content = progressElement.querySelector('.bubble-content'); + if (content) { + content.innerHTML = message; + } + } + } + + if (files.length === 1) { + // Single file upload + const file = files[0]; + showProgress(`Compressing ${file.name}...`); + + const { volumes, originalSize, compressedSize, originalHash } = await compressFileToVolumes(file); + + updateProgress(`✓ Compressed ${file.name} (${volumes.length} volumes, ${formatFileSize(compressedSize)})`); + + // Create file transfer state + const fileTransfer = { + fileId, + fileName: file.name, + originalSize, + compressedSize, + totalVolumes: volumes.length, + sentVolumes: 0, + status: 'sending', + originalHash + }; + + window.fileTransfers.set(fileId, fileTransfer); + + // Send file start message + onSend({ + type: 'file_start', + fileId, + fileName: file.name, + originalSize, + compressedSize, + totalVolumes: volumes.length, + originalHash + }); + + // Send volumes + await sendVolumes(fileId, volumes, onSend, updateProgress, file.name); + + } else { + // Multiple files upload - create archive + const totalSize = files.reduce((sum, file) => sum + file.size, 0); + showProgress(`Compressing ${files.length} files (${formatFileSize(totalSize)})...`); + + const { volumes, originalSize, compressedSize, archiveHash, fileCount, fileManifest } = await compressFilesToArchive(files); + + updateProgress(`✓ Compressed ${files.length} files (${volumes.length} volumes, ${formatFileSize(compressedSize)})`); + + // Create file transfer state for archive + const fileTransfer = { + fileId, + fileName: `${files.length} files.zip`, // Virtual archive name + originalSize, + compressedSize, + totalVolumes: volumes.length, + sentVolumes: 0, + status: 'sending', + archiveHash, + fileCount, + fileManifest, + isArchive: true + }; + + window.fileTransfers.set(fileId, fileTransfer); + + // Send archive start message + onSend({ + type: 'file_start', + fileId, + fileName: `${files.length} files`, + originalSize, + compressedSize, + totalVolumes: volumes.length, + archiveHash, + fileCount, + fileManifest, + isArchive: true + }); + + // Send volumes + await sendVolumes(fileId, volumes, onSend, updateProgress, `${files.length} files`); + } + + } catch (error) { + console.error('File compression error:', error); + if (window.addSystemMsg) { + window.addSystemMsg(`Failed to compress files: ${error.message}`); + } + } +} + +// Send volumes with progress tracking +// 发送分卷并跟踪进度 +async function sendVolumes(fileId, volumes, onSend, updateProgress, fileName) { + const fileTransfer = window.fileTransfers.get(fileId); + if (!fileTransfer) return; + + let currentVolume = 0; + const batchSize = 5; // 每批发送5个分卷 + + function sendNextBatch() { + if (currentVolume >= volumes.length) { + // 发送完成消息 + onSend({ + type: 'file_complete', + fileId + }); + + fileTransfer.status = 'completed'; + updateFileProgress(fileId); + updateProgress(`✓ Sent ${fileName} successfully`); + return; + } + + // 发送当前批次 + const batchEnd = Math.min(currentVolume + batchSize, volumes.length); + const batch = []; + + for (let i = currentVolume; i < batchEnd; i++) { + batch.push({ + type: 'file_volume', + fileId, + volumeIndex: i, + volumeData: volumes[i], + isLast: i === volumes.length - 1 + }); + } + + // 发送批次中的所有分卷 + batch.forEach(volumeMsg => onSend(volumeMsg)); + + // 更新发送进度 + fileTransfer.sentVolumes = batchEnd; + updateFileProgress(fileId); + + currentVolume = batchEnd; + + // 继续发送下一批,使用较短的延迟 + setTimeout(sendNextBatch, 100); + } + + // 开始发送 + sendNextBatch(); +} + // Update file progress in chat // 更新聊天中的文件进度 function updateFileProgress(fileId) { @@ -364,7 +601,7 @@ export function handleFileMessage(message, isPrivate = false) { // Handle file start message // 处理文件开始消息 function handleFileStart(message, isPrivate) { - const { fileId, fileName, originalSize, compressedSize, totalVolumes, originalHash } = message; + const { fileId, fileName, originalSize, compressedSize, totalVolumes, originalHash, archiveHash, fileCount, fileManifest, isArchive } = message; const fileTransfer = { fileId, @@ -375,20 +612,39 @@ function handleFileStart(message, isPrivate) { receivedVolumes: new Set(), volumeData: new Array(totalVolumes), status: 'receiving', - originalHash + originalHash, + archiveHash, + fileCount, + fileManifest, + isArchive }; window.fileTransfers.set(fileId, fileTransfer); // 添加文件消息到聊天 if (window.addOtherMsg) { - window.addOtherMsg({ - type: 'file', - fileId, - fileName, - originalSize, - totalVolumes - }, '', '', false, isPrivate ? 'file_private' : 'file'); + let displayData; + if (isArchive) { + displayData = { + type: 'file', + fileId, + fileName: `${fileCount} files`, + originalSize, + totalVolumes, + fileCount, + isArchive: true + }; + } else { + displayData = { + type: 'file', + fileId, + fileName, + originalSize, + totalVolumes + }; + } + + window.addOtherMsg(displayData, '', '', false, isPrivate ? 'file_private' : 'file'); } } @@ -428,11 +684,18 @@ export async function downloadFile(fileId) { if (!transfer || transfer.status !== 'completed') return; try { - await decompressVolumesToFile(transfer.volumeData, transfer.fileName, transfer.originalHash); - window.addSystemMsg(`Downloaded ${transfer.fileName}`); + if (transfer.isArchive) { + // Download archive as multiple files + const extractedFiles = await decompressArchiveToFiles(transfer.volumeData, transfer.fileManifest, transfer.archiveHash); + window.addSystemMsg(`Downloaded ${extractedFiles.length} files`); + } else { + // Download single file + await decompressVolumesToFile(transfer.volumeData, transfer.fileName, transfer.originalHash); + window.addSystemMsg(`Downloaded ${transfer.fileName}`); + } } catch (error) { console.error('Download error:', error); - window.addSystemMsg(`Failed to download ${transfer.fileName}: ${error.message}`); + window.addSystemMsg(`Failed to download: ${error.message}`); } } diff --git a/client/js/util.fileUpload.js b/client/js/util.fileUpload.js new file mode 100644 index 0000000..c354d5e --- /dev/null +++ b/client/js/util.fileUpload.js @@ -0,0 +1,296 @@ +// File upload modal and batch processing utilities +// 文件上传模态框和批量处理工具 + +import { + $, + $id, + createElement, + on, + addClass, + removeClass +} from './util.dom.js'; +import { formatFileSize } from './util.file.js'; + +// File upload modal state +// 文件上传模态框状态 +let uploadModal = null; +let selectedFiles = new Map(); // MapDrag and drop files here
+or
+