mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-21 14:24:50 +08:00
第一版本
This commit is contained in:
357
web/static/css/style.css
Normal file
357
web/static/css/style.css
Normal file
@@ -0,0 +1,357 @@
|
||||
/* 自定义样式 - 补充Tailwind CSS */
|
||||
|
||||
/* 全局样式 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 响应式设计优化 */
|
||||
@media (max-width: 640px) {
|
||||
.max-w-7xl {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
nav .flex {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
height: auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
nav .space-x-4 {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.grid.md\\:grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grid.md\\:grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.lg\\:grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lg\\:max-w-md {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 文件拖拽区域样式 */
|
||||
#dropZone {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#dropZone:hover {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
#dropZone.drag-over {
|
||||
border-color: #3b82f6 !important;
|
||||
background-color: #dbeafe !important;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 进度条动画 */
|
||||
#progressBar {
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* 视频元素样式 */
|
||||
video {
|
||||
background-color: #000;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
video::-webkit-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 聊天消息滚动条样式 */
|
||||
#chatMessages {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e0 #f7fafc;
|
||||
}
|
||||
|
||||
#chatMessages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
#chatMessages::-webkit-scrollbar-track {
|
||||
background: #f7fafc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#chatMessages::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#chatMessages::-webkit-scrollbar-thumb:hover {
|
||||
background: #a0aec0;
|
||||
}
|
||||
|
||||
/* 按钮悬停效果优化 */
|
||||
button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.bg-white {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.bg-white:hover {
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 渐入动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* 成功提示动画 */
|
||||
@keyframes bounce {
|
||||
0%, 20%, 53%, 80%, 100% {
|
||||
transform: translate3d(0,0,0);
|
||||
}
|
||||
40%, 43% {
|
||||
transform: translate3d(0, -30px, 0);
|
||||
}
|
||||
70% {
|
||||
transform: translate3d(0, -15px, 0);
|
||||
}
|
||||
90% {
|
||||
transform: translate3d(0, -4px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.success-bounce {
|
||||
animation: bounce 1s ease;
|
||||
}
|
||||
|
||||
/* 错误提示样式 */
|
||||
.error-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文件图标样式 */
|
||||
.file-icon {
|
||||
font-size: 2rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
/* 状态指示器 */
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
background-color: #10b981;
|
||||
box-shadow: 0 0 10px #10b981;
|
||||
}
|
||||
|
||||
.status-connecting {
|
||||
background-color: #f59e0b;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 代码块样式 */
|
||||
pre, code {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 工具提示样式 */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 120px;
|
||||
background-color: #555;
|
||||
color: white;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
margin-left: -60px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.text-gray-900 {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.border-gray-300 {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
nav, footer, button, .no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高对比度支持 */
|
||||
@media (prefers-contrast: high) {
|
||||
button {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 减少动画支持 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
393
web/static/js/common.js
Normal file
393
web/static/js/common.js
Normal file
@@ -0,0 +1,393 @@
|
||||
// 通用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;
|
||||
373
web/static/js/file-transfer.js
Normal file
373
web/static/js/file-transfer.js
Normal file
@@ -0,0 +1,373 @@
|
||||
// 文件传输相关功能
|
||||
|
||||
// 设置数据通道
|
||||
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');
|
||||
}
|
||||
}
|
||||
256
web/static/js/p2p-transfer.js
Normal file
256
web/static/js/p2p-transfer.js
Normal file
@@ -0,0 +1,256 @@
|
||||
// P2P文件传输系统
|
||||
// 全局变量
|
||||
let websocket = null;
|
||||
let peerConnection = null;
|
||||
let dataChannel = null;
|
||||
let selectedFiles = [];
|
||||
let currentPickupCode = '';
|
||||
let currentRole = ''; // 'sender' or 'receiver'
|
||||
let fileTransfers = new Map(); // 存储文件传输状态
|
||||
let isP2PConnected = false; // P2P连接状态
|
||||
let isConnecting = false; // 是否正在连接中
|
||||
let connectionTimeout = null; // 连接超时定时器
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeEventListeners();
|
||||
});
|
||||
|
||||
// 初始化事件监听器
|
||||
function initializeEventListeners() {
|
||||
// 文件选择事件
|
||||
document.getElementById('fileInput').addEventListener('change', handleFileSelect);
|
||||
|
||||
// 取件码输入事件
|
||||
document.getElementById('pickupCodeInput').addEventListener('input', (e) => {
|
||||
e.target.value = e.target.value.toUpperCase();
|
||||
if (e.target.value.length === 6) {
|
||||
// 自动连接
|
||||
setTimeout(() => joinRoom(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
// 拖拽上传
|
||||
setupDragAndDrop();
|
||||
}
|
||||
|
||||
// 设置拖拽上传
|
||||
function setupDragAndDrop() {
|
||||
const dropArea = document.querySelector('.border-dashed');
|
||||
dropArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropArea.classList.add('border-blue-400');
|
||||
});
|
||||
|
||||
dropArea.addEventListener('dragleave', () => {
|
||||
dropArea.classList.remove('border-blue-400');
|
||||
});
|
||||
|
||||
dropArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropArea.classList.remove('border-blue-400');
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
selectedFiles = files;
|
||||
displaySelectedFiles();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
function handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
if (files.length > 0) {
|
||||
selectedFiles = files;
|
||||
displaySelectedFiles();
|
||||
}
|
||||
}
|
||||
|
||||
// 显示选中的文件
|
||||
function displaySelectedFiles() {
|
||||
const container = document.getElementById('selectedFiles');
|
||||
const filesList = document.getElementById('filesList');
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
container.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
container.classList.remove('hidden');
|
||||
filesList.innerHTML = '';
|
||||
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const fileItem = document.createElement('div');
|
||||
fileItem.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg';
|
||||
fileItem.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 onclick="removeFile(${index})" class="text-red-500 hover:text-red-700 p-1">
|
||||
❌
|
||||
</button>
|
||||
`;
|
||||
filesList.appendChild(fileItem);
|
||||
});
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
function removeFile(index) {
|
||||
selectedFiles.splice(index, 1);
|
||||
displaySelectedFiles();
|
||||
}
|
||||
|
||||
// 生成取件码
|
||||
async function generatePickupCode() {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
// 准备文件信息
|
||||
const fileInfos = selectedFiles.map((file, index) => ({
|
||||
id: 'file_' + index,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/create-room', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ files: fileInfos })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
currentPickupCode = data.code;
|
||||
currentRole = 'sender';
|
||||
showPickupCode(data.code);
|
||||
connectWebSocket();
|
||||
} else {
|
||||
alert('生成取件码失败: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成取件码失败:', error);
|
||||
alert('生成取件码失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示取件码
|
||||
function showPickupCode(code) {
|
||||
document.getElementById('pickupCodeDisplay').textContent = code;
|
||||
document.getElementById('pickupCodeSection').classList.remove('hidden');
|
||||
document.getElementById('generateCodeBtn').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 复制取件码
|
||||
function copyPickupCode() {
|
||||
navigator.clipboard.writeText(currentPickupCode).then(() => {
|
||||
alert('取件码已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
// 重置发送方
|
||||
function resetSender() {
|
||||
selectedFiles = [];
|
||||
currentPickupCode = '';
|
||||
currentRole = '';
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
|
||||
document.getElementById('selectedFiles').classList.add('hidden');
|
||||
document.getElementById('pickupCodeSection').classList.add('hidden');
|
||||
document.getElementById('generateCodeBtn').classList.remove('hidden');
|
||||
document.getElementById('fileInput').value = '';
|
||||
}
|
||||
|
||||
// 加入房间
|
||||
async function joinRoom() {
|
||||
const code = document.getElementById('pickupCodeInput').value.trim();
|
||||
if (code.length !== 6) {
|
||||
alert('请输入6位取件码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/room-info?code=${code}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentPickupCode = code;
|
||||
currentRole = 'receiver';
|
||||
displayReceiverFiles(data.files);
|
||||
connectWebSocket();
|
||||
} else {
|
||||
alert(data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('连接失败:', error);
|
||||
alert('连接失败,请检查取件码是否正确');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示接收方文件列表
|
||||
function displayReceiverFiles(files) {
|
||||
document.getElementById('codeInputSection').classList.add('hidden');
|
||||
document.getElementById('receiverFilesSection').classList.remove('hidden');
|
||||
|
||||
const filesList = document.getElementById('receiverFilesList');
|
||||
filesList.innerHTML = '';
|
||||
|
||||
files.forEach((file, index) => {
|
||||
const fileItem = document.createElement('div');
|
||||
fileItem.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg';
|
||||
fileItem.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 onclick="downloadFile('${file.id}')" disabled
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded font-semibold opacity-50 cursor-not-allowed">
|
||||
📥 下载
|
||||
</button>
|
||||
`;
|
||||
filesList.appendChild(fileItem);
|
||||
});
|
||||
|
||||
// 初始化时显示正在建立连接状态
|
||||
updateP2PStatus(false);
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function getFileIcon(mimeType) {
|
||||
if (mimeType.startsWith('image/')) return '🖼️';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('zip') || mimeType.includes('rar')) return '📦';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 页面卸载时清理
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
}
|
||||
});
|
||||
374
web/static/js/webrtc-connection.js
Normal file
374
web/static/js/webrtc-connection.js
Normal file
@@ -0,0 +1,374 @@
|
||||
// WebSocket和WebRTC连接管理
|
||||
|
||||
// WebSocket连接
|
||||
function connectWebSocket() {
|
||||
console.log('尝试连接WebSocket, 角色:', currentRole, '取件码:', currentPickupCode);
|
||||
|
||||
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);
|
||||
|
||||
websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket连接已建立');
|
||||
isConnecting = false;
|
||||
updateConnectionStatus(true);
|
||||
|
||||
// 发送方在WebSocket连接建立后立即初始化P2P(但不创建offer)
|
||||
if (currentRole === 'sender') {
|
||||
console.log('发送方初始化P2P连接(等待接收方就绪)');
|
||||
initPeerConnectionForSender();
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
await handleWebSocketMessage(message);
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
isConnecting = false;
|
||||
updateConnectionStatus(false);
|
||||
updateP2PStatus(false);
|
||||
};
|
||||
|
||||
websocket.onclose = (event) => {
|
||||
console.log('WebSocket连接已关闭, 代码:', event.code, '原因:', event.reason);
|
||||
isConnecting = false;
|
||||
updateConnectionStatus(false);
|
||||
updateP2PStatus(false);
|
||||
websocket = null;
|
||||
|
||||
// 如果不是正常关闭且还需要连接,尝试重连
|
||||
if (event.code !== 1000 && currentPickupCode && !isConnecting) {
|
||||
console.log('WebSocket异常关闭,5秒后尝试重连');
|
||||
setTimeout(() => {
|
||||
if (currentPickupCode && !websocket && !isConnecting) {
|
||||
console.log('尝试重新连接WebSocket');
|
||||
connectWebSocket();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 更新连接状态
|
||||
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>
|
||||
接收方已连接
|
||||
</div>` :
|
||||
`<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>
|
||||
等待接收方连接...
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 为发送方初始化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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user