mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-04 03:25:03 +08:00
feat: 注释掉静态文件服务和模板加载
This commit is contained in:
@@ -42,9 +42,9 @@ func main() {
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// 静态文件服务
|
||||
fileServer := http.FileServer(http.Dir("./web/static/"))
|
||||
r.Handle("/static/*", http.StripPrefix("/static", fileServer))
|
||||
// // 静态文件服务
|
||||
// fileServer := http.FileServer(http.Dir("./web/static/"))
|
||||
// r.Handle("/static/*", http.StripPrefix("/static", fileServer))
|
||||
|
||||
// 路由定义
|
||||
r.Get("/", h.IndexHandler)
|
||||
|
||||
@@ -22,7 +22,7 @@ func NewHandler(p2pService *services.P2PService) *Handler {
|
||||
}
|
||||
|
||||
// 加载模板
|
||||
h.loadTemplates()
|
||||
// h.loadTemplates()
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,393 +0,0 @@
|
||||
// 通用JavaScript工具函数
|
||||
|
||||
// 工具函数
|
||||
const Utils = {
|
||||
// 格式化文件大小
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
// 格式化时间
|
||||
formatTime(date) {
|
||||
return new Date(date).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
// 生成随机字符串
|
||||
randomString(length) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// 复制到剪贴板
|
||||
async copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} else {
|
||||
// 兼容性处理
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const result = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取文件类型图标
|
||||
getFileIcon(fileName, fileType) {
|
||||
const ext = fileName.split('.').pop().toLowerCase();
|
||||
|
||||
// 根据MIME类型
|
||||
if (fileType) {
|
||||
if (fileType.startsWith('image/')) return '🖼️';
|
||||
if (fileType.startsWith('video/')) return '🎥';
|
||||
if (fileType.startsWith('audio/')) return '🎵';
|
||||
if (fileType.includes('pdf')) return '📄';
|
||||
if (fileType.includes('text')) return '📝';
|
||||
if (fileType.includes('zip') || fileType.includes('rar')) return '📦';
|
||||
}
|
||||
|
||||
// 根据文件扩展名
|
||||
switch (ext) {
|
||||
case 'pdf': return '📄';
|
||||
case 'doc':
|
||||
case 'docx': return '📝';
|
||||
case 'xls':
|
||||
case 'xlsx': return '📊';
|
||||
case 'ppt':
|
||||
case 'pptx': return '📈';
|
||||
case 'txt': return '📄';
|
||||
case 'epub':
|
||||
case 'mobi': return '📚';
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z': return '📦';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'bmp': return '🖼️';
|
||||
case 'mp4':
|
||||
case 'avi':
|
||||
case 'mov':
|
||||
case 'wmv': return '🎥';
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'flac':
|
||||
case 'aac': return '🎵';
|
||||
case 'js':
|
||||
case 'html':
|
||||
case 'css':
|
||||
case 'py':
|
||||
case 'java':
|
||||
case 'cpp': return '💻';
|
||||
default: return '📁';
|
||||
}
|
||||
},
|
||||
|
||||
// 验证取件码格式
|
||||
validateCode(code) {
|
||||
return /^[A-Z0-9]{6}$/.test(code);
|
||||
},
|
||||
|
||||
// 获取浏览器信息
|
||||
getBrowserInfo() {
|
||||
const ua = navigator.userAgent;
|
||||
let browser = 'Unknown';
|
||||
let version = 'Unknown';
|
||||
|
||||
if (ua.indexOf('Chrome') > -1) {
|
||||
browser = 'Chrome';
|
||||
version = ua.match(/Chrome\/(\d+)/)[1];
|
||||
} else if (ua.indexOf('Firefox') > -1) {
|
||||
browser = 'Firefox';
|
||||
version = ua.match(/Firefox\/(\d+)/)[1];
|
||||
} else if (ua.indexOf('Safari') > -1 && ua.indexOf('Chrome') === -1) {
|
||||
browser = 'Safari';
|
||||
version = ua.match(/Version\/(\d+)/)[1];
|
||||
} else if (ua.indexOf('Edge') > -1) {
|
||||
browser = 'Edge';
|
||||
version = ua.match(/Edge\/(\d+)/)[1];
|
||||
} else if (ua.indexOf('360SE') > -1) {
|
||||
browser = '360浏览器';
|
||||
} else if (ua.indexOf('QQBrowser') > -1) {
|
||||
browser = 'QQ浏览器';
|
||||
version = ua.match(/QQBrowser\/(\d+)/)[1];
|
||||
}
|
||||
|
||||
return { browser, version };
|
||||
},
|
||||
|
||||
// 检查WebRTC支持
|
||||
checkWebRTCSupport() {
|
||||
return !!(window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection);
|
||||
},
|
||||
|
||||
// 检查文件API支持
|
||||
checkFileAPISupport() {
|
||||
return !!(window.File && window.FileReader && window.FileList && window.Blob);
|
||||
},
|
||||
|
||||
// 节流函数
|
||||
throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 防抖函数
|
||||
debounce(func, delay) {
|
||||
let timeoutId;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func.apply(context, args), delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 通知系统
|
||||
const Notification = {
|
||||
// 显示成功消息
|
||||
success(message, duration = 3000) {
|
||||
this.show(message, 'success', duration);
|
||||
},
|
||||
|
||||
// 显示错误消息
|
||||
error(message, duration = 5000) {
|
||||
this.show(message, 'error', duration);
|
||||
},
|
||||
|
||||
// 显示警告消息
|
||||
warning(message, duration = 4000) {
|
||||
this.show(message, 'warning', duration);
|
||||
},
|
||||
|
||||
// 显示信息消息
|
||||
info(message, duration = 3000) {
|
||||
this.show(message, 'info', duration);
|
||||
},
|
||||
|
||||
// 显示通知
|
||||
show(message, type = 'info', duration = 3000) {
|
||||
// 创建通知容器(如果不存在)
|
||||
let container = document.getElementById('notification-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'notification-container';
|
||||
container.className = 'fixed top-4 right-4 z-50 space-y-2';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// 创建通知元素
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto flex ring-1 ring-black ring-opacity-5 fade-in`;
|
||||
|
||||
const bgColor = {
|
||||
success: 'bg-green-50 border-green-200',
|
||||
error: 'bg-red-50 border-red-200',
|
||||
warning: 'bg-yellow-50 border-yellow-200',
|
||||
info: 'bg-blue-50 border-blue-200'
|
||||
}[type] || 'bg-gray-50 border-gray-200';
|
||||
|
||||
const iconEmoji = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
}[type] || 'ℹ️';
|
||||
|
||||
notification.innerHTML = `
|
||||
<div class="flex-1 w-0 p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-xl">${iconEmoji}</span>
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p class="text-sm font-medium text-gray-900">${message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex border-l border-gray-200">
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="w-full border border-transparent rounded-none rounded-r-lg p-4 flex items-center justify-center text-sm font-medium text-gray-600 hover:text-gray-500 focus:outline-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
notification.className += ` ${bgColor}`;
|
||||
container.appendChild(notification);
|
||||
|
||||
// 自动移除
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 加载管理器
|
||||
const Loading = {
|
||||
show(message = '加载中...') {
|
||||
this.hide(); // 先隐藏现有的加载提示
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'loading-overlay';
|
||||
overlay.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 flex items-center space-x-3">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span class="text-gray-700">${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
},
|
||||
|
||||
hide() {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
if (overlay) {
|
||||
overlay.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// API请求工具
|
||||
const API = {
|
||||
async request(url, options = {}) {
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const config = { ...defaultOptions, ...options };
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else {
|
||||
return await response.text();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API请求失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async get(url, params = {}) {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
Object.keys(params).forEach(key =>
|
||||
urlObj.searchParams.append(key, params[key])
|
||||
);
|
||||
|
||||
return this.request(urlObj.toString());
|
||||
},
|
||||
|
||||
async post(url, data = {}) {
|
||||
return this.request(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(url) {
|
||||
return this.request(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 页面加载完成后执行
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 检查浏览器兼容性
|
||||
const browserInfo = Utils.getBrowserInfo();
|
||||
console.log(`浏览器: ${browserInfo.browser} ${browserInfo.version}`);
|
||||
|
||||
// 检查功能支持
|
||||
if (!Utils.checkFileAPISupport()) {
|
||||
Notification.warning('您的浏览器不完全支持文件API,部分功能可能受限');
|
||||
}
|
||||
|
||||
if (!Utils.checkWebRTCSupport()) {
|
||||
console.warn('浏览器不支持WebRTC,视频功能不可用');
|
||||
}
|
||||
|
||||
// 添加全局错误处理
|
||||
window.addEventListener('error', function(event) {
|
||||
console.error('全局错误:', event.error);
|
||||
Notification.error('页面发生错误,请刷新后重试');
|
||||
});
|
||||
|
||||
// 添加网络状态监听
|
||||
window.addEventListener('online', function() {
|
||||
Notification.success('网络连接已恢复');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', function() {
|
||||
Notification.warning('网络连接已断开,请检查网络设置');
|
||||
});
|
||||
|
||||
// 添加页面可见性变化监听
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) {
|
||||
console.log('页面已隐藏');
|
||||
} else {
|
||||
console.log('页面已显示');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 导出全局对象
|
||||
window.Utils = Utils;
|
||||
window.Notification = Notification;
|
||||
window.Loading = Loading;
|
||||
window.API = API;
|
||||
@@ -1,373 +0,0 @@
|
||||
// 文件传输相关功能
|
||||
|
||||
// 设置数据通道
|
||||
function setupDataChannel(channel) {
|
||||
dataChannel = channel;
|
||||
let pendingChunkMeta = null;
|
||||
|
||||
channel.onopen = () => {
|
||||
console.log('数据通道已打开');
|
||||
isP2PConnected = true;
|
||||
updateP2PStatus(true);
|
||||
|
||||
// 清除连接超时定时器
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
// 检查是否是二进制数据
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
// 处理二进制数据块
|
||||
if (pendingChunkMeta && currentRole === 'receiver') {
|
||||
receiveFileChunk(pendingChunkMeta, event.data);
|
||||
pendingChunkMeta = null;
|
||||
}
|
||||
} else {
|
||||
// 处理JSON消息
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'file-chunk-meta') {
|
||||
pendingChunkMeta = message;
|
||||
} else {
|
||||
handleDataChannelMessage(event.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析数据通道消息失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
channel.onerror = (error) => {
|
||||
console.error('数据通道错误:', error);
|
||||
isP2PConnected = false;
|
||||
updateP2PStatus(false);
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
console.log('数据通道已关闭');
|
||||
isP2PConnected = false;
|
||||
updateP2PStatus(false);
|
||||
};
|
||||
}
|
||||
|
||||
// 更新P2P连接状态
|
||||
function updateP2PStatus(connected) {
|
||||
const receiverStatus = document.getElementById('receiverStatus');
|
||||
const downloadButtons = document.querySelectorAll('button[onclick^="downloadFile"]');
|
||||
|
||||
if (currentRole === 'receiver' && receiverStatus) {
|
||||
if (connected) {
|
||||
receiverStatus.innerHTML = `
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
P2P连接已建立,可以下载文件
|
||||
</div>`;
|
||||
|
||||
// 启用下载按钮
|
||||
downloadButtons.forEach(btn => {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
btn.classList.add('hover:bg-blue-600');
|
||||
});
|
||||
} else {
|
||||
receiverStatus.innerHTML = `
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full bg-yellow-100 text-yellow-800">
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full mr-2"></span>
|
||||
正在建立P2P连接...
|
||||
</div>`;
|
||||
|
||||
// 禁用下载按钮
|
||||
downloadButtons.forEach(btn => {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
btn.classList.remove('hover:bg-blue-600');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
function downloadFile(fileId) {
|
||||
if (!isP2PConnected || !dataChannel || dataChannel.readyState !== 'open') {
|
||||
alert('P2P连接未建立,请等待连接建立后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送文件请求
|
||||
const request = {
|
||||
type: 'file-request',
|
||||
fileId: fileId
|
||||
};
|
||||
|
||||
dataChannel.send(JSON.stringify(request));
|
||||
showTransferProgress(fileId, 'downloading');
|
||||
}
|
||||
|
||||
// 处理数据通道消息
|
||||
function handleDataChannelMessage(data) {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
|
||||
switch (message.type) {
|
||||
case 'file-request':
|
||||
if (currentRole === 'sender') {
|
||||
sendFileData(message.fileId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-info':
|
||||
if (currentRole === 'receiver') {
|
||||
// 存储文件信息用于下载
|
||||
if (!fileTransfers.has(message.fileId)) {
|
||||
fileTransfers.set(message.fileId, {
|
||||
chunks: [],
|
||||
totalSize: message.size,
|
||||
receivedSize: 0,
|
||||
fileName: message.name,
|
||||
mimeType: message.mimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-data':
|
||||
// 旧的file-data类型已被file-chunk-meta + 二进制数据替代
|
||||
// 这里保留是为了向后兼容
|
||||
if (currentRole === 'receiver') {
|
||||
receiveFileDataLegacy(message);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-complete':
|
||||
if (currentRole === 'receiver') {
|
||||
completeFileDownload(message.fileId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理数据通道消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送文件数据
|
||||
function sendFileData(fileId) {
|
||||
const fileIndex = parseInt(fileId.split('_')[1]);
|
||||
const file = selectedFiles[fileIndex];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// 首先发送文件元信息
|
||||
const fileInfo = {
|
||||
type: 'file-info',
|
||||
fileId: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
lastModified: file.lastModified
|
||||
};
|
||||
dataChannel.send(JSON.stringify(fileInfo));
|
||||
|
||||
const reader = new FileReader();
|
||||
const chunkSize = 65536; // 增加到64KB chunks以提高速度
|
||||
let offset = 0;
|
||||
|
||||
const sendChunk = () => {
|
||||
const slice = file.slice(offset, offset + chunkSize);
|
||||
reader.readAsArrayBuffer(slice);
|
||||
};
|
||||
|
||||
reader.onload = (e) => {
|
||||
const chunk = e.target.result;
|
||||
|
||||
// 使用更高效的方式传输二进制数据
|
||||
if (dataChannel.readyState === 'open') {
|
||||
// 先发送元数据
|
||||
const metadata = {
|
||||
type: 'file-chunk-meta',
|
||||
fileId: fileId,
|
||||
offset: offset,
|
||||
size: chunk.byteLength,
|
||||
total: file.size,
|
||||
isLast: offset + chunk.byteLength >= file.size
|
||||
};
|
||||
dataChannel.send(JSON.stringify(metadata));
|
||||
|
||||
// 再发送二进制数据
|
||||
dataChannel.send(chunk);
|
||||
}
|
||||
|
||||
offset += chunk.byteLength;
|
||||
|
||||
if (offset < file.size) {
|
||||
// 减少延迟以提高传输速度
|
||||
setTimeout(sendChunk, 1);
|
||||
} else {
|
||||
dataChannel.send(JSON.stringify({
|
||||
type: 'file-complete',
|
||||
fileId: fileId
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
sendChunk();
|
||||
}
|
||||
|
||||
// 接收文件块(二进制数据)
|
||||
function receiveFileChunk(meta, chunkData) {
|
||||
if (!fileTransfers.has(meta.fileId)) {
|
||||
// 如果没有文件信息,创建默认的
|
||||
fileTransfers.set(meta.fileId, {
|
||||
chunks: [],
|
||||
totalSize: meta.total,
|
||||
receivedSize: 0,
|
||||
fileName: `unknown_file_${meta.fileId}`,
|
||||
mimeType: 'application/octet-stream'
|
||||
});
|
||||
}
|
||||
|
||||
const transfer = fileTransfers.get(meta.fileId);
|
||||
transfer.chunks.push(new Uint8Array(chunkData));
|
||||
transfer.receivedSize += chunkData.byteLength;
|
||||
|
||||
// 更新总大小(以防文件信息还没收到)
|
||||
if (transfer.totalSize !== meta.total) {
|
||||
transfer.totalSize = meta.total;
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
updateTransferProgress(meta.fileId, transfer.receivedSize, transfer.totalSize);
|
||||
|
||||
if (meta.isLast) {
|
||||
completeFileDownload(meta.fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// 接收文件数据(向后兼容的旧版本)
|
||||
function receiveFileDataLegacy(message) {
|
||||
if (!fileTransfers.has(message.fileId)) {
|
||||
// 如果没有文件信息,创建默认的
|
||||
fileTransfers.set(message.fileId, {
|
||||
chunks: [],
|
||||
totalSize: message.total,
|
||||
receivedSize: 0,
|
||||
fileName: `unknown_file_${message.fileId}`,
|
||||
mimeType: 'application/octet-stream'
|
||||
});
|
||||
}
|
||||
|
||||
const transfer = fileTransfers.get(message.fileId);
|
||||
transfer.chunks.push(new Uint8Array(message.chunk));
|
||||
transfer.receivedSize += message.chunk.length;
|
||||
|
||||
// 更新总大小(以防文件信息还没收到)
|
||||
if (transfer.totalSize !== message.total) {
|
||||
transfer.totalSize = message.total;
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
updateTransferProgress(message.fileId, transfer.receivedSize, transfer.totalSize);
|
||||
|
||||
if (message.isLast) {
|
||||
completeFileDownload(message.fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// 完成文件下载
|
||||
function completeFileDownload(fileId) {
|
||||
const transfer = fileTransfers.get(fileId);
|
||||
if (!transfer) return;
|
||||
|
||||
// 合并所有chunks,使用正确的MIME类型
|
||||
const blob = new Blob(transfer.chunks, { type: transfer.mimeType });
|
||||
|
||||
// 使用正确的文件名
|
||||
const fileName = transfer.fileName || `downloaded_file_${fileId}`;
|
||||
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log(`文件下载完成: ${fileName}, 大小: ${formatFileSize(transfer.totalSize)}`);
|
||||
|
||||
// 清理
|
||||
fileTransfers.delete(fileId);
|
||||
hideTransferProgress(fileId);
|
||||
}
|
||||
|
||||
// 显示传输进度
|
||||
function showTransferProgress(fileId, type) {
|
||||
const progressContainer = document.getElementById('transferProgress');
|
||||
const progressList = document.getElementById('progressList');
|
||||
|
||||
progressContainer.classList.remove('hidden');
|
||||
|
||||
// 获取文件名
|
||||
let fileName = fileId;
|
||||
if (currentRole === 'receiver') {
|
||||
// 从接收方文件列表中获取文件名
|
||||
const fileIndex = parseInt(fileId.split('_')[1]);
|
||||
const receiverFilesList = document.getElementById('receiverFilesList');
|
||||
const fileItems = receiverFilesList.querySelectorAll('.font-medium');
|
||||
if (fileItems[fileIndex]) {
|
||||
fileName = fileItems[fileIndex].textContent;
|
||||
}
|
||||
} else if (currentRole === 'sender') {
|
||||
// 从发送方文件列表中获取文件名
|
||||
const fileIndex = parseInt(fileId.split('_')[1]);
|
||||
if (selectedFiles[fileIndex]) {
|
||||
fileName = selectedFiles[fileIndex].name;
|
||||
}
|
||||
}
|
||||
|
||||
const progressItem = document.createElement('div');
|
||||
progressItem.id = `progress_${fileId}`;
|
||||
progressItem.className = 'bg-gray-50 p-3 rounded-lg';
|
||||
progressItem.innerHTML = `
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium">${type === 'downloading' ? '📥 下载' : '📤 上传'}: ${fileName}</span>
|
||||
<span class="text-sm text-gray-500">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
progressList.appendChild(progressItem);
|
||||
}
|
||||
|
||||
// 更新传输进度
|
||||
function updateTransferProgress(fileId, received, total) {
|
||||
const progressItem = document.getElementById(`progress_${fileId}`);
|
||||
if (!progressItem) return;
|
||||
|
||||
const percentage = Math.round((received / total) * 100);
|
||||
const progressBar = progressItem.querySelector('.bg-blue-600');
|
||||
const percentageText = progressItem.querySelector('.text-gray-500');
|
||||
|
||||
progressBar.style.width = percentage + '%';
|
||||
percentageText.textContent = percentage + '%';
|
||||
}
|
||||
|
||||
// 隐藏传输进度
|
||||
function hideTransferProgress(fileId) {
|
||||
const progressItem = document.getElementById(`progress_${fileId}`);
|
||||
if (progressItem) {
|
||||
progressItem.remove();
|
||||
}
|
||||
|
||||
// 如果没有进度项了,隐藏整个进度容器
|
||||
const progressList = document.getElementById('progressList');
|
||||
if (progressList.children.length === 0) {
|
||||
document.getElementById('transferProgress').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,902 +0,0 @@
|
||||
// WebSocket和WebRTC连接管理
|
||||
|
||||
// 全局变量
|
||||
let clientConnections = new Map(); // 存储与其他客户端的P2P连接
|
||||
let currentClientId = ''; // 当前客户端ID
|
||||
|
||||
// WebSocket连接
|
||||
function connectWebSocket() {
|
||||
console.log('尝试连接WebSocket, 角色:', currentRole, '取件码:', currentPickupCode);
|
||||
|
||||
if (!currentPickupCode || !currentRole) {
|
||||
console.error('缺少必要参数:取件码或角色');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
console.log('已在连接中,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
isConnecting = true;
|
||||
|
||||
// 如果已经有连接,先关闭
|
||||
if (websocket) {
|
||||
console.log('关闭现有WebSocket连接');
|
||||
websocket.close();
|
||||
websocket = null;
|
||||
}
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws/p2p?code=${currentPickupCode}&role=${currentRole}`;
|
||||
console.log('WebSocket URL:', wsUrl);
|
||||
|
||||
try {
|
||||
websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket连接已建立');
|
||||
isConnecting = false;
|
||||
updateConnectionStatus(true);
|
||||
|
||||
// 连接建立后,启用P2P功能
|
||||
if (currentRole === 'receiver') {
|
||||
updateP2PStatus(true); // 接收方连接成功后立即启用下载
|
||||
}
|
||||
|
||||
// 发送方在WebSocket连接建立后初始化(等待接收方连接)
|
||||
if (currentRole === 'sender') {
|
||||
console.log('发送方初始化完成,等待接收方连接');
|
||||
showRoomStatus();
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('收到WebSocket消息:', message);
|
||||
await handleWebSocketMessage(message);
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
isConnecting = false;
|
||||
updateConnectionStatus(false);
|
||||
updateP2PStatus(false);
|
||||
showNotification('WebSocket连接失败,请检查网络连接', 'error');
|
||||
};
|
||||
|
||||
websocket.onclose = (event) => {
|
||||
console.log('WebSocket连接已关闭, 代码:', event.code, '原因:', event.reason);
|
||||
isConnecting = false;
|
||||
updateConnectionStatus(false);
|
||||
updateP2PStatus(false);
|
||||
websocket = null;
|
||||
|
||||
// 清理所有P2P连接
|
||||
clientConnections.forEach((conn, clientId) => {
|
||||
if (conn.peerConnection) {
|
||||
conn.peerConnection.close();
|
||||
}
|
||||
});
|
||||
clientConnections.clear();
|
||||
|
||||
// 如果不是正常关闭且还需要连接,尝试重连
|
||||
if (event.code !== 1000 && currentPickupCode && !isConnecting) {
|
||||
console.log('WebSocket异常关闭,5秒后尝试重连');
|
||||
showNotification('连接断开,5秒后自动重连...', 'info');
|
||||
setTimeout(() => {
|
||||
if (currentPickupCode && !websocket && !isConnecting) {
|
||||
console.log('尝试重新连接WebSocket');
|
||||
connectWebSocket();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置连接超时
|
||||
setTimeout(() => {
|
||||
if (websocket && websocket.readyState === WebSocket.CONNECTING) {
|
||||
console.log('WebSocket连接超时');
|
||||
websocket.close();
|
||||
showNotification('连接超时,请重试', 'error');
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建WebSocket连接失败:', error);
|
||||
isConnecting = false;
|
||||
showNotification('无法创建WebSocket连接', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 处理WebSocket消息
|
||||
async function handleWebSocketMessage(message) {
|
||||
console.log('处理WebSocket消息:', message.type, message);
|
||||
|
||||
switch (message.type) {
|
||||
case 'file-list':
|
||||
// 接收到文件列表
|
||||
if (currentRole === 'receiver') {
|
||||
displayReceiverFiles(message.payload.files);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'room-status':
|
||||
// 房间状态更新
|
||||
updateRoomStatus(message.payload);
|
||||
break;
|
||||
|
||||
case 'new-receiver':
|
||||
// 新接收方加入
|
||||
if (currentRole === 'sender') {
|
||||
console.log('新接收方加入:', message.payload.client_id);
|
||||
// 发送方可以准备为新接收方创建P2P连接
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new-sender':
|
||||
// 新发送方加入
|
||||
if (currentRole === 'receiver') {
|
||||
console.log('新发送方加入:', message.payload.client_id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'client-left':
|
||||
// 客户端离开
|
||||
console.log('客户端离开:', message.payload.client_id, message.payload.role);
|
||||
// 清理对应的P2P连接
|
||||
if (clientConnections.has(message.payload.client_id)) {
|
||||
const conn = clientConnections.get(message.payload.client_id);
|
||||
if (conn.peerConnection) {
|
||||
conn.peerConnection.close();
|
||||
}
|
||||
clientConnections.delete(message.payload.client_id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-request':
|
||||
// 文件请求
|
||||
if (currentRole === 'sender') {
|
||||
await handleFileRequest(message.payload);
|
||||
}
|
||||
break;
|
||||
|
||||
// WebRTC信令消息
|
||||
case 'offer':
|
||||
await handleOffer(message.payload);
|
||||
break;
|
||||
case 'answer':
|
||||
await handleAnswer(message.payload);
|
||||
break;
|
||||
case 'ice-candidate':
|
||||
await handleIceCandidate(message.payload);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('未知消息类型:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新房间状态显示
|
||||
function updateRoomStatus(status) {
|
||||
console.log('更新房间状态:', status);
|
||||
|
||||
const totalClients = status.sender_count + status.receiver_count;
|
||||
|
||||
// 更新发送方界面的房间状态
|
||||
if (currentRole === 'sender') {
|
||||
const onlineCountEl = document.getElementById('onlineCount');
|
||||
const senderCountEl = document.getElementById('senderCount');
|
||||
const receiverCountEl = document.getElementById('receiverCount');
|
||||
|
||||
if (onlineCountEl) onlineCountEl.textContent = totalClients;
|
||||
if (senderCountEl) senderCountEl.textContent = status.sender_count;
|
||||
if (receiverCountEl) receiverCountEl.textContent = status.receiver_count;
|
||||
|
||||
const clientsList = document.getElementById('clientsList');
|
||||
if (clientsList) {
|
||||
clientsList.innerHTML = '';
|
||||
|
||||
status.clients.forEach(client => {
|
||||
if (client.id !== currentClientId) { // 不显示自己
|
||||
const clientDiv = document.createElement('div');
|
||||
clientDiv.className = 'text-xs text-blue-600';
|
||||
const role = client.role === 'sender' ? '📤 发送' : '📥 接收';
|
||||
const joinTime = new Date(client.joined_at).toLocaleTimeString();
|
||||
clientDiv.textContent = `${role} - ${joinTime}`;
|
||||
clientsList.appendChild(clientDiv);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 显示房间状态区域
|
||||
const roomStatusSection = document.getElementById('roomStatusSection');
|
||||
if (roomStatusSection) {
|
||||
roomStatusSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新接收方界面的房间状态
|
||||
if (currentRole === 'receiver') {
|
||||
const receiverOnlineCountEl = document.getElementById('receiverOnlineCount');
|
||||
const receiverSenderCountEl = document.getElementById('receiverSenderCount');
|
||||
const receiverReceiverCountEl = document.getElementById('receiverReceiverCount');
|
||||
|
||||
if (receiverOnlineCountEl) receiverOnlineCountEl.textContent = totalClients;
|
||||
if (receiverSenderCountEl) receiverSenderCountEl.textContent = status.sender_count;
|
||||
if (receiverReceiverCountEl) receiverReceiverCountEl.textContent = status.receiver_count;
|
||||
|
||||
const clientsList = document.getElementById('receiverClientsList');
|
||||
if (clientsList) {
|
||||
clientsList.innerHTML = '';
|
||||
|
||||
status.clients.forEach(client => {
|
||||
if (client.id !== currentClientId) { // 不显示自己
|
||||
const clientDiv = document.createElement('div');
|
||||
clientDiv.className = 'text-xs text-blue-600';
|
||||
const role = client.role === 'sender' ? '📤 发送' : '📥 接收';
|
||||
const joinTime = new Date(client.joined_at).toLocaleTimeString();
|
||||
clientDiv.textContent = `${role} - ${joinTime}`;
|
||||
clientsList.appendChild(clientDiv);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 显示房间状态区域
|
||||
function showRoomStatus() {
|
||||
if (currentRole === 'sender') {
|
||||
document.getElementById('roomStatusSection').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新连接状态
|
||||
function updateConnectionStatus(connected) {
|
||||
const senderStatus = document.getElementById('senderStatus');
|
||||
const receiverStatus = document.getElementById('receiverStatus');
|
||||
|
||||
if (currentRole === 'sender' && senderStatus) {
|
||||
senderStatus.innerHTML = connected ?
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
WebSocket已连接
|
||||
</div>` :
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-red-100 text-red-800">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||
连接断开
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (currentRole === 'receiver' && receiverStatus) {
|
||||
receiverStatus.innerHTML = connected ?
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
已连接,可以下载文件
|
||||
</div>` :
|
||||
`<div class="inline-flex items-center px-3 py-1 rounded-full bg-red-100 text-red-800">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||
连接断开
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件请求
|
||||
async function handleFileRequest(payload) {
|
||||
console.log('处理文件请求:', payload);
|
||||
|
||||
const fileId = payload.file_id;
|
||||
const requesterId = payload.requester;
|
||||
const requestId = payload.request_id;
|
||||
|
||||
// 找到对应的文件
|
||||
const file = selectedFiles.find(f => f.id === fileId || selectedFiles.indexOf(f).toString() === fileId);
|
||||
if (!file) {
|
||||
console.error('未找到请求的文件:', fileId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建或获取与请求者的P2P连接
|
||||
let connection = clientConnections.get(requesterId);
|
||||
if (!connection) {
|
||||
connection = await createPeerConnection(requesterId);
|
||||
clientConnections.set(requesterId, connection);
|
||||
}
|
||||
|
||||
// 发送文件
|
||||
if (connection.dataChannel && connection.dataChannel.readyState === 'open') {
|
||||
await sendFileToClient(file, connection.dataChannel, requestId);
|
||||
} else {
|
||||
console.log('等待数据通道建立...');
|
||||
connection.pendingFiles = connection.pendingFiles || [];
|
||||
connection.pendingFiles.push({ file, requestId });
|
||||
}
|
||||
}
|
||||
|
||||
// 创建P2P连接
|
||||
async function createPeerConnection(targetClientId) {
|
||||
console.log('创建P2P连接到:', targetClientId);
|
||||
|
||||
const connection = {
|
||||
peerConnection: null,
|
||||
dataChannel: null,
|
||||
pendingFiles: []
|
||||
};
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
});
|
||||
|
||||
connection.peerConnection = pc;
|
||||
|
||||
// 创建数据通道(发送方)
|
||||
if (currentRole === 'sender') {
|
||||
const dataChannel = pc.createDataChannel('fileTransfer', {
|
||||
ordered: true
|
||||
});
|
||||
|
||||
connection.dataChannel = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('数据通道已打开,可以传输文件');
|
||||
// 发送待发送的文件
|
||||
if (connection.pendingFiles && connection.pendingFiles.length > 0) {
|
||||
connection.pendingFiles.forEach(({ file, requestId }) => {
|
||||
sendFileToClient(file, dataChannel, requestId);
|
||||
});
|
||||
connection.pendingFiles = [];
|
||||
}
|
||||
};
|
||||
|
||||
dataChannel.onmessage = (event) => {
|
||||
console.log('数据通道收到消息:', event.data);
|
||||
};
|
||||
}
|
||||
|
||||
// 处理数据通道(接收方)
|
||||
pc.ondatachannel = (event) => {
|
||||
const channel = event.channel;
|
||||
connection.dataChannel = channel;
|
||||
|
||||
channel.onopen = () => {
|
||||
console.log('接收方数据通道已打开');
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
handleFileData(event.data, targetClientId);
|
||||
};
|
||||
};
|
||||
|
||||
// ICE候选者
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
websocket.send(JSON.stringify({
|
||||
type: 'ice-candidate',
|
||||
payload: {
|
||||
candidate: event.candidate,
|
||||
target_client: targetClientId
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
// 处理WebRTC信令消息
|
||||
async function handleOffer(payload) {
|
||||
console.log('处理offer:', payload);
|
||||
// 实现WebRTC offer处理逻辑
|
||||
}
|
||||
|
||||
async function handleAnswer(payload) {
|
||||
console.log('处理answer:', payload);
|
||||
// 实现WebRTC answer处理逻辑
|
||||
}
|
||||
|
||||
// 发送文件给客户端
|
||||
async function sendFileToClient(file, dataChannel, requestId) {
|
||||
console.log('开始发送文件:', file.name, '到客户端');
|
||||
|
||||
// 发送文件信息
|
||||
const fileInfo = {
|
||||
type: 'file-info',
|
||||
file_id: requestId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime_type: file.type,
|
||||
last_modified: file.lastModified
|
||||
};
|
||||
|
||||
dataChannel.send(JSON.stringify(fileInfo));
|
||||
|
||||
// 分块发送文件
|
||||
const chunkSize = 65536; // 64KB chunks
|
||||
let offset = 0;
|
||||
|
||||
const sendChunk = () => {
|
||||
if (offset >= file.size) {
|
||||
// 发送完成消息
|
||||
const completeMsg = {
|
||||
type: 'file-complete',
|
||||
file_id: requestId
|
||||
};
|
||||
dataChannel.send(JSON.stringify(completeMsg));
|
||||
console.log('文件发送完成:', file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
const slice = file.slice(offset, offset + chunkSize);
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const chunk = e.target.result;
|
||||
|
||||
// 发送块元数据
|
||||
const metadata = {
|
||||
type: 'file-chunk-meta',
|
||||
file_id: requestId,
|
||||
offset: offset,
|
||||
size: chunk.byteLength,
|
||||
is_last: offset + chunk.byteLength >= file.size
|
||||
};
|
||||
|
||||
dataChannel.send(JSON.stringify(metadata));
|
||||
|
||||
// 发送二进制数据
|
||||
dataChannel.send(chunk);
|
||||
|
||||
offset += chunk.byteLength;
|
||||
|
||||
// 继续发送下一块
|
||||
setTimeout(sendChunk, 10); // 小延时以避免阻塞
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(slice);
|
||||
};
|
||||
|
||||
sendChunk();
|
||||
}
|
||||
|
||||
// 处理接收到的文件数据
|
||||
function handleFileData(data, senderId) {
|
||||
console.log('从发送方接收文件数据:', senderId);
|
||||
|
||||
// 检查是否是二进制数据
|
||||
if (data instanceof ArrayBuffer) {
|
||||
// 处理二进制数据块
|
||||
if (pendingChunkMeta) {
|
||||
receiveFileChunk(pendingChunkMeta, data, senderId);
|
||||
pendingChunkMeta = null;
|
||||
}
|
||||
} else {
|
||||
// 处理JSON消息
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
console.log('接收到文件传输消息:', message.type);
|
||||
|
||||
switch (message.type) {
|
||||
case 'file-chunk-meta':
|
||||
// 存储chunk元数据,等待二进制数据
|
||||
pendingChunkMeta = message;
|
||||
break;
|
||||
|
||||
case 'file-info':
|
||||
// 初始化文件传输
|
||||
initFileTransfer(message, senderId);
|
||||
break;
|
||||
|
||||
case 'file-complete':
|
||||
// 文件传输完成
|
||||
completeFileDownload(message.file_id, senderId);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('未知文件传输消息类型:', message.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析文件传输消息失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化文件传输
|
||||
function initFileTransfer(fileInfo, senderId) {
|
||||
console.log('初始化文件传输:', fileInfo);
|
||||
|
||||
const transferKey = `${fileInfo.file_id}_${senderId}`;
|
||||
|
||||
if (!fileTransfers.has(transferKey)) {
|
||||
fileTransfers.set(transferKey, {
|
||||
fileId: fileInfo.file_id,
|
||||
senderId: senderId,
|
||||
chunks: [],
|
||||
totalSize: fileInfo.size,
|
||||
receivedSize: 0,
|
||||
fileName: fileInfo.name,
|
||||
mimeType: fileInfo.mime_type || fileInfo.type,
|
||||
startTime: Date.now()
|
||||
});
|
||||
|
||||
console.log('文件传输已初始化:', transferKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 接收文件数据块
|
||||
function receiveFileChunk(metadata, chunk, senderId) {
|
||||
const transferKey = `${metadata.file_id}_${senderId}`;
|
||||
const transfer = fileTransfers.get(transferKey);
|
||||
|
||||
if (!transfer) {
|
||||
console.error('未找到对应的文件传输:', transferKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// 存储数据块
|
||||
transfer.chunks.push({
|
||||
offset: metadata.offset,
|
||||
data: chunk
|
||||
});
|
||||
|
||||
transfer.receivedSize += chunk.byteLength;
|
||||
|
||||
// 更新进度
|
||||
const progress = (transfer.receivedSize / transfer.totalSize) * 100;
|
||||
updateTransferProgress(metadata.file_id, progress, transfer.receivedSize, transfer.totalSize);
|
||||
|
||||
console.log(`文件块接收进度: ${progress.toFixed(1)}% (${transfer.receivedSize}/${transfer.totalSize})`);
|
||||
|
||||
// 检查是否是最后一块
|
||||
if (metadata.is_last || transfer.receivedSize >= transfer.totalSize) {
|
||||
console.log('文件接收完成,开始合并数据块');
|
||||
assembleAndDownloadFile(transferKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 组装文件并触发下载
|
||||
function assembleAndDownloadFile(transferKey) {
|
||||
const transfer = fileTransfers.get(transferKey);
|
||||
if (!transfer) {
|
||||
console.error('未找到文件传输信息:', transferKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// 按偏移量排序数据块
|
||||
transfer.chunks.sort((a, b) => a.offset - b.offset);
|
||||
|
||||
// 合并所有数据块
|
||||
const totalSize = transfer.chunks.reduce((sum, chunk) => sum + chunk.data.byteLength, 0);
|
||||
const mergedData = new Uint8Array(totalSize);
|
||||
let currentOffset = 0;
|
||||
|
||||
transfer.chunks.forEach(chunk => {
|
||||
const chunkView = new Uint8Array(chunk.data);
|
||||
mergedData.set(chunkView, currentOffset);
|
||||
currentOffset += chunkView.length;
|
||||
});
|
||||
|
||||
// 创建Blob并触发下载
|
||||
const blob = new Blob([mergedData], { type: transfer.mimeType });
|
||||
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = transfer.fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// 清理传输信息
|
||||
fileTransfers.delete(transferKey);
|
||||
|
||||
// 显示完成状态
|
||||
hideTransferProgress(transfer.fileId);
|
||||
|
||||
// 恢复下载按钮
|
||||
const button = document.querySelector(`button[onclick="downloadFile('${transfer.fileId}')"]`);
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = '📥 下载';
|
||||
}
|
||||
|
||||
const transferTime = (Date.now() - transfer.startTime) / 1000;
|
||||
const speed = (transfer.totalSize / transferTime / 1024 / 1024).toFixed(2);
|
||||
|
||||
console.log(`文件下载完成: ${transfer.fileName}`);
|
||||
console.log(`传输时间: ${transferTime.toFixed(1)}秒,平均速度: ${speed} MB/s`);
|
||||
|
||||
// 显示成功消息
|
||||
showNotification(`文件 "${transfer.fileName}" 下载完成!传输速度: ${speed} MB/s`, 'success');
|
||||
}
|
||||
|
||||
// 为发送方初始化P2P连接(不立即创建offer)
|
||||
function initPeerConnectionForSender() {
|
||||
console.log('为发送方初始化P2P连接(等待接收方就绪)');
|
||||
|
||||
// 清除之前的超时定时器
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
}
|
||||
|
||||
// 设置连接超时(60秒,合理的超时时间)
|
||||
connectionTimeout = setTimeout(() => {
|
||||
console.error('P2P连接超时(60秒)');
|
||||
if (peerConnection && !isP2PConnected) {
|
||||
console.log('关闭超时的P2P连接');
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
updateP2PStatus(false);
|
||||
alert('P2P连接超时,请检查网络连接并重试');
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// 使用国内优化的WebRTC配置
|
||||
peerConnection = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
// 阿里云和腾讯STUN服务器
|
||||
{ urls: 'stun:stun.chat.bilibili.com:3478' },
|
||||
{ urls: 'stun:stun.voipbuster.com' },
|
||||
{ urls: 'stun:stun.voipstunt.com' },
|
||||
{ urls: 'stun:stun.qq.com:3478' },
|
||||
// 备用国外服务器
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
],
|
||||
iceCandidatePoolSize: 10
|
||||
});
|
||||
|
||||
// 连接状态监听
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log('P2P连接状态:', peerConnection.connectionState);
|
||||
if (peerConnection.connectionState === 'connected') {
|
||||
console.log('P2P连接建立成功');
|
||||
isP2PConnected = true;
|
||||
updateP2PStatus(true);
|
||||
|
||||
// 清除连接超时定时器
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
} else if (peerConnection.connectionState === 'failed') {
|
||||
console.error('P2P连接失败');
|
||||
updateP2PStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ICE连接状态监听
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
console.log('ICE连接状态:', peerConnection.iceConnectionState);
|
||||
if (peerConnection.iceConnectionState === 'failed') {
|
||||
console.error('ICE连接失败');
|
||||
updateP2PStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建数据通道
|
||||
dataChannel = peerConnection.createDataChannel('fileTransfer', {
|
||||
ordered: true
|
||||
});
|
||||
setupDataChannel(dataChannel);
|
||||
|
||||
// 处理ICE候选
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log('发送ICE候选:', event.candidate.candidate);
|
||||
sendWebSocketMessage({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
});
|
||||
} else {
|
||||
console.log('ICE候选收集完成');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 创建offer(发送方专用)
|
||||
function createOffer() {
|
||||
if (!peerConnection) {
|
||||
console.error('PeerConnection未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('发送方创建 offer');
|
||||
|
||||
peerConnection.createOffer().then(offer => {
|
||||
console.log('Offer 创建成功');
|
||||
return peerConnection.setLocalDescription(offer);
|
||||
}).then(() => {
|
||||
console.log('本地描述设置成功,发送 offer');
|
||||
sendWebSocketMessage({
|
||||
type: 'offer',
|
||||
payload: peerConnection.localDescription
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error('创建 offer 失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化P2P连接(接收方使用)
|
||||
function initPeerConnection() {
|
||||
console.log('接收方初始化P2P连接');
|
||||
|
||||
// 清除之前的超时定时器
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
}
|
||||
|
||||
// 设置连接超时(60秒)
|
||||
connectionTimeout = setTimeout(() => {
|
||||
console.error('P2P连接超时(60秒)');
|
||||
if (peerConnection && !isP2PConnected) {
|
||||
console.log('关闭超时的P2P连接');
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
updateP2PStatus(false);
|
||||
alert('P2P连接超时,请检查网络连接并重试');
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// 使用国内优化配置
|
||||
peerConnection = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
// 阿里云和腾讯STUN服务器
|
||||
{ urls: 'stun:stun.chat.bilibili.com:3478' },
|
||||
{ urls: 'stun:stun.voipbuster.com' },
|
||||
{ urls: 'stun:stun.voipstunt.com' },
|
||||
{ urls: 'stun:stun.qq.com:3478' },
|
||||
// 备用国外服务器
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
],
|
||||
iceCandidatePoolSize: 10
|
||||
});
|
||||
|
||||
// 连接状态监听
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log('P2P连接状态:', peerConnection.connectionState);
|
||||
if (peerConnection.connectionState === 'connected') {
|
||||
console.log('P2P连接建立成功');
|
||||
isP2PConnected = true;
|
||||
updateP2PStatus(true);
|
||||
|
||||
// 清除连接超时定时器
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
} else if (peerConnection.connectionState === 'failed') {
|
||||
console.error('P2P连接失败');
|
||||
updateP2PStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ICE连接状态监听
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
console.log('ICE连接状态:', peerConnection.iceConnectionState);
|
||||
if (peerConnection.iceConnectionState === 'failed') {
|
||||
console.error('ICE连接失败');
|
||||
updateP2PStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理数据通道
|
||||
peerConnection.ondatachannel = (event) => {
|
||||
console.log('接收到数据通道');
|
||||
const channel = event.channel;
|
||||
setupDataChannel(channel);
|
||||
};
|
||||
|
||||
// 处理ICE候选
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log('发送ICE候选:', event.candidate.candidate);
|
||||
sendWebSocketMessage({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
});
|
||||
} else {
|
||||
console.log('ICE候选收集完成');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 处理WebSocket消息
|
||||
async function handleWebSocketMessage(message) {
|
||||
console.log('收到WebSocket消息:', message.type);
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'offer':
|
||||
console.log('处理 offer');
|
||||
// 确保接收方的peerConnection已初始化
|
||||
if (!peerConnection) {
|
||||
console.log('接收方peerConnection未初始化,先初始化');
|
||||
initPeerConnection();
|
||||
// 等待一小段时间让peerConnection完全初始化
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('远程描述设置成功,创建 answer');
|
||||
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
console.log('本地描述设置成功,发送 answer');
|
||||
|
||||
sendWebSocketMessage({
|
||||
type: 'answer',
|
||||
payload: answer
|
||||
});
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
console.log('处理 answer');
|
||||
if (peerConnection) {
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('远程 answer 设置成功');
|
||||
} else {
|
||||
console.error('收到answer但peerConnection未初始化');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
console.log('处理 ICE 候选:', message.payload.candidate);
|
||||
if (peerConnection && peerConnection.remoteDescription) {
|
||||
try {
|
||||
await peerConnection.addIceCandidate(new RTCIceCandidate(message.payload));
|
||||
console.log('ICE 候选添加成功');
|
||||
} catch (error) {
|
||||
console.error('添加ICE候选失败:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('收到ICE候选但远程描述未设置,暂时缓存');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-list':
|
||||
if (currentRole === 'receiver') {
|
||||
console.log('接收到文件列表');
|
||||
displayReceiverFiles(message.payload.files);
|
||||
// 接收方在收到文件列表后初始化P2P连接
|
||||
if (!peerConnection) {
|
||||
console.log('初始化接收方P2P连接');
|
||||
initPeerConnection();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'receiver-ready':
|
||||
if (currentRole === 'sender') {
|
||||
console.log('接收方已连接,创建offer');
|
||||
// 发送方现在可以创建offer了
|
||||
setTimeout(() => {
|
||||
if (peerConnection && !isP2PConnected) {
|
||||
createOffer();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理WebSocket消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送WebSocket消息
|
||||
function sendWebSocketMessage(message) {
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
websocket.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('WebSocket未连接,无法发送消息:', message.type);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebSocket连接测试</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebSocket连接测试</h1>
|
||||
<div id="status">未连接</div>
|
||||
<button onclick="testConnection()">测试连接</button>
|
||||
<div id="log"></div>
|
||||
|
||||
<script>
|
||||
function log(message) {
|
||||
const logDiv = document.getElementById('log');
|
||||
logDiv.innerHTML += '<div>' + new Date().toLocaleTimeString() + ': ' + message + '</div>';
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function testConnection() {
|
||||
const code = '354888'; // 使用已存在的房间码
|
||||
const role = 'receiver';
|
||||
const wsUrl = `ws://localhost:8080/ws/p2p?code=${code}&role=${role}`;
|
||||
|
||||
log('尝试连接: ' + wsUrl);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = function() {
|
||||
log('WebSocket连接成功!');
|
||||
document.getElementById('status').textContent = '已连接';
|
||||
};
|
||||
|
||||
ws.onerror = function(error) {
|
||||
log('WebSocket错误: ' + JSON.stringify(error));
|
||||
};
|
||||
|
||||
ws.onclose = function(event) {
|
||||
log('WebSocket关闭: 代码=' + event.code + ', 原因=' + event.reason);
|
||||
document.getElementById('status').textContent = '已断开';
|
||||
};
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
log('收到消息: ' + event.data);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,127 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>WebSocket调试测试</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebSocket连接测试</h1>
|
||||
<div>
|
||||
<input type="text" id="testCode" placeholder="输入取件码" maxlength="6" style="padding: 10px; margin: 10px;">
|
||||
<select id="testRole" style="padding: 10px; margin: 10px;">
|
||||
<option value="sender">发送方</option>
|
||||
<option value="receiver">接收方</option>
|
||||
</select>
|
||||
<button onclick="testWebSocket()" style="padding: 10px; margin: 10px;">测试连接</button>
|
||||
<button onclick="closeWebSocket()" style="padding: 10px; margin: 10px;">关闭连接</button>
|
||||
<button onclick="clearLog()" style="padding: 10px; margin: 10px;">清空日志</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>连接状态:<span id="status">未连接</span></h2>
|
||||
<h3>日志:</h3>
|
||||
<div id="log" style="border: 1px solid #ccc; padding: 10px; height: 400px; overflow-y: auto; font-family: monospace; background: #f5f5f5;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let testSocket = null;
|
||||
let logElement = document.getElementById('log');
|
||||
let statusElement = document.getElementById('status');
|
||||
|
||||
function log(message) {
|
||||
const time = new Date().toLocaleTimeString();
|
||||
logElement.innerHTML += `[${time}] ${message}<br>`;
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
console.log(`[${time}] ${message}`);
|
||||
}
|
||||
|
||||
function updateStatus(status) {
|
||||
statusElement.textContent = status;
|
||||
log(`状态更新: ${status}`);
|
||||
}
|
||||
|
||||
function testWebSocket() {
|
||||
const code = document.getElementById('testCode').value.trim();
|
||||
const role = document.getElementById('testRole').value;
|
||||
|
||||
if (!code) {
|
||||
log('错误: 请输入取件码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (testSocket) {
|
||||
log('关闭现有连接...');
|
||||
testSocket.close();
|
||||
testSocket = null;
|
||||
}
|
||||
|
||||
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws/p2p?code=${code}&role=${role}`;
|
||||
|
||||
log(`尝试连接: ${wsUrl}`);
|
||||
updateStatus('连接中...');
|
||||
|
||||
try {
|
||||
testSocket = new WebSocket(wsUrl);
|
||||
|
||||
testSocket.onopen = function(event) {
|
||||
log('✅ WebSocket连接成功建立');
|
||||
updateStatus('已连接');
|
||||
};
|
||||
|
||||
testSocket.onmessage = function(event) {
|
||||
let message = event.data;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
log(`📥 收到消息 (${message.type}): ${JSON.stringify(message, null, 2)}`);
|
||||
} catch (e) {
|
||||
log(`📥 收到原始消息: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
testSocket.onerror = function(error) {
|
||||
log(`❌ WebSocket错误: ${error}`);
|
||||
updateStatus('连接错误');
|
||||
};
|
||||
|
||||
testSocket.onclose = function(event) {
|
||||
log(`🔌 连接关闭 - 代码: ${event.code}, 原因: ${event.reason}, 是否干净关闭: ${event.wasClean}`);
|
||||
updateStatus('连接已关闭');
|
||||
testSocket = null;
|
||||
};
|
||||
|
||||
// 连接超时检测
|
||||
setTimeout(() => {
|
||||
if (testSocket && testSocket.readyState === WebSocket.CONNECTING) {
|
||||
log('⏰ 连接超时 (10秒)');
|
||||
testSocket.close();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 创建WebSocket失败: ${error.message}`);
|
||||
updateStatus('创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
function closeWebSocket() {
|
||||
if (testSocket) {
|
||||
log('手动关闭连接...');
|
||||
testSocket.close();
|
||||
testSocket = null;
|
||||
} else {
|
||||
log('没有活动连接');
|
||||
}
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
logElement.innerHTML = '';
|
||||
}
|
||||
|
||||
// 页面加载时显示信息
|
||||
window.onload = function() {
|
||||
log('WebSocket测试页面已加载');
|
||||
log('请先创建一个房间(使用取件码),然后测试连接');
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,80 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - 文件传输系统</title>
|
||||
|
||||
<!-- SEO优化 -->
|
||||
<meta name="description" content="安全快速的P2P文件传输系统,支持大文件上传、视频流传输、取件码分享">
|
||||
<meta name="keywords" content="文件传输,P2P,WebRTC,大文件上传,视频传输,取件码">
|
||||
<meta name="author" content="文件传输系统">
|
||||
|
||||
<!-- 移动端优化 -->
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<!-- 使用Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- 自定义样式 -->
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
|
||||
<!-- WebRTC兼容性 -->
|
||||
<script>
|
||||
// WebRTC 兼容性检查
|
||||
window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
|
||||
window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
|
||||
window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate;
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="text-xl font-bold text-gray-900">
|
||||
📁 文件传输
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex space-x-4">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">
|
||||
首页
|
||||
</a>
|
||||
<a href="/upload" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">
|
||||
上传文件
|
||||
</a>
|
||||
<a href="/video" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">
|
||||
视频传输
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="bg-white border-t mt-auto">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center text-gray-500 text-sm">
|
||||
<p>© 2024 文件传输系统. 支持P2P传输、WebRTC视频、大文件上传</p>
|
||||
<p class="mt-2">
|
||||
兼容主流浏览器: Chrome, Safari, Firefox, Edge, 360浏览器, QQ浏览器
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 通用JavaScript -->
|
||||
<script src="/static/js/common.js"></script>
|
||||
|
||||
<!-- 页面特定脚本 -->
|
||||
{{template "scripts" .}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,353 +0,0 @@
|
||||
{{define "content"}}
|
||||
<!-- 加载屏幕 -->
|
||||
<div id="loadingScreen" class="fixed inset-0 bg-white z-50 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 border-4 border-blue-200 border-t-blue-500 rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">正在加载...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<<!-- 固定的Header -->
|
||||
<div class="fixed top-0 left-0 right-0 bg-white/95 backdrop-blur-sm border-b border-gray-200 z-50">
|
||||
<div class="container mx-auto px-4 py-4">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-1">🔗 P2P文件传输</h1>
|
||||
<p class="text-gray-600 text-sm">安全快速的点对点文件传输</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 pt-20" style="display: none;" id="mainContent">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- 主功能区域 - 标签页布局 -->
|
||||
|
||||
<!-- 主功能区域 - 标签页布局 -->
|
||||
<div class="max-w-4xl mx-auto bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<!-- 标签页导航 -->
|
||||
<div class="flex border-b border-gray-200">
|
||||
<button id="sendTab" onclick="switchTab('send')"
|
||||
class="tab-button active flex-1 px-6 py-4 text-center font-medium transition-all duration-200 border-b-2 border-blue-500 bg-blue-50 text-blue-600">
|
||||
<div class="flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
||||
</svg>
|
||||
发送文件
|
||||
</div>
|
||||
</button>
|
||||
<button id="receiveTab" onclick="switchTab('receive')"
|
||||
class="tab-button flex-1 px-6 py-4 text-center font-medium transition-all duration-200 border-b-2 border-transparent text-gray-600 hover:text-gray-800 hover:bg-gray-50">
|
||||
<div class="flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 11l3 3m0 0l3-3m-3 3V8"></path>
|
||||
</svg>
|
||||
接收文件
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 发送文件内容区域 -->
|
||||
<div id="sendContent" class="tab-content active p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
||||
</svg>
|
||||
选择文件并生成取件码
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm">选择要发送的文件,系统会生成取件码供接收方使用</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- 文件选择区域 - 简化设计 -->
|
||||
<div id="fileDropZone" class="border-2 border-dashed border-gray-200 rounded-xl p-8 text-center hover:border-blue-300 hover:bg-blue-50 transition-all duration-200 cursor-pointer group"
|
||||
onclick="handleDropZoneClick(event)">
|
||||
<div class="text-4xl mb-3 group-hover:scale-110 transition-transform duration-200"><EFBFBD></div>
|
||||
<h3 class="text-lg font-medium text-gray-700 mb-2">选择文件</h3>
|
||||
<p class="text-sm text-gray-500">点击或拖拽文件到此处</p>
|
||||
<p class="text-xs text-gray-400 mt-1">支持多文件选择</p>
|
||||
<input type="file" id="fileInput" multiple class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- 文件列表区域 -->
|
||||
<div id="fileListArea" class="hidden">
|
||||
<div class="border-2 border-dashed border-gray-200 rounded-xl p-4 cursor-pointer hover:border-blue-300 hover:bg-blue-50 transition-all duration-200"
|
||||
onclick="handleDropZoneClick(event)">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-medium text-gray-700">已选择文件</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="fileCount" class="text-sm text-gray-500 bg-gray-100 px-2 py-1 rounded-full"></span>
|
||||
<button onclick="addMoreFiles()" class="text-sm text-blue-600 hover:text-blue-800 px-2 py-1 rounded">
|
||||
+ 添加更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="filesList" class="space-y-1 max-h-40 overflow-y-auto mb-4"></div>
|
||||
<div class="text-center">
|
||||
<button id="generateCodeBtn" onclick="generatePickupCode()"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded-lg text-sm font-medium transition-colors duration-200 shadow-sm hover:shadow-md">
|
||||
生成取件码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 取件码显示 - 更紧凑的设计 -->
|
||||
<div id="pickupCodeSection" class="mt-4 hidden">
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
|
||||
<div class="flex items-center justify-center mb-3">
|
||||
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<h4 class="font-semibold text-green-800 text-sm">取件码和链接</h4>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-green-200 rounded-lg p-3 mb-3">
|
||||
<div class="text-2xl font-mono font-bold text-green-600 tracking-wider select-all" id="pickupCodeDisplay" onclick="event.stopPropagation(); copyPickupCode();"></div>
|
||||
</div>
|
||||
|
||||
<!-- 特定链接 -->
|
||||
<div class="bg-white border border-green-200 rounded-lg p-2 mb-3">
|
||||
<div class="text-xs text-gray-600 mb-1">直接取件链接:</div>
|
||||
<div class="text-xs font-mono text-blue-600 break-all select-all cursor-pointer" id="pickupLinkDisplay" onclick="event.stopPropagation(); copyPickupLink();"></div>
|
||||
</div>
|
||||
|
||||
<p class="text-green-700 text-xs mb-3">点击取件码或链接可直接复制</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button onclick="event.stopPropagation(); copyPickupCode()" class="bg-green-500 hover:bg-green-600 text-white py-1.5 px-3 rounded-lg text-xs font-medium transition-colors">
|
||||
📋 复制码
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); copyPickupLink()" class="bg-blue-500 hover:bg-blue-600 text-white py-1.5 px-3 rounded-lg text-xs font-medium transition-colors">
|
||||
🔗 复制链接
|
||||
</button>
|
||||
<button onclick="event.stopPropagation(); resetSender()" class="bg-gray-500 hover:bg-gray-600 text-white py-1.5 px-3 rounded-lg text-xs font-medium transition-colors">
|
||||
🔄 重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接状态 - 简化显示 -->
|
||||
<div id="senderStatus" class="mt-3">
|
||||
<div class="flex items-center justify-center p-2 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2 h-2 bg-yellow-500 rounded-full mr-2 animate-pulse"></div>
|
||||
<span class="text-yellow-800 text-xs font-medium">等待连接...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 房间状态 - 折叠式显示 -->
|
||||
<div id="roomStatusSection" class="mt-3 hidden">
|
||||
<details class="bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<summary class="p-2 cursor-pointer text-blue-800 font-medium text-xs hover:bg-blue-100 rounded-lg">
|
||||
房间状态 📊
|
||||
</summary>
|
||||
<div class="px-2 pb-2">
|
||||
<div id="roomConnections" class="text-xs text-blue-700 space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span>在线用户:</span>
|
||||
<span id="onlineCount" class="font-medium">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>发送方:</span>
|
||||
<span id="senderCount" class="font-medium">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>接收方:</span>
|
||||
<span id="receiverCount" class="font-medium">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="clientsList" class="mt-1 space-y-1"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 接收文件内容区域 -->
|
||||
<div id="receiveContent" class="tab-content hidden p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 11l3 3m0 0l3-3m-3 3V8"></path>
|
||||
</svg>
|
||||
输入取件码获取文件
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm">输入6位取件码,连接发送方并下载文件</p>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:接收文件 -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
|
||||
<h2 class="text-xl font-semibold text-white flex items-center">
|
||||
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 11l3 3m0 0l3-3m-3 3V8"></path>
|
||||
</svg>
|
||||
接收文件
|
||||
</h2>
|
||||
<p class="text-green-100 text-sm mt-1">输入取件码,获取文件</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- 取件码输入 - 简化设计 -->
|
||||
<div id="codeInputSection" class="text-center">
|
||||
<div class="max-w-sm mx-auto">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">请输入6位取件码</label>
|
||||
<input type="text" id="pickupCodeInput" placeholder="取件码" maxlength="6"
|
||||
class="w-full px-4 py-4 border-2 border-gray-200 rounded-xl text-center text-2xl font-mono font-bold uppercase focus:outline-none focus:border-green-500 focus:ring-4 focus:ring-green-100 transition-all duration-200">
|
||||
<button onclick="joinRoom()" class="w-full mt-4 bg-green-500 hover:bg-green-600 text-white py-2 px-4 rounded-lg text-sm font-medium transition-colors duration-200 shadow-sm hover:shadow-md">
|
||||
🔗 连接获取
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表显示 - 优化布局 -->
|
||||
<div id="receiverFilesSection" class="hidden">
|
||||
<div class="mt-6">
|
||||
<h4 class="font-medium text-gray-700 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
可下载文件
|
||||
</h4>
|
||||
<div id="receiverFilesList" class="space-y-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- 接收状态 - 简化显示 -->
|
||||
<div id="receiverStatus" class="mt-6">
|
||||
<div class="flex items-center justify-center p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full mr-2"></div>
|
||||
<span class="text-green-800 text-sm font-medium">已连接,可下载文件</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 房间状态显示 (接收方) - 折叠式 -->
|
||||
<div id="receiverRoomStatusSection" class="mt-4">
|
||||
<details class="bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<summary class="p-3 cursor-pointer text-blue-800 font-medium text-sm hover:bg-blue-100 rounded-lg">
|
||||
房间状态 📊
|
||||
</summary>
|
||||
<div class="px-3 pb-3">
|
||||
<div id="receiverRoomConnections" class="text-xs text-blue-700 space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span>在线用户:</span>
|
||||
<span id="receiverOnlineCount" class="font-medium">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>发送方:</span>
|
||||
<span id="receiverSenderCount" class="font-medium">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>接收方:</span>
|
||||
<span id="receiverReceiverCount" class="font-medium">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="receiverClientsList" class="mt-2 space-y-1"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 传输进度 - 浮动卡片设计 -->
|
||||
<div id="transferProgress" class="hidden">
|
||||
<div class="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-purple-500 to-purple-600 px-6 py-4">
|
||||
<h3 class="text-xl font-semibold text-white flex items-center">
|
||||
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
传输进度
|
||||
</h3>
|
||||
<p class="text-purple-100 text-sm mt-1">文件传输状态</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div id="progressList" class="space-y-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部提示信息 -->
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center px-4 py-2 bg-white rounded-full shadow-sm border border-gray-200">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.707-3.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L9 10.586l7.293-7.293a1 1 0 011.414 0z"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-600">点对点传输,安全快速,不经过服务器</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误边界处理 -->
|
||||
<script>
|
||||
// 标签页切换函数
|
||||
function switchTab(tab) {
|
||||
// 移除所有标签页的活动状态
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active', 'border-blue-500', 'bg-blue-50', 'text-blue-600', 'border-green-500', 'bg-green-50', 'text-green-600');
|
||||
btn.classList.add('border-transparent', 'text-gray-600');
|
||||
});
|
||||
|
||||
// 隐藏所有标签页内容
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// 激活选中的标签页
|
||||
if (tab === 'send') {
|
||||
const sendTab = document.getElementById('sendTab');
|
||||
const sendContent = document.getElementById('sendContent');
|
||||
|
||||
sendTab.classList.remove('border-transparent', 'text-gray-600');
|
||||
sendTab.classList.add('active', 'border-blue-500', 'bg-blue-50', 'text-blue-600');
|
||||
|
||||
sendContent.classList.remove('hidden');
|
||||
sendContent.classList.add('active');
|
||||
} else if (tab === 'receive') {
|
||||
const receiveTab = document.getElementById('receiveTab');
|
||||
const receiveContent = document.getElementById('receiveContent');
|
||||
|
||||
receiveTab.classList.remove('border-transparent', 'text-gray-600');
|
||||
receiveTab.classList.add('active', 'border-green-500', 'bg-green-50', 'text-green-600');
|
||||
|
||||
receiveContent.classList.remove('hidden');
|
||||
receiveContent.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏加载屏幕并显示主内容
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
document.getElementById('loadingScreen').style.display = 'none';
|
||||
document.getElementById('mainContent').style.display = 'block';
|
||||
document.getElementById('mainContent').classList.add('fade-in-up');
|
||||
}, 800);
|
||||
});
|
||||
|
||||
// 全局错误处理
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('页面错误:', event.error);
|
||||
showNotification('页面发生错误,请刷新重试', 'error');
|
||||
});
|
||||
|
||||
// 未处理的Promise拒绝
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('未处理的Promise拒绝:', event.reason);
|
||||
showNotification('操作失败,请重试', 'error');
|
||||
event.preventDefault();
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<!-- P2P文件传输相关脚本 -->
|
||||
<script src="/static/js/p2p-transfer-new.js"></script>
|
||||
{{end}}
|
||||
@@ -1,353 +0,0 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 页面标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">📤 文件上传</h1>
|
||||
<p class="text-lg text-gray-600">
|
||||
支持拖拽上传、多文件批量上传、进度显示。最大支持64TB文件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{if .Success}}
|
||||
<!-- 上传成功提示 -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-6 mb-8">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-4">✅</div>
|
||||
<h2 class="text-xl font-bold text-green-800 mb-4">文件上传成功!</h2>
|
||||
|
||||
<div class="bg-white p-4 rounded-lg border mb-4">
|
||||
<div class="text-left space-y-2">
|
||||
<p><strong>文件名:</strong>{{.FileInfo.FileName}}</p>
|
||||
<p><strong>文件大小:</strong><span id="fileSize">{{.FileInfo.FileSize}}</span> 字节</p>
|
||||
<p><strong>上传时间:</strong>{{.FileInfo.UploadTime.Format "2006-01-02 15:04:05"}}</p>
|
||||
<p><strong>过期时间:</strong>{{.FileInfo.ExpiryTime.Format "2006-01-02 15:04:05"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 p-4 rounded-lg mb-4">
|
||||
<p class="text-sm text-gray-600 mb-2">您的取件码:</p>
|
||||
<div class="text-3xl font-bold text-blue-600 mb-2" id="shareCode">{{.FileInfo.Code}}</div>
|
||||
<button onclick="copyCode('{{.FileInfo.Code}}')" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded text-sm">
|
||||
📋 复制取件码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<a href="{{.FileInfo.DownloadURL}}" class="bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-lg inline-block">
|
||||
⬇️ 直接下载
|
||||
</a>
|
||||
<br>
|
||||
<button onclick="shareFile('{{.FileInfo.Code}}')" class="bg-purple-500 hover:bg-purple-600 text-white px-6 py-3 rounded-lg">
|
||||
🔗 分享文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-8">
|
||||
<form id="uploadForm" enctype="multipart/form-data" method="post">
|
||||
<!-- 拖拽上传区域 -->
|
||||
<div id="dropZone" class="border-2 border-dashed border-gray-300 rounded-lg p-12 text-center hover:border-blue-400 transition-colors">
|
||||
<div id="dropZoneContent">
|
||||
<div class="text-6xl mb-4">📁</div>
|
||||
<h3 class="text-xl font-semibold mb-2">拖拽文件到此处</h3>
|
||||
<p class="text-gray-600 mb-4">或者点击选择文件</p>
|
||||
<input type="file" id="fileInput" name="file" multiple class="hidden">
|
||||
<button type="button" onclick="document.getElementById('fileInput').click()"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold">
|
||||
选择文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div id="fileList" class="mt-6 hidden">
|
||||
<h3 class="text-lg font-semibold mb-3">选中的文件:</h3>
|
||||
<div id="files"></div>
|
||||
</div>
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<div id="uploadSection" class="mt-6 text-center hidden">
|
||||
<button type="submit" id="uploadBtn"
|
||||
class="bg-green-500 hover:bg-green-600 text-white px-8 py-3 rounded-lg font-semibold text-lg">
|
||||
🚀 开始上传
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<div id="uploadProgress" class="mt-6 hidden">
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-between text-sm text-gray-600">
|
||||
<span>上传进度</span>
|
||||
<span id="progressText">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div id="progressBar" class="bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="uploadStatus" class="text-center text-gray-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 格式转换 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-8 mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">📚 Kindle格式转换</h2>
|
||||
<p class="text-gray-600 mb-4">
|
||||
专为Kindle用户提供EPUB到MOBI格式转换服务。上传EPUB文件后自动转换为MOBI格式。
|
||||
</p>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p class="text-yellow-800">
|
||||
💡 <strong>提示:</strong>格式转换功能正在开发中,敬请期待。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 技术说明 -->
|
||||
<div class="bg-gray-50 rounded-lg p-8 mt-8">
|
||||
<h2 class="text-xl font-bold mb-4">🔧 技术特性</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">✨ 拖拽上传</h3>
|
||||
<p class="text-gray-600 text-sm">支持拖拽文件到页面,HTML5 File API优化用户体验。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">📊 进度显示</h3>
|
||||
<p class="text-gray-600 text-sm">实时显示上传进度,支持多文件批量上传。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">🔄 断点续传</h3>
|
||||
<p class="text-gray-600 text-sm">大文件分片上传,网络中断后可继续上传。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">🛡️ 安全存储</h3>
|
||||
<p class="text-gray-600 text-sm">24小时自动清理,保护用户隐私和服务器空间。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
// 文件上传相关变量
|
||||
let selectedFiles = [];
|
||||
let uploadInProgress = false;
|
||||
|
||||
// DOM元素
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileList = document.getElementById('fileList');
|
||||
const filesContainer = document.getElementById('files');
|
||||
const uploadSection = document.getElementById('uploadSection');
|
||||
const uploadForm = document.getElementById('uploadForm');
|
||||
const uploadProgress = document.getElementById('uploadProgress');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const uploadStatus = document.getElementById('uploadStatus');
|
||||
|
||||
// 拖拽事件处理
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-blue-400', 'bg-blue-50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
handleFiles(files);
|
||||
});
|
||||
|
||||
// 文件选择事件
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
handleFiles(files);
|
||||
});
|
||||
|
||||
// 处理选中的文件
|
||||
function handleFiles(files) {
|
||||
selectedFiles = files;
|
||||
displayFiles();
|
||||
|
||||
if (files.length > 0) {
|
||||
fileList.classList.remove('hidden');
|
||||
uploadSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示文件列表
|
||||
function displayFiles() {
|
||||
filesContainer.innerHTML = '';
|
||||
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg mb-2';
|
||||
|
||||
fileDiv.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<span class="text-2xl mr-3">${getFileIcon(file.type)}</span>
|
||||
<div>
|
||||
<div class="font-medium">${file.name}</div>
|
||||
<div class="text-sm text-gray-500">${formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="removeFile(${index})"
|
||||
class="text-red-500 hover:text-red-700 p-1">
|
||||
❌
|
||||
</button>
|
||||
`;
|
||||
|
||||
filesContainer.appendChild(fileDiv);
|
||||
});
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
function removeFile(index) {
|
||||
selectedFiles.splice(index, 1);
|
||||
displayFiles();
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
fileList.classList.add('hidden');
|
||||
uploadSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件图标
|
||||
function getFileIcon(type) {
|
||||
if (type.startsWith('image/')) return '🖼️';
|
||||
if (type.startsWith('video/')) return '🎥';
|
||||
if (type.startsWith('audio/')) return '🎵';
|
||||
if (type.includes('pdf')) return '📄';
|
||||
if (type.includes('text')) return '📝';
|
||||
if (type.includes('zip') || type.includes('rar')) return '📦';
|
||||
return '📁';
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 表单提交处理
|
||||
uploadForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (uploadInProgress || selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadInProgress = true;
|
||||
showUploadProgress();
|
||||
|
||||
try {
|
||||
// 这里简化处理,实际应该支持分片上传
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFiles[0]); // 暂时只处理第一个文件
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
// 监听上传进度
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = (e.loaded / e.total) * 100;
|
||||
updateProgress(percentComplete);
|
||||
}
|
||||
});
|
||||
|
||||
// 上传完成处理
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 200) {
|
||||
updateProgress(100);
|
||||
uploadStatus.textContent = '上传成功!正在跳转...';
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error('上传失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
xhr.addEventListener('error', () => {
|
||||
throw new Error('网络错误');
|
||||
});
|
||||
|
||||
xhr.open('POST', '/upload');
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.send(formData);
|
||||
|
||||
} catch (error) {
|
||||
uploadStatus.textContent = '上传失败: ' + error.message;
|
||||
uploadInProgress = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 显示上传进度
|
||||
function showUploadProgress() {
|
||||
uploadProgress.classList.remove('hidden');
|
||||
uploadSection.classList.add('hidden');
|
||||
uploadStatus.textContent = '正在上传文件...';
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
function updateProgress(percent) {
|
||||
progressBar.style.width = percent + '%';
|
||||
progressText.textContent = Math.round(percent) + '%';
|
||||
}
|
||||
|
||||
// 复制取件码
|
||||
function copyCode(code) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
alert('取件码已复制到剪贴板: ' + code);
|
||||
}).catch(() => {
|
||||
// 兼容性处理
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = code;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
alert('取件码已复制到剪贴板: ' + code);
|
||||
});
|
||||
}
|
||||
|
||||
// 分享文件
|
||||
function shareFile(code) {
|
||||
const shareUrl = `${window.location.origin}/download/${code}`;
|
||||
const shareText = `文件分享\n取件码: ${code}\n下载链接: ${shareUrl}\n有效期: 24小时`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: '文件分享',
|
||||
text: shareText,
|
||||
url: shareUrl
|
||||
});
|
||||
} else {
|
||||
copyCode(shareText);
|
||||
alert('分享信息已复制到剪贴板');
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时格式化文件大小
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const fileSizeElement = document.getElementById('fileSize');
|
||||
if (fileSizeElement) {
|
||||
const bytes = parseInt(fileSizeElement.textContent);
|
||||
fileSizeElement.textContent = formatFileSize(bytes);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,571 +0,0 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- 页面标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">📺 实时视频传输</h1>
|
||||
<p class="text-lg text-gray-600">
|
||||
基于WebRTC的P2P视频传输,低延迟高画质,支持多人连接。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 连接状态 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div id="connectionStatus" class="w-3 h-3 rounded-full bg-red-500 mr-3"></div>
|
||||
<span id="statusText" class="font-medium">未连接</span>
|
||||
</div>
|
||||
<div class="space-x-4">
|
||||
<button id="connectBtn" onclick="connectToRoom()"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded font-semibold">
|
||||
🔗 连接
|
||||
</button>
|
||||
<button id="disconnectBtn" onclick="disconnectFromRoom()"
|
||||
class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded font-semibold hidden">
|
||||
❌ 断开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频区域 -->
|
||||
<div class="grid lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- 本地视频 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">本地视频</h3>
|
||||
<div class="relative bg-gray-900 rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
|
||||
<video id="localVideo" autoplay muted playsinline
|
||||
class="w-full h-full object-cover">
|
||||
</video>
|
||||
<div id="localVideoOverlay" class="absolute inset-0 flex items-center justify-center text-white text-lg">
|
||||
📹 点击连接开启摄像头
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 space-x-2">
|
||||
<button id="toggleVideo" onclick="toggleVideo()"
|
||||
class="bg-green-500 hover:bg-green-600 text-white px-3 py-2 rounded text-sm">
|
||||
📹 视频
|
||||
</button>
|
||||
<button id="toggleAudio" onclick="toggleAudio()"
|
||||
class="bg-green-500 hover:bg-green-600 text-white px-3 py-2 rounded text-sm">
|
||||
🎤 音频
|
||||
</button>
|
||||
<button onclick="switchCamera()"
|
||||
class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-2 rounded text-sm">
|
||||
🔄 切换
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 远程视频 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">远程视频</h3>
|
||||
<div class="relative bg-gray-900 rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
|
||||
<video id="remoteVideo" autoplay playsinline
|
||||
class="w-full h-full object-cover">
|
||||
</video>
|
||||
<div id="remoteVideoOverlay" class="absolute inset-0 flex items-center justify-center text-white text-lg">
|
||||
📡 等待远程连接...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天区域 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">💬 文字聊天</h3>
|
||||
<div id="chatMessages" class="bg-gray-50 rounded-lg p-4 h-40 overflow-y-auto mb-4">
|
||||
<div class="text-gray-500 text-center">聊天消息将显示在这里...</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input type="text" id="chatInput" placeholder="输入消息..."
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<button onclick="sendMessage()"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-r-lg">
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 技术信息 -->
|
||||
<div class="bg-gray-50 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">🔧 连接信息</h3>
|
||||
<div class="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong>连接状态:</strong>
|
||||
<span id="connectionState">未连接</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>ICE状态:</strong>
|
||||
<span id="iceState">new</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>视频编解码器:</strong>
|
||||
<span id="videoCodec">-</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>音频编解码器:</strong>
|
||||
<span id="audioCodec">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<details>
|
||||
<summary class="cursor-pointer font-medium">WebRTC统计信息</summary>
|
||||
<div id="rtcStats" class="mt-2 p-3 bg-white rounded text-xs font-mono">
|
||||
点击连接后显示详细统计信息...
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 浏览器兼容性提示 -->
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-6">
|
||||
<h4 class="font-semibold text-yellow-800 mb-2">🌐 浏览器兼容性</h4>
|
||||
<p class="text-yellow-800 text-sm">
|
||||
本功能需要支持WebRTC的现代浏览器。推荐使用Chrome、Firefox、Safari、Edge最新版本。
|
||||
部分功能在360浏览器、QQ浏览器等国产浏览器中可能受限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
// WebRTC相关变量
|
||||
let localStream = null;
|
||||
let remoteStream = null;
|
||||
let peerConnection = null;
|
||||
let websocket = null;
|
||||
let isConnected = false;
|
||||
let videoEnabled = true;
|
||||
let audioEnabled = true;
|
||||
|
||||
// DOM元素
|
||||
const localVideo = document.getElementById('localVideo');
|
||||
const remoteVideo = document.getElementById('remoteVideo');
|
||||
const localVideoOverlay = document.getElementById('localVideoOverlay');
|
||||
const remoteVideoOverlay = document.getElementById('remoteVideoOverlay');
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||
const chatMessages = document.getElementById('chatMessages');
|
||||
const chatInput = document.getElementById('chatInput');
|
||||
|
||||
// WebRTC配置
|
||||
const rtcConfig = {
|
||||
iceServers: [
|
||||
// 阿里云STUN服务器
|
||||
{ urls: 'stun:stun.chat.bilibili.com:3478' },
|
||||
{ urls: 'stun:stun.voipbuster.com' },
|
||||
{ urls: 'stun:stun.voipstunt.com' },
|
||||
// 腾讯云STUN服务器
|
||||
{ urls: 'stun:stun.qq.com:3478' },
|
||||
// 备用国外服务器
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
// 连接到房间
|
||||
async function connectToRoom() {
|
||||
try {
|
||||
updateStatus('connecting', '正在连接...');
|
||||
|
||||
// 获取本地媒体流
|
||||
await getUserMedia();
|
||||
|
||||
// 建立WebSocket连接
|
||||
await connectWebSocket();
|
||||
|
||||
// 创建PeerConnection
|
||||
createPeerConnection();
|
||||
|
||||
updateStatus('connected', '已连接');
|
||||
connectBtn.classList.add('hidden');
|
||||
disconnectBtn.classList.remove('hidden');
|
||||
|
||||
} catch (error) {
|
||||
console.error('连接失败:', error);
|
||||
updateStatus('error', '连接失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
function disconnectFromRoom() {
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
}
|
||||
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(track => track.stop());
|
||||
localStream = null;
|
||||
}
|
||||
|
||||
localVideo.srcObject = null;
|
||||
remoteVideo.srcObject = null;
|
||||
|
||||
updateStatus('disconnected', '未连接');
|
||||
connectBtn.classList.remove('hidden');
|
||||
disconnectBtn.classList.add('hidden');
|
||||
|
||||
localVideoOverlay.style.display = 'flex';
|
||||
remoteVideoOverlay.style.display = 'flex';
|
||||
|
||||
isConnected = false;
|
||||
}
|
||||
|
||||
// 获取用户媒体
|
||||
async function getUserMedia() {
|
||||
try {
|
||||
const constraints = {
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 30 }
|
||||
},
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
};
|
||||
|
||||
localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
localVideo.srcObject = localStream;
|
||||
localVideoOverlay.style.display = 'none';
|
||||
|
||||
} catch (error) {
|
||||
throw new Error('无法访问摄像头或麦克风: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 建立WebSocket连接
|
||||
function connectWebSocket() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws/video`;
|
||||
|
||||
websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket连接已建立');
|
||||
resolve();
|
||||
};
|
||||
|
||||
websocket.onmessage = async (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
await handleWebSocketMessage(message);
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
reject(new Error('WebSocket连接失败'));
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
console.log('WebSocket连接已关闭');
|
||||
if (isConnected) {
|
||||
updateStatus('error', '连接已断开');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 创建PeerConnection
|
||||
function createPeerConnection() {
|
||||
peerConnection = new RTCPeerConnection(rtcConfig);
|
||||
|
||||
// 添加本地流
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(track => {
|
||||
peerConnection.addTrack(track, localStream);
|
||||
});
|
||||
}
|
||||
|
||||
// 处理远程流
|
||||
peerConnection.ontrack = (event) => {
|
||||
remoteStream = event.streams[0];
|
||||
remoteVideo.srcObject = remoteStream;
|
||||
remoteVideoOverlay.style.display = 'none';
|
||||
};
|
||||
|
||||
// 处理ICE候选
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate && websocket) {
|
||||
sendWebSocketMessage({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 监听连接状态
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
updateConnectionInfo();
|
||||
};
|
||||
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
updateConnectionInfo();
|
||||
};
|
||||
}
|
||||
|
||||
// 处理WebSocket消息
|
||||
async function handleWebSocketMessage(message) {
|
||||
switch (message.type) {
|
||||
case 'welcome':
|
||||
console.log('收到欢迎消息:', message.payload);
|
||||
break;
|
||||
|
||||
case 'offer':
|
||||
await handleOffer(message.payload);
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
await handleAnswer(message.payload);
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
await handleICECandidate(message.payload);
|
||||
break;
|
||||
|
||||
case 'chat':
|
||||
displayChatMessage(message.payload);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('未知消息类型:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送WebSocket消息
|
||||
function sendWebSocketMessage(message) {
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
websocket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理Offer
|
||||
async function handleOffer(offer) {
|
||||
try {
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
|
||||
sendWebSocketMessage({
|
||||
type: 'answer',
|
||||
payload: answer
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('处理Offer失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理Answer
|
||||
async function handleAnswer(answer) {
|
||||
try {
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
} catch (error) {
|
||||
console.error('处理Answer失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理ICE候选
|
||||
async function handleICECandidate(candidate) {
|
||||
try {
|
||||
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} catch (error) {
|
||||
console.error('添加ICE候选失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
function updateStatus(status, text) {
|
||||
statusText.textContent = text;
|
||||
connectionStatus.className = `w-3 h-3 rounded-full mr-3 ${getStatusColor(status)}`;
|
||||
isConnected = status === 'connected';
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'connected': return 'bg-green-500';
|
||||
case 'connecting': return 'bg-yellow-500';
|
||||
case 'error': return 'bg-red-500';
|
||||
default: return 'bg-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
// 切换视频
|
||||
function toggleVideo() {
|
||||
if (localStream) {
|
||||
videoEnabled = !videoEnabled;
|
||||
localStream.getVideoTracks().forEach(track => {
|
||||
track.enabled = videoEnabled;
|
||||
});
|
||||
|
||||
const btn = document.getElementById('toggleVideo');
|
||||
btn.textContent = videoEnabled ? '📹 视频' : '📹 关闭';
|
||||
btn.className = videoEnabled ?
|
||||
'bg-green-500 hover:bg-green-600 text-white px-3 py-2 rounded text-sm' :
|
||||
'bg-red-500 hover:bg-red-600 text-white px-3 py-2 rounded text-sm';
|
||||
}
|
||||
}
|
||||
|
||||
// 切换音频
|
||||
function toggleAudio() {
|
||||
if (localStream) {
|
||||
audioEnabled = !audioEnabled;
|
||||
localStream.getAudioTracks().forEach(track => {
|
||||
track.enabled = audioEnabled;
|
||||
});
|
||||
|
||||
const btn = document.getElementById('toggleAudio');
|
||||
btn.textContent = audioEnabled ? '🎤 音频' : '🎤 静音';
|
||||
btn.className = audioEnabled ?
|
||||
'bg-green-500 hover:bg-green-600 text-white px-3 py-2 rounded text-sm' :
|
||||
'bg-red-500 hover:bg-red-600 text-white px-3 py-2 rounded text-sm';
|
||||
}
|
||||
}
|
||||
|
||||
// 切换摄像头
|
||||
async function switchCamera() {
|
||||
if (localStream) {
|
||||
const videoTrack = localStream.getVideoTracks()[0];
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
||||
|
||||
if (videoDevices.length > 1) {
|
||||
// 简单的前后摄像头切换逻辑
|
||||
const currentDevice = videoTrack.getSettings().deviceId;
|
||||
const newDevice = videoDevices.find(device => device.deviceId !== currentDevice);
|
||||
|
||||
if (newDevice) {
|
||||
try {
|
||||
const newStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { deviceId: newDevice.deviceId },
|
||||
audio: true
|
||||
});
|
||||
|
||||
// 替换视频轨道
|
||||
const newVideoTrack = newStream.getVideoTracks()[0];
|
||||
if (peerConnection) {
|
||||
const sender = peerConnection.getSenders().find(s =>
|
||||
s.track && s.track.kind === 'video'
|
||||
);
|
||||
if (sender) {
|
||||
sender.replaceTrack(newVideoTrack);
|
||||
}
|
||||
}
|
||||
|
||||
videoTrack.stop();
|
||||
localStream.removeTrack(videoTrack);
|
||||
localStream.addTrack(newVideoTrack);
|
||||
localVideo.srcObject = localStream;
|
||||
|
||||
} catch (error) {
|
||||
console.error('切换摄像头失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送聊天消息
|
||||
function sendMessage() {
|
||||
const message = chatInput.value.trim();
|
||||
if (message && websocket) {
|
||||
sendWebSocketMessage({
|
||||
type: 'chat',
|
||||
payload: {
|
||||
text: message,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
displayChatMessage({
|
||||
text: message,
|
||||
sender: 'me',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
chatInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 显示聊天消息
|
||||
function displayChatMessage(messageData) {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `mb-2 ${messageData.sender === 'me' ? 'text-right' : 'text-left'}`;
|
||||
|
||||
const time = new Date(messageData.timestamp).toLocaleTimeString();
|
||||
messageDiv.innerHTML = `
|
||||
<div class="inline-block max-w-xs lg:max-w-md px-3 py-2 rounded-lg ${
|
||||
messageData.sender === 'me' ?
|
||||
'bg-blue-500 text-white' :
|
||||
'bg-gray-200 text-gray-800'
|
||||
}">
|
||||
<div>${messageData.text}</div>
|
||||
<div class="text-xs opacity-75 mt-1">${time}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
chatMessages.appendChild(messageDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
// 更新连接信息
|
||||
function updateConnectionInfo() {
|
||||
if (peerConnection) {
|
||||
document.getElementById('connectionState').textContent = peerConnection.connectionState;
|
||||
document.getElementById('iceState').textContent = peerConnection.iceConnectionState;
|
||||
|
||||
// 获取统计信息
|
||||
peerConnection.getStats().then(stats => {
|
||||
let videoCodec = '-';
|
||||
let audioCodec = '-';
|
||||
let statsText = '';
|
||||
|
||||
stats.forEach(report => {
|
||||
if (report.type === 'codec') {
|
||||
if (report.mimeType && report.mimeType.includes('video')) {
|
||||
videoCodec = report.mimeType.split('/')[1];
|
||||
} else if (report.mimeType && report.mimeType.includes('audio')) {
|
||||
audioCodec = report.mimeType.split('/')[1];
|
||||
}
|
||||
}
|
||||
|
||||
statsText += `${report.type}: ${JSON.stringify(report, null, 2)}\n\n`;
|
||||
});
|
||||
|
||||
document.getElementById('videoCodec').textContent = videoCodec;
|
||||
document.getElementById('audioCodec').textContent = audioCodec;
|
||||
document.getElementById('rtcStats').textContent = statsText;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天输入回车发送
|
||||
chatInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// 页面卸载时清理资源
|
||||
window.addEventListener('beforeunload', () => {
|
||||
disconnectFromRoom();
|
||||
});
|
||||
|
||||
// 检查浏览器兼容性
|
||||
if (!navigator.mediaDevices || !window.RTCPeerConnection) {
|
||||
alert('您的浏览器不支持WebRTC功能,请使用Chrome、Firefox、Safari或Edge最新版本。');
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user