749 lines
20 KiB
JavaScript
749 lines
20 KiB
JavaScript
// Import necessary modules
|
|
// 导入必要的模块
|
|
import { deflate, inflate } from 'fflate';
|
|
import { showFileUploadModal } from './util.fileUpload.js';
|
|
|
|
// 分卷大小统一配置
|
|
const DEFAULT_VOLUME_SIZE = 512 * 1024; // 512KB
|
|
|
|
// File transfer state management
|
|
// 文件传输状态管理
|
|
window.fileTransfers = new Map();
|
|
|
|
// Base64 encoding for binary data (more efficient than hex)
|
|
// Base64编码用于二进制数据(比十六进制更高效)
|
|
function arrayBufferToBase64(buffer) {
|
|
const uint8Array = new Uint8Array(buffer);
|
|
let binary = '';
|
|
const chunkSize = 0x8000; // 32KB chunks to avoid call stack limits
|
|
|
|
for (let i = 0; i < uint8Array.length; i += chunkSize) {
|
|
const chunk = uint8Array.subarray(i, i + chunkSize);
|
|
binary += String.fromCharCode.apply(null, chunk);
|
|
}
|
|
|
|
return btoa(binary);
|
|
}
|
|
|
|
// Base64 decoding back to binary
|
|
// Base64解码回二进制数据
|
|
function base64ToArrayBuffer(base64) {
|
|
const binary = atob(base64);
|
|
const uint8Array = new Uint8Array(binary.length);
|
|
|
|
for (let i = 0; i < binary.length; i++) {
|
|
uint8Array[i] = binary.charCodeAt(i);
|
|
}
|
|
|
|
return uint8Array;
|
|
}
|
|
|
|
// Generate unique file ID
|
|
// 生成唯一文件ID
|
|
function generateFileId() {
|
|
return 'file_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
}
|
|
|
|
// Calculate SHA-256 hash for data integrity verification
|
|
// 计算SHA-256哈希值用于数据完整性验证
|
|
async function calculateHash(data) {
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
|
|
|
|
// Compress file into volumes with optimized compression
|
|
// 将文件压缩为分卷,优化压缩算法
|
|
async function compressFileToVolumes(file, volumeSize = DEFAULT_VOLUME_SIZE) { // 96KB原始数据,base64后约128KB
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = async function(e) {
|
|
const arrayBuffer = new Uint8Array(e.target.result);
|
|
|
|
try {
|
|
// Calculate hash of original file for integrity
|
|
const originalHash = await calculateHash(arrayBuffer);
|
|
|
|
// Use single compression pass with balanced compression
|
|
// 使用单次压缩,平衡压缩率和速度
|
|
deflate(arrayBuffer, {
|
|
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: file.size,
|
|
compressedSize: compressed.length,
|
|
originalHash
|
|
});
|
|
});
|
|
} catch (hashError) {
|
|
reject(hashError);
|
|
}
|
|
};
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsArrayBuffer(file);
|
|
});
|
|
}
|
|
|
|
// Compress multiple files into a single archive with volumes
|
|
// 将多个文件压缩为单个分卷归档
|
|
async function compressFilesToArchive(files, volumeSize = DEFAULT_VOLUME_SIZE) { 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) {
|
|
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
|
|
return new Promise((resolve, reject) => {
|
|
inflate(compressed, async (err, decompressed) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
// Verify hash if provided
|
|
if (originalHash) {
|
|
try {
|
|
const calculatedHash = await calculateHash(decompressed);
|
|
if (calculatedHash !== originalHash) {
|
|
reject(new Error('File integrity check failed: hash mismatch'));
|
|
return;
|
|
}
|
|
} catch (hashError) {
|
|
reject(new Error('File integrity check failed: ' + hashError.message));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Create blob and download
|
|
const blob = new Blob([decompressed]);
|
|
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);
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
} catch (error) {
|
|
console.error('Decompression error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// 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({
|
|
inputSelector,
|
|
attachBtnSelector,
|
|
fileInputSelector,
|
|
onSend
|
|
}) {
|
|
const attachBtn = document.querySelector(attachBtnSelector);
|
|
|
|
if (attachBtn) {
|
|
// 点击附件按钮显示文件上传模态框
|
|
// Click attach button to show file upload modal
|
|
attachBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
showFileUploadModal(async (files) => {
|
|
// 传递 userName 给 onSend
|
|
const userName = window.roomsData && window.activeRoomIndex >= 0
|
|
? (window.roomsData[window.activeRoomIndex]?.myUserName || '')
|
|
: '';
|
|
await handleFilesUpload(files, (msg) => {
|
|
// 合并 userName 字段
|
|
onSend({ ...msg, userName });
|
|
});
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// 删除系统提示
|
|
}
|
|
|
|
function updateProgress(message) {
|
|
// 删除系统提示
|
|
}
|
|
|
|
if (files.length === 1) {
|
|
// Single file upload
|
|
const file = files[0];
|
|
showProgress();
|
|
|
|
const { volumes, originalSize, compressedSize, originalHash } = await compressFileToVolumes(file);
|
|
|
|
updateProgress();
|
|
|
|
// 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();
|
|
|
|
const { volumes, originalSize, compressedSize, archiveHash, fileCount, fileManifest } = await compressFilesToArchive(files);
|
|
|
|
updateProgress();
|
|
|
|
// 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) {
|
|
const transfer = window.fileTransfers.get(fileId);
|
|
if (!transfer) return;
|
|
const elements = document.querySelectorAll(`[data-file-id="${fileId}"]`);
|
|
elements.forEach(element => {
|
|
const progressContainer = element.querySelector('.file-progress-container');
|
|
const progressBar = element.querySelector('.file-progress');
|
|
const statusText = element.querySelector('.file-status');
|
|
const downloadBtn = element.querySelector('.file-download-btn');
|
|
|
|
// 判断是否为发送方(发送方没有volumeData)
|
|
const isSender = !transfer.volumeData || transfer.volumeData.length === 0;
|
|
|
|
if (transfer.status === 'sending') {
|
|
const progress = (transfer.sentVolumes / transfer.totalVolumes) * 100;
|
|
if (progressContainer) {
|
|
progressContainer.style.display = 'block';
|
|
progressContainer.classList.remove('fade-out');
|
|
}
|
|
if (progressBar) progressBar.style.width = `${progress}%`;
|
|
if (statusText) statusText.textContent = `Sending ${transfer.sentVolumes}/${transfer.totalVolumes}`;
|
|
if (downloadBtn) {
|
|
downloadBtn.classList.remove('show', 'animate-in');
|
|
downloadBtn.style.display = 'none';
|
|
}
|
|
} else if (transfer.status === 'receiving') {
|
|
const progress = (transfer.receivedVolumes.size / transfer.totalVolumes) * 100;
|
|
if (progressContainer) {
|
|
progressContainer.style.display = 'block';
|
|
progressContainer.classList.remove('fade-out');
|
|
}
|
|
if (progressBar) progressBar.style.width = `${progress}%`;
|
|
if (statusText) statusText.textContent = `Receiving ${transfer.receivedVolumes.size}/${transfer.totalVolumes}`;
|
|
if (downloadBtn) {
|
|
downloadBtn.classList.remove('show', 'animate-in');
|
|
downloadBtn.style.display = 'none';
|
|
}
|
|
} else if (transfer.status === 'completed') {
|
|
// 传输完成时的动画序列
|
|
if (progressContainer) {
|
|
// 先添加淡出动画类
|
|
progressContainer.classList.add('fade-out');
|
|
// 延迟后完全隐藏
|
|
setTimeout(() => {
|
|
progressContainer.style.display = 'none';
|
|
}, 400);
|
|
}
|
|
|
|
if (downloadBtn) {
|
|
// 只有接收方才显示下载按钮
|
|
if (isSender) {
|
|
downloadBtn.classList.remove('show', 'animate-in');
|
|
downloadBtn.style.display = 'none';
|
|
} else {
|
|
// 延迟显示下载按钮,等进度条消失动画完成
|
|
setTimeout(() => {
|
|
downloadBtn.style.display = 'flex';
|
|
downloadBtn.classList.add('show');
|
|
downloadBtn.disabled = false;
|
|
// 添加进入动画
|
|
setTimeout(() => {
|
|
downloadBtn.classList.add('animate-in');
|
|
}, 50);
|
|
// 清理动画类
|
|
setTimeout(() => {
|
|
downloadBtn.classList.remove('animate-in');
|
|
}, 550);
|
|
}, 200);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Handle incoming file messages
|
|
// 处理接收到的文件消息
|
|
export function handleFileMessage(message, isPrivate = false) {
|
|
const { type, fileId, userName } = message;
|
|
|
|
switch (type) {
|
|
case 'file_start':
|
|
handleFileStart(message, isPrivate);
|
|
break;
|
|
case 'file_volume':
|
|
handleFileVolume(message);
|
|
break;
|
|
case 'file_complete':
|
|
handleFileComplete(message);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Handle file start message
|
|
// 处理文件开始消息
|
|
function handleFileStart(message, isPrivate) {
|
|
const { fileId, fileName, originalSize, compressedSize, totalVolumes, originalHash, archiveHash, fileCount, fileManifest, isArchive, userName } = message;
|
|
|
|
const fileTransfer = {
|
|
fileId,
|
|
fileName,
|
|
originalSize,
|
|
compressedSize,
|
|
totalVolumes,
|
|
receivedVolumes: new Set(),
|
|
volumeData: new Array(totalVolumes),
|
|
status: 'receiving',
|
|
originalHash,
|
|
archiveHash,
|
|
fileCount,
|
|
fileManifest,
|
|
isArchive,
|
|
userName // 记录发送者名字
|
|
};
|
|
|
|
window.fileTransfers.set(fileId, fileTransfer);
|
|
|
|
// 添加文件消息到聊天
|
|
if (window.addOtherMsg) {
|
|
let displayData;
|
|
if (isArchive) {
|
|
displayData = {
|
|
type: 'file',
|
|
fileId,
|
|
fileName: `${fileCount} files`,
|
|
originalSize,
|
|
totalVolumes,
|
|
fileCount,
|
|
isArchive: true,
|
|
userName
|
|
};
|
|
} else {
|
|
displayData = {
|
|
type: 'file',
|
|
fileId,
|
|
fileName,
|
|
originalSize,
|
|
totalVolumes,
|
|
userName
|
|
};
|
|
}
|
|
|
|
window.addOtherMsg(displayData, userName, userName, false, isPrivate ? 'file_private' : 'file');
|
|
}
|
|
}
|
|
|
|
// Handle file volume message
|
|
// 处理文件分卷消息
|
|
function handleFileVolume(message) {
|
|
const { fileId, volumeIndex, volumeData } = message;
|
|
const transfer = window.fileTransfers.get(fileId);
|
|
|
|
if (!transfer) return;
|
|
|
|
transfer.receivedVolumes.add(volumeIndex);
|
|
transfer.volumeData[volumeIndex] = volumeData;
|
|
|
|
updateFileProgress(fileId);
|
|
}
|
|
|
|
// Handle file complete message
|
|
// 处理文件完成消息
|
|
function handleFileComplete(message) {
|
|
const { fileId } = message;
|
|
const transfer = window.fileTransfers.get(fileId);
|
|
|
|
if (!transfer) return;
|
|
|
|
// 检查是否所有分卷都已接收
|
|
if (transfer.receivedVolumes.size === transfer.totalVolumes) {
|
|
transfer.status = 'completed';
|
|
updateFileProgress(fileId);
|
|
}
|
|
}
|
|
|
|
// Download file from volumes
|
|
// 从分卷下载文件
|
|
export async function downloadFile(fileId) {
|
|
const transfer = window.fileTransfers.get(fileId);
|
|
if (!transfer || transfer.status !== 'completed') return;
|
|
|
|
try {
|
|
if (transfer.isArchive) {
|
|
// Download archive as multiple files
|
|
await decompressArchiveToFiles(transfer.volumeData, transfer.fileManifest, transfer.archiveHash);
|
|
// 删除系统提示
|
|
} else {
|
|
// Download single file
|
|
await decompressVolumesToFile(transfer.volumeData, transfer.fileName, transfer.originalHash);
|
|
// 删除系统提示
|
|
}
|
|
} catch (error) {
|
|
console.error('Download error:', error);
|
|
window.addSystemMsg(`Failed to download: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Format file size
|
|
// 格式化文件大小
|
|
export function formatFileSize(bytes) {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
// Legacy image send function for backward compatibility
|
|
// 向后兼容的图片发送函数
|
|
export function setupImageSend(config) {
|
|
setupFileSend(config);
|
|
} |