拙劣的实现批量发送和批量下载文件
This commit is contained in:
1 parent
3f18c635d7
commit
7bf66d02a4
4 files changed
+1037
-141
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-6
@@ -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 `
|
||||
<div class="file-message" data-file-id="${fileId}">
|
||||
<div class="file-info">
|
||||
<div class="file-icon">${fileIcon}</div>
|
||||
<div class="file-details">
|
||||
<div class="file-name" title="${safeFileName}">${safeFileName}</div>
|
||||
<div class="file-name" title="${safeDisplayName}">${safeDisplayName}</div>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">${formattedSize}</span>
|
||||
<span class="file-size">${displayMeta}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,7 +340,7 @@ function renderFileMessage(fileData, isSender) {
|
||||
<div class="file-status">${statusText}</div>
|
||||
</div>
|
||||
<button class="file-download-btn" style="${downloadBtnStyle}" onclick="window.downloadFile('${fileId}')">
|
||||
📥 Download
|
||||
📥 ${downloadText}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
+398
-135
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(); // Map<fileId, File>
|
||||
let onSendCallback = null;
|
||||
|
||||
// Generate unique file ID
|
||||
// 生成唯一文件ID
|
||||
function generateFileId() {
|
||||
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
// Create file upload modal
|
||||
// 创建文件上传模态框
|
||||
function createUploadModal() {
|
||||
const modal = createElement('div', {
|
||||
class: 'file-upload-modal'
|
||||
}, `
|
||||
<div class="file-upload-overlay"></div>
|
||||
<div class="file-upload-container">
|
||||
<div class="file-upload-header">
|
||||
<h3>Upload Files</h3>
|
||||
<button class="file-upload-close">×</button>
|
||||
</div>
|
||||
<div class="file-upload-content">
|
||||
<div class="file-drop-zone" id="file-drop-zone">
|
||||
<div class="file-drop-icon">📁</div>
|
||||
<div class="file-drop-text">
|
||||
<p><strong>Drag and drop files here</strong></p>
|
||||
<p>or <button class="file-browse-btn" type="button">browse files</button></p>
|
||||
</div>
|
||||
<input type="file" id="file-upload-input" multiple style="display: none;" accept="*/*">
|
||||
</div>
|
||||
<div class="file-list-container" id="file-list-container" style="display: none;">
|
||||
<div class="file-list-header">
|
||||
<span class="file-list-title">Selected Files</span>
|
||||
<button class="file-clear-all-btn" type="button">Clear All</button>
|
||||
</div>
|
||||
<div class="file-list" id="file-list"></div>
|
||||
<div class="file-list-summary" id="file-list-summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-upload-footer">
|
||||
<button class="file-upload-cancel-btn" type="button">Cancel</button>
|
||||
<button class="file-upload-send-btn" type="button" disabled>Send Files</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
// Show file upload modal
|
||||
// 显示文件上传模态框
|
||||
export function showFileUploadModal(onSend) {
|
||||
if (uploadModal) {
|
||||
document.body.removeChild(uploadModal);
|
||||
}
|
||||
|
||||
onSendCallback = onSend;
|
||||
selectedFiles.clear();
|
||||
|
||||
uploadModal = createUploadModal();
|
||||
document.body.appendChild(uploadModal);
|
||||
|
||||
setupModalEvents();
|
||||
|
||||
// Focus and animation
|
||||
setTimeout(() => {
|
||||
addClass(uploadModal, 'show');
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// Hide file upload modal
|
||||
// 隐藏文件上传模态框
|
||||
function hideUploadModal() {
|
||||
if (!uploadModal) return;
|
||||
|
||||
removeClass(uploadModal, 'show');
|
||||
setTimeout(() => {
|
||||
if (uploadModal && uploadModal.parentNode) {
|
||||
document.body.removeChild(uploadModal);
|
||||
}
|
||||
uploadModal = null;
|
||||
selectedFiles.clear();
|
||||
onSendCallback = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Setup modal event listeners
|
||||
// 设置模态框事件监听器
|
||||
function setupModalEvents() {
|
||||
if (!uploadModal) return;
|
||||
|
||||
const overlay = $('.file-upload-overlay', uploadModal);
|
||||
const closeBtn = $('.file-upload-close', uploadModal);
|
||||
const cancelBtn = $('.file-upload-cancel-btn', uploadModal);
|
||||
const sendBtn = $('.file-upload-send-btn', uploadModal);
|
||||
const browseBtn = $('.file-browse-btn', uploadModal);
|
||||
const fileInput = $('#file-upload-input', uploadModal);
|
||||
const dropZone = $('#file-drop-zone', uploadModal);
|
||||
const clearAllBtn = $('.file-clear-all-btn', uploadModal);
|
||||
|
||||
// Close modal events
|
||||
on(overlay, 'click', hideUploadModal);
|
||||
on(closeBtn, 'click', hideUploadModal);
|
||||
on(cancelBtn, 'click', hideUploadModal);
|
||||
|
||||
// Browse files
|
||||
on(browseBtn, 'click', () => fileInput.click());
|
||||
on(fileInput, 'change', handleFileInputChange);
|
||||
|
||||
// Drag and drop
|
||||
on(dropZone, 'dragover', handleDragOver);
|
||||
on(dropZone, 'dragleave', handleDragLeave);
|
||||
on(dropZone, 'drop', handleDrop);
|
||||
|
||||
// Clear all files
|
||||
on(clearAllBtn, 'click', clearAllFiles);
|
||||
|
||||
// Send files
|
||||
on(sendBtn, 'click', handleSendFiles);
|
||||
|
||||
// Prevent default drag behaviors on document
|
||||
on(document, 'dragover', (e) => e.preventDefault());
|
||||
on(document, 'drop', (e) => e.preventDefault());
|
||||
}
|
||||
|
||||
// Handle file input change
|
||||
// 处理文件输入变化
|
||||
function handleFileInputChange(e) {
|
||||
const files = Array.from(e.target.files);
|
||||
addFiles(files);
|
||||
e.target.value = ''; // Clear input
|
||||
}
|
||||
|
||||
// Handle drag over
|
||||
// 处理拖拽悬停
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addClass(e.currentTarget, 'drag-over');
|
||||
}
|
||||
|
||||
// Handle drag leave
|
||||
// 处理拖拽离开
|
||||
function handleDragLeave(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeClass(e.currentTarget, 'drag-over');
|
||||
}
|
||||
|
||||
// Handle drop
|
||||
// 处理文件拖放
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeClass(e.currentTarget, 'drag-over');
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
addFiles(files);
|
||||
}
|
||||
|
||||
// Add files to selection
|
||||
// 添加文件到选择列表
|
||||
function addFiles(files) {
|
||||
files.forEach(file => {
|
||||
const fileId = generateFileId();
|
||||
selectedFiles.set(fileId, file);
|
||||
});
|
||||
|
||||
updateFileList();
|
||||
updateSendButton();
|
||||
}
|
||||
|
||||
// Remove file from selection
|
||||
// 从选择列表中移除文件
|
||||
function removeFile(fileId) {
|
||||
selectedFiles.delete(fileId);
|
||||
updateFileList();
|
||||
updateSendButton();
|
||||
}
|
||||
|
||||
// Clear all files
|
||||
// 清空所有文件
|
||||
function clearAllFiles() {
|
||||
selectedFiles.clear();
|
||||
updateFileList();
|
||||
updateSendButton();
|
||||
}
|
||||
|
||||
// Update file list display
|
||||
// 更新文件列表显示
|
||||
function updateFileList() {
|
||||
if (!uploadModal) return;
|
||||
|
||||
const fileList = $('#file-list', uploadModal);
|
||||
const fileListContainer = $('#file-list-container', uploadModal);
|
||||
const fileListSummary = $('#file-list-summary', uploadModal);
|
||||
const dropZone = $('#file-drop-zone', uploadModal);
|
||||
|
||||
if (selectedFiles.size === 0) {
|
||||
fileListContainer.style.display = 'none';
|
||||
dropZone.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
fileListContainer.style.display = 'block';
|
||||
dropZone.style.display = 'none';
|
||||
|
||||
// Update file list
|
||||
fileList.innerHTML = '';
|
||||
|
||||
for (const [fileId, file] of selectedFiles) {
|
||||
const fileItem = createElement('div', {
|
||||
class: 'file-item',
|
||||
'data-file-id': fileId
|
||||
}, `
|
||||
<div class="file-item-info">
|
||||
<div class="file-item-name" title="${file.name}">${file.name}</div>
|
||||
<div class="file-item-size">${formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
<button class="file-item-remove" type="button" data-file-id="${fileId}">×</button>
|
||||
`);
|
||||
|
||||
fileList.appendChild(fileItem);
|
||||
|
||||
// Add remove event
|
||||
const removeBtn = $('.file-item-remove', fileItem);
|
||||
on(removeBtn, 'click', (e) => {
|
||||
e.preventDefault();
|
||||
removeFile(fileId);
|
||||
});
|
||||
}
|
||||
|
||||
// Update summary
|
||||
const totalSize = Array.from(selectedFiles.values()).reduce((sum, file) => sum + file.size, 0);
|
||||
fileListSummary.innerHTML = `
|
||||
<strong>${selectedFiles.size}</strong> files selected,
|
||||
<strong>${formatFileSize(totalSize)}</strong> total
|
||||
`;
|
||||
}
|
||||
|
||||
// Update send button state
|
||||
// 更新发送按钮状态
|
||||
function updateSendButton() {
|
||||
if (!uploadModal) return;
|
||||
|
||||
const sendBtn = $('.file-upload-send-btn', uploadModal);
|
||||
sendBtn.disabled = selectedFiles.size === 0;
|
||||
}
|
||||
|
||||
// Handle send files
|
||||
// 处理发送文件
|
||||
async function handleSendFiles() {
|
||||
if (selectedFiles.size === 0 || !onSendCallback) return;
|
||||
|
||||
const files = Array.from(selectedFiles.values());
|
||||
|
||||
try {
|
||||
// Close modal first
|
||||
hideUploadModal();
|
||||
|
||||
// Send files through callback
|
||||
await onSendCallback(files);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending files:', error);
|
||||
if (window.addSystemMsg) {
|
||||
window.addSystemMsg(`Failed to send files: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard events
|
||||
// 处理键盘事件
|
||||
on(document, 'keydown', (e) => {
|
||||
if (!uploadModal) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
hideUploadModal();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user