mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-03-06 23:29:42 +08:00
第一版本
This commit is contained in:
80
web/templates/base.html
Normal file
80
web/templates/base.html
Normal 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>© 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
115
web/templates/index.html
Normal 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
353
web/templates/upload.html
Normal 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
571
web/templates/video.html
Normal 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}}
|
||||
Reference in New Issue
Block a user