第一版本

This commit is contained in:
MatrixSeven
2025-07-28 16:33:10 +08:00
commit 70ad644a71
26 changed files with 4676 additions and 0 deletions

357
web/static/css/style.css Normal file
View 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
View 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;

View 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');
}
}

View 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();
}
});

View 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);
}
}

80
web/templates/base.html Normal file
View File

@@ -0,0 +1,80 @@
<!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>&copy; 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>

115
web/templates/index.html Normal file
View File

@@ -0,0 +1,115 @@
{{define "content"}}
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<div class="container mx-auto px-4 py-8">
<div class="max-w-4xl mx-auto">
<!-- 标题 -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-800 mb-2">P2P文件传输</h1>
<p class="text-gray-600">选择文件自动生成取件码,对方输入取件码即可在线下载</p>
</div>
<!-- 主界面 -->
<div class="bg-white rounded-xl shadow-lg p-6 mb-6">
<!-- 发送文件区域 -->
<div id="senderSection" class="mb-8">
<h3 class="text-xl font-semibold mb-4">📤 发送文件</h3>
<!-- 文件选择区域 -->
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors cursor-pointer"
onclick="document.getElementById('fileInput').click()">
<div class="text-6xl mb-4">📁</div>
<p class="text-lg mb-2">点击选择文件或拖拽文件到此处</p>
<p class="text-gray-500">支持多文件选择</p>
<input type="file" id="fileInput" multiple class="hidden">
</div>
<!-- 选中的文件列表 -->
<div id="selectedFiles" class="mt-6 hidden">
<h4 class="font-semibold mb-3">已选择的文件:</h4>
<div id="filesList" class="space-y-2 max-h-60 overflow-y-auto"></div>
<div class="mt-4 text-center">
<button id="generateCodeBtn" onclick="generatePickupCode()"
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold">
🎯 生成取件码
</button>
</div>
</div>
<!-- 取件码显示 -->
<div id="pickupCodeSection" class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg hidden">
<div class="text-center">
<h4 class="font-semibold text-green-800 mb-2">取件码已生成</h4>
<div class="text-3xl font-mono font-bold text-green-600 mb-2" id="pickupCodeDisplay"></div>
<p class="text-green-700 mb-4">请将此取件码发送给对方</p>
<div class="flex justify-center space-x-3">
<button onclick="copyPickupCode()" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded">
📋 复制取件码
</button>
<button onclick="resetSender()" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded">
🔄 重新选择文件
</button>
</div>
</div>
<!-- 连接状态 -->
<div id="senderStatus" class="mt-4 text-center">
<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>
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="border-t border-gray-200 my-8"></div>
<!-- 接收文件区域 -->
<div id="receiverSection">
<h3 class="text-xl font-semibold mb-4">📥 接收文件</h3>
<!-- 取件码输入 -->
<div id="codeInputSection">
<div class="flex justify-center mb-4">
<div class="flex flex-col items-center max-w-md w-full">
<input type="text" id="pickupCodeInput" placeholder="输入6位取件码" maxlength="6"
class="w-full px-4 py-3 border border-gray-300 rounded-lg text-center text-2xl font-mono font-bold uppercase mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500">
<button onclick="joinRoom()" class="bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-lg font-semibold">
🔗 连接并获取文件
</button>
</div>
</div>
</div>
<!-- 文件列表显示 -->
<div id="receiverFilesSection" class="hidden">
<h4 class="font-semibold mb-3">可下载的文件:</h4>
<div id="receiverFilesList" class="space-y-2"></div>
<!-- 接收状态 -->
<div id="receiverStatus" class="mt-4 text-center">
<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>
</div>
</div>
</div>
<!-- 传输进度 -->
<div id="transferProgress" class="bg-white rounded-xl shadow-lg p-6 hidden">
<h3 class="text-xl font-semibold mb-4">传输进度</h3>
<div id="progressList" class="space-y-3"></div>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<!-- P2P文件传输相关脚本 -->
<script src="/static/js/p2p-transfer.js"></script>
<script src="/static/js/webrtc-connection.js"></script>
<script src="/static/js/file-transfer.js"></script>
{{end}}

353
web/templates/upload.html Normal file
View File

@@ -0,0 +1,353 @@
{{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}}

571
web/templates/video.html Normal file
View File

@@ -0,0 +1,571 @@
{{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}}