本次更新涵盖多个功能模块的优化与新增: 1. 新增Wi-Fi直连配对方式与协作画布模块 2. 完成设备管理重命名API与前端适配 3. 优化日签卡片空数据保护与UI细节 4. 新增剪贴板工具与每日运势会话 5. 修复应用锁恢复、语音消息录制等已知问题 6. 完善文件传输统计与配对逻辑 7. 更新安卓权限配置与iOS隐私描述 8. 新增自动化测试脚本与文档 9. 清理旧版审计报告与测试文件
1373 lines
47 KiB
JavaScript
1373 lines
47 KiB
JavaScript
/*
|
||
* 闲言传输 — Web双向互传页面脚本
|
||
* 创建时间: 2026-05-14
|
||
* 更新时间: 2026-05-14
|
||
* 名称: transfer.js
|
||
* 作用: transfer.html的独立JS文件,支持取件码/账号登录/局域网传输
|
||
* 上次更新: 从transfer.html内联脚本提取
|
||
*/
|
||
|
||
const API_BASE = window.location.origin + '/api/file_transfer';
|
||
const USER_API_BASE = window.location.origin + '/api/user_security';
|
||
const WS_URL = (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.hostname + ':9443';
|
||
|
||
let ws = null;
|
||
let peerConnection = null;
|
||
let dataChannel = null;
|
||
let receiveBuffer = [];
|
||
let receiveSize = 0;
|
||
let currentFileMeta = null;
|
||
|
||
let accountWs = null;
|
||
let accountState = { token: null, userId: null, username: null, peerId: null };
|
||
let discoveredDevices = [];
|
||
let selectedDeviceId = null;
|
||
let selectedFile = null;
|
||
let transferCancelFlag = false;
|
||
let sendPeerConnection = null;
|
||
let sendDataChannel = null;
|
||
|
||
let lanDevices = [];
|
||
let lanSelectedDevice = null;
|
||
let lanSelectedFile = null;
|
||
let lanScanAbort = null;
|
||
|
||
let chatMessages = { account: [], lan: [] };
|
||
|
||
const iceConfig = {
|
||
iceServers: [
|
||
{ urls: 'stun:stun.l.google.com:19302' },
|
||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||
]
|
||
};
|
||
|
||
const CHUNK_SIZE = 65536;
|
||
const SMALL_FILE_THRESHOLD = 10 * 1024 * 1024;
|
||
const LAN_SCAN_PORT = 53317;
|
||
const LAN_SCAN_TIMEOUT = 3000;
|
||
|
||
function switchTab(tab) {
|
||
document.querySelectorAll('.tab-item').forEach((el, i) => {
|
||
const tabs = ['pickup', 'account', 'lan'];
|
||
el.classList.toggle('active', tabs[i] === tab);
|
||
});
|
||
document.querySelectorAll('.tab-content').forEach(el => {
|
||
el.classList.toggle('active', el.id === 'tab-' + tab);
|
||
});
|
||
}
|
||
|
||
function showStatus(elId, type, msg) {
|
||
const el = document.getElementById(elId);
|
||
el.className = 'status ' + type;
|
||
el.textContent = msg;
|
||
}
|
||
|
||
function formatSize(bytes) {
|
||
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB';
|
||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB';
|
||
if (bytes >= 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||
return bytes + ' B';
|
||
}
|
||
|
||
function formatSpeed(bytesPerSec) {
|
||
if (bytesPerSec >= 1048576) return (bytesPerSec / 1048576).toFixed(1) + ' MB/s';
|
||
if (bytesPerSec >= 1024) return (bytesPerSec / 1024).toFixed(0) + ' KB/s';
|
||
return bytesPerSec + ' B/s';
|
||
}
|
||
|
||
function formatTime(date) {
|
||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
function getFileIcon(name) {
|
||
const ext = name.split('.').pop().toLowerCase();
|
||
const icons = {
|
||
pdf: '📄', doc: '📝', docx: '📝', xls: '📊', xlsx: '📊',
|
||
jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
|
||
mp4: '🎬', mov: '🎬', avi: '🎬', mkv: '🎬',
|
||
mp3: '🎵', wav: '🎵', flac: '🎵', aac: '🎵',
|
||
zip: '📦', rar: '📦', '7z': '📦', tar: '📦', gz: '📦',
|
||
txt: '📃', json: '📃', csv: '📃',
|
||
};
|
||
return icons[ext] || '📎';
|
||
}
|
||
|
||
function getDeviceIcon(type) {
|
||
const icons = { phone: '📱', tablet: '📱', desktop: '🖥️', laptop: '💻', browser: '🌐', web: '🌐', mobile: '📱' };
|
||
return icons[type] || '📱';
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
async function loginWithAccount() {
|
||
const username = document.getElementById('loginUsername').value.trim();
|
||
const password = document.getElementById('loginPassword').value;
|
||
if (!username || !password) {
|
||
showStatus('loginStatus', 'error', '❌ 请输入用户名和密码');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('loginBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '登录中...';
|
||
showStatus('loginStatus', 'connecting', '⏳ 正在登录...');
|
||
|
||
try {
|
||
const resp = await fetch(USER_API_BASE + '/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ account: username, password: password })
|
||
});
|
||
const data = await resp.json();
|
||
|
||
if (data.code === 1 && data.data) {
|
||
accountState.token = data.data.token || data.data.user_token;
|
||
accountState.userId = String(data.data.user_id || data.data.id || '');
|
||
accountState.username = data.data.username || data.data.nickname || username;
|
||
|
||
localStorage.setItem('xy_transfer_token', accountState.token);
|
||
localStorage.setItem('xy_transfer_userId', accountState.userId);
|
||
localStorage.setItem('xy_transfer_username', accountState.username);
|
||
|
||
showStatus('loginStatus', 'connected', '✅ 登录成功');
|
||
showAccountPanel();
|
||
connectSignaling(accountState.token);
|
||
} else {
|
||
showStatus('loginStatus', 'error', '❌ ' + (data.msg || '登录失败'));
|
||
}
|
||
} catch (e) {
|
||
showStatus('loginStatus', 'error', '❌ 网络错误: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '登录';
|
||
}
|
||
}
|
||
|
||
function showAccountPanel() {
|
||
document.getElementById('loginForm').style.display = 'none';
|
||
document.getElementById('accountPanel').style.display = 'block';
|
||
document.getElementById('userAvatar').textContent = (accountState.username || '?')[0].toUpperCase();
|
||
document.getElementById('userName').textContent = accountState.username;
|
||
document.getElementById('userIdDisplay').textContent = 'ID: ' + accountState.userId;
|
||
}
|
||
|
||
function logoutAccount() {
|
||
accountState = { token: null, userId: null, username: null, peerId: null };
|
||
localStorage.removeItem('xy_transfer_token');
|
||
localStorage.removeItem('xy_transfer_userId');
|
||
localStorage.removeItem('xy_transfer_username');
|
||
if (accountWs) { accountWs.close(); accountWs = null; }
|
||
discoveredDevices = [];
|
||
selectedDeviceId = null;
|
||
selectedFile = null;
|
||
chatMessages.account = [];
|
||
document.getElementById('loginForm').style.display = 'block';
|
||
document.getElementById('accountPanel').style.display = 'none';
|
||
showStatus('loginStatus', 'error', '');
|
||
}
|
||
|
||
function connectSignaling(token) {
|
||
if (accountWs && accountWs.readyState === WebSocket.OPEN) return;
|
||
|
||
const url = WS_URL + '?token=' + encodeURIComponent(token);
|
||
accountWs = new WebSocket(url);
|
||
|
||
accountWs.onopen = () => {
|
||
accountWs.send(JSON.stringify({
|
||
type: 'register',
|
||
data: {
|
||
deviceId: 'web_' + Date.now(),
|
||
alias: 'Web浏览器',
|
||
deviceType: 'web',
|
||
protocol: 'xianyan-v1',
|
||
userId: accountState.userId,
|
||
}
|
||
}));
|
||
updateConnectionStatus(true);
|
||
};
|
||
|
||
accountWs.onmessage = (event) => {
|
||
const msg = JSON.parse(event.data);
|
||
handleAccountWsMessage(msg);
|
||
};
|
||
|
||
accountWs.onerror = () => {
|
||
updateConnectionStatus(false);
|
||
};
|
||
|
||
accountWs.onclose = () => {
|
||
updateConnectionStatus(false);
|
||
setTimeout(() => {
|
||
if (accountState.token) connectSignaling(accountState.token);
|
||
}, 5000);
|
||
};
|
||
}
|
||
|
||
function updateConnectionStatus(connected) {
|
||
const el = document.getElementById('connectionStatus');
|
||
if (connected) {
|
||
el.className = 'login-status online';
|
||
el.innerHTML = '<span>🟢</span><span>信令服务已连接</span>';
|
||
} else {
|
||
el.className = 'login-status offline';
|
||
el.innerHTML = '<span>🟡</span><span>信令服务连接中...</span>';
|
||
}
|
||
}
|
||
|
||
function handleAccountWsMessage(msg) {
|
||
switch (msg.type) {
|
||
case 'registered':
|
||
accountState.peerId = msg.data?.peerId || msg.peerId;
|
||
break;
|
||
case 'myDevicesResponse':
|
||
handleMyDevicesResponse(msg);
|
||
break;
|
||
case 'offer':
|
||
handleSendOffer(msg);
|
||
break;
|
||
case 'answer':
|
||
handleSendAnswer(msg);
|
||
break;
|
||
case 'iceCandidate':
|
||
handleSendIceCandidate(msg);
|
||
break;
|
||
case 'wsRelay':
|
||
handleWsRelayReceive(msg);
|
||
break;
|
||
case 'transportNegotiate':
|
||
handleTransportNegotiate(msg);
|
||
break;
|
||
case 'disconnection':
|
||
break;
|
||
case 'error':
|
||
console.error('信令错误:', msg.message || msg.data?.message);
|
||
break;
|
||
}
|
||
}
|
||
|
||
function discoverDevices() {
|
||
if (!accountWs || accountWs.readyState !== WebSocket.OPEN) {
|
||
alert('信令服务未连接,请稍后重试');
|
||
return;
|
||
}
|
||
accountWs.send(JSON.stringify({
|
||
type: 'discoverMyDevices',
|
||
data: { userId: accountState.userId }
|
||
}));
|
||
const container = document.getElementById('deviceListContainer');
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">⏳</div><div class="text">正在搜索设备...</div></div>';
|
||
}
|
||
|
||
function handleMyDevicesResponse(msg) {
|
||
const devices = msg.data?.devices || [];
|
||
discoveredDevices = devices;
|
||
renderDeviceList(devices, 'deviceListContainer', 'account');
|
||
}
|
||
|
||
function renderDeviceList(devices, containerId, mode) {
|
||
const container = document.getElementById(containerId);
|
||
if (!devices || devices.length === 0) {
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><div class="text">未发现在线设备</div></div>';
|
||
return;
|
||
}
|
||
|
||
let html = '<div class="device-list">';
|
||
devices.forEach((d, i) => {
|
||
const isOnline = d.online !== false;
|
||
const icon = getDeviceIcon(d.deviceType || d.device_type);
|
||
const name = d.alias || d.deviceModel || d.device_model || '未知设备';
|
||
const detail = [d.ipCity || d.ip_city, d.ip || ''].filter(Boolean).join(' · ');
|
||
const id = d.peerId || d.deviceId || d.device_id || i;
|
||
html += '<div class="device-item" onclick="selectDevice(\'' + id + '\', \'' + mode + '\')">';
|
||
html += '<div class="device-icon">' + icon + '</div>';
|
||
html += '<div class="device-info">';
|
||
html += '<div class="device-name">' + escapeHtml(name) + '</div>';
|
||
html += '<div class="device-detail">' + escapeHtml(detail || d.deviceType || 'Web设备') + '</div>';
|
||
html += '</div>';
|
||
html += '<span class="device-status ' + (isOnline ? 'online' : 'offline') + '">' + (isOnline ? '在线' : '离线') + '</span>';
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function selectDevice(deviceId, mode) {
|
||
if (mode === 'account') {
|
||
selectedDeviceId = deviceId;
|
||
const device = discoveredDevices.find(d => (d.peerId || d.deviceId || d.device_id) === deviceId);
|
||
const name = device ? (device.alias || device.deviceModel || '设备') : '设备';
|
||
const icon = getDeviceIcon(device?.deviceType || device?.device_type);
|
||
document.getElementById('targetDeviceName').textContent = name;
|
||
document.getElementById('sendFileSection').style.display = 'block';
|
||
document.getElementById('chatDeviceEmoji').textContent = icon;
|
||
document.getElementById('chatDeviceName').textContent = name;
|
||
document.getElementById('accountChatContainer').classList.add('active');
|
||
} else if (mode === 'lan') {
|
||
lanSelectedDevice = deviceId;
|
||
const device = lanDevices.find(d => d.id === deviceId);
|
||
const name = device ? device.alias : '设备';
|
||
const icon = getDeviceIcon(device?.deviceType);
|
||
document.getElementById('lanTargetName').textContent = name;
|
||
document.getElementById('lanSendSection').style.display = 'block';
|
||
document.getElementById('lanChatDeviceEmoji').textContent = icon;
|
||
document.getElementById('lanChatDeviceName').textContent = name;
|
||
document.getElementById('lanChatContainer').classList.add('active');
|
||
}
|
||
}
|
||
|
||
async function startLanScan() {
|
||
if (lanScanAbort) return;
|
||
|
||
const btn = document.getElementById('lanScanBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ 扫描中...';
|
||
|
||
lanDevices = [];
|
||
lanSelectedDevice = null;
|
||
|
||
const statusEl = document.getElementById('lanScanStatus');
|
||
statusEl.className = 'lan-scan-status scanning';
|
||
document.getElementById('lanScanText').textContent = '正在扫描局域网...';
|
||
document.getElementById('lanScanProgress').style.display = 'block';
|
||
document.getElementById('lanScanProgressBar').style.width = '0%';
|
||
|
||
const container = document.getElementById('lanDeviceListContainer');
|
||
container.innerHTML = '<div class="empty-state"><div class="icon">⏳</div><div class="text">正在扫描局域网设备...</div></div>';
|
||
|
||
try {
|
||
const localIp = await getLocalIp();
|
||
if (!localIp) {
|
||
document.getElementById('lanScanText').textContent = '⚠️ 无法获取本机IP,请检查网络';
|
||
statusEl.className = 'lan-scan-status idle';
|
||
resetLanScanBtn();
|
||
return;
|
||
}
|
||
|
||
const subnet = localIp.split('.').slice(0, 3).join('.');
|
||
const promises = [];
|
||
|
||
for (let i = 1; i <= 254; i++) {
|
||
const ip = subnet + '.' + i;
|
||
if (ip === localIp) continue;
|
||
promises.push(probeLanDevice(ip));
|
||
}
|
||
|
||
let completed = 0;
|
||
const total = promises.length;
|
||
|
||
for (const p of promises) {
|
||
p.then(() => {
|
||
completed++;
|
||
const pct = (completed / total * 100).toFixed(0);
|
||
document.getElementById('lanScanProgressBar').style.width = pct + '%';
|
||
}).catch(() => {
|
||
completed++;
|
||
const pct = (completed / total * 100).toFixed(0);
|
||
document.getElementById('lanScanProgressBar').style.width = pct + '%';
|
||
});
|
||
}
|
||
|
||
await Promise.allSettled(promises);
|
||
|
||
document.getElementById('lanScanProgressBar').style.width = '100%';
|
||
|
||
if (lanDevices.length > 0) {
|
||
statusEl.className = 'lan-scan-status done';
|
||
document.getElementById('lanScanText').textContent = '✅ 发现 ' + lanDevices.length + ' 个设备';
|
||
} else {
|
||
statusEl.className = 'lan-scan-status idle';
|
||
document.getElementById('lanScanText').textContent = '📭 未发现局域网设备,确保同WiFi下';
|
||
}
|
||
|
||
renderLanDeviceList();
|
||
} catch (e) {
|
||
statusEl.className = 'lan-scan-status idle';
|
||
document.getElementById('lanScanText').textContent = '❌ 扫描失败: ' + e.message;
|
||
} finally {
|
||
document.getElementById('lanScanProgress').style.display = 'none';
|
||
resetLanScanBtn();
|
||
}
|
||
}
|
||
|
||
function resetLanScanBtn() {
|
||
const btn = document.getElementById('lanScanBtn');
|
||
btn.disabled = false;
|
||
btn.textContent = '🔍 扫描局域网设备';
|
||
}
|
||
|
||
async function getLocalIp() {
|
||
try {
|
||
const pc = new RTCPeerConnection({ iceServers: [] });
|
||
pc.createDataChannel('');
|
||
const offer = await pc.createOffer();
|
||
await pc.setLocalDescription(offer);
|
||
|
||
return new Promise((resolve) => {
|
||
const timeout = setTimeout(() => {
|
||
pc.close();
|
||
resolve(null);
|
||
}, 3000);
|
||
|
||
pc.onicecandidate = (event) => {
|
||
if (!event.candidate) return;
|
||
const parts = event.candidate.candidate.split(' ');
|
||
const ip = parts[4];
|
||
if (ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !ip.startsWith('0.')) {
|
||
clearTimeout(timeout);
|
||
pc.close();
|
||
resolve(ip);
|
||
}
|
||
};
|
||
});
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function probeLanDevice(ip) {
|
||
try {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), LAN_SCAN_TIMEOUT);
|
||
|
||
const resp = await fetch('http://' + ip + ':' + LAN_SCAN_PORT + '/api/localsend/v2/info', {
|
||
signal: controller.signal
|
||
});
|
||
clearTimeout(timer);
|
||
|
||
if (!resp.ok) return;
|
||
|
||
const info = await resp.json();
|
||
const device = {
|
||
id: info.fingerprint || ip,
|
||
alias: info.alias || '闲言设备',
|
||
deviceModel: info.deviceModel || null,
|
||
deviceType: info.deviceType || 'mobile',
|
||
ip: ip,
|
||
port: info.port || LAN_SCAN_PORT,
|
||
fingerprint: info.fingerprint || null,
|
||
online: true
|
||
};
|
||
|
||
const exists = lanDevices.find(d => d.id === device.id);
|
||
if (!exists) {
|
||
lanDevices.push(device);
|
||
renderLanDeviceList();
|
||
}
|
||
} catch (e) {
|
||
}
|
||
}
|
||
|
||
function renderLanDeviceList() {
|
||
renderDeviceList(lanDevices, 'lanDeviceListContainer', 'lan');
|
||
}
|
||
|
||
function onAccountFileSelected(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
selectedFile = file;
|
||
const preview = document.getElementById('selectedFilePreview');
|
||
preview.innerHTML = '<div class="selected-file">' +
|
||
'<div class="icon">' + getFileIcon(file.name) + '</div>' +
|
||
'<div class="info"><div class="name">' + escapeHtml(file.name) + '</div>' +
|
||
'<div class="size">' + formatSize(file.size) + '</div></div>' +
|
||
'<button class="remove" onclick="clearSelectedFile()">✕</button></div>';
|
||
document.getElementById('sendFileBtn').style.display = 'block';
|
||
}
|
||
|
||
function clearSelectedFile() {
|
||
selectedFile = null;
|
||
document.getElementById('accountFileInput').value = '';
|
||
document.getElementById('selectedFilePreview').innerHTML = '';
|
||
document.getElementById('sendFileBtn').style.display = 'none';
|
||
}
|
||
|
||
function onLanFileSelected(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
lanSelectedFile = file;
|
||
const preview = document.getElementById('lanSelectedFilePreview');
|
||
preview.innerHTML = '<div class="selected-file">' +
|
||
'<div class="icon">' + getFileIcon(file.name) + '</div>' +
|
||
'<div class="info"><div class="name">' + escapeHtml(file.name) + '</div>' +
|
||
'<div class="size">' + formatSize(file.size) + '</div></div>' +
|
||
'<button class="remove" onclick="clearLanSelectedFile()">✕</button></div>';
|
||
document.getElementById('lanSendFileBtn').style.display = 'block';
|
||
}
|
||
|
||
function clearLanSelectedFile() {
|
||
lanSelectedFile = null;
|
||
document.getElementById('lanFileInput').value = '';
|
||
document.getElementById('lanSelectedFilePreview').innerHTML = '';
|
||
document.getElementById('lanSendFileBtn').style.display = 'none';
|
||
}
|
||
|
||
async function sendSelectedFile() {
|
||
if (!selectedFile || !selectedDeviceId) return;
|
||
await connectWithFallback(selectedDeviceId, selectedFile);
|
||
}
|
||
|
||
async function connectWithFallback(deviceId, file) {
|
||
transferCancelFlag = false;
|
||
showTransferOverlay(file.name, file.size);
|
||
|
||
try {
|
||
const lanOk = await tryLanDirect(deviceId, file);
|
||
if (lanOk || transferCancelFlag) return;
|
||
} catch (e) { }
|
||
|
||
if (file.size <= SMALL_FILE_THRESHOLD) {
|
||
try {
|
||
await sendViaWsRelay(deviceId, file);
|
||
return;
|
||
} catch (e) { }
|
||
}
|
||
|
||
try {
|
||
await sendViaWebRTC(deviceId, file);
|
||
return;
|
||
} catch (e) { }
|
||
|
||
if (file.size > SMALL_FILE_THRESHOLD) {
|
||
try {
|
||
await sendViaWsRelay(deviceId, file);
|
||
return;
|
||
} catch (e) { }
|
||
}
|
||
|
||
hideTransferOverlay();
|
||
alert('传输失败:所有通道均不可用');
|
||
}
|
||
|
||
async function tryLanDirect(deviceId, file) {
|
||
const device = discoveredDevices.find(d => (d.peerId || d.deviceId || d.device_id) === deviceId);
|
||
if (!device || !device.ip) return false;
|
||
|
||
try {
|
||
const resp = await fetch('http://' + device.ip + ':53618/api/info', {
|
||
signal: AbortSignal.timeout(3000)
|
||
});
|
||
if (!resp.ok) return false;
|
||
const info = await resp.json();
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const uploadResp = await fetch('http://' + device.ip + ':53618/api/send-file', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
return uploadResp.ok;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function sendViaWsRelay(deviceId, file) {
|
||
if (!accountWs || accountWs.readyState !== WebSocket.OPEN) {
|
||
throw new Error('信令服务未连接');
|
||
}
|
||
|
||
const meta = {
|
||
type: 'file-meta',
|
||
name: file.name,
|
||
size: file.size,
|
||
mimeType: file.type || 'application/octet-stream',
|
||
timestamp: Date.now()
|
||
};
|
||
|
||
accountWs.send(JSON.stringify({
|
||
type: 'wsRelay',
|
||
targetId: deviceId,
|
||
data: {
|
||
relayType: 'file-meta',
|
||
payload: meta
|
||
}
|
||
}));
|
||
|
||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||
let sentChunks = 0;
|
||
let startTime = Date.now();
|
||
|
||
for (let i = 0; i < totalChunks; i++) {
|
||
if (transferCancelFlag) throw new Error('已取消');
|
||
|
||
const start = i * CHUNK_SIZE;
|
||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||
const blob = file.slice(start, end);
|
||
const buffer = await blob.arrayBuffer();
|
||
const base64 = arrayBufferToBase64(buffer);
|
||
|
||
accountWs.send(JSON.stringify({
|
||
type: 'wsRelay',
|
||
targetId: deviceId,
|
||
data: {
|
||
relayType: 'file-chunk',
|
||
payload: {
|
||
index: i,
|
||
totalChunks: totalChunks,
|
||
data: base64
|
||
}
|
||
}
|
||
}));
|
||
|
||
sentChunks++;
|
||
const sentBytes = Math.min(sentChunks * CHUNK_SIZE, file.size);
|
||
const elapsed = (Date.now() - startTime) / 1000;
|
||
const speed = elapsed > 0 ? sentBytes / elapsed : 0;
|
||
updateTransferProgress(sentBytes, file.size, speed);
|
||
|
||
if (i % 5 === 0) {
|
||
await new Promise(r => setTimeout(r, 10));
|
||
}
|
||
}
|
||
|
||
accountWs.send(JSON.stringify({
|
||
type: 'wsRelay',
|
||
targetId: deviceId,
|
||
data: {
|
||
relayType: 'file-end',
|
||
payload: { name: file.name, size: file.size }
|
||
}
|
||
}));
|
||
|
||
hideTransferOverlay();
|
||
}
|
||
|
||
async function sendViaWebRTC(deviceId, file) {
|
||
if (!accountWs || accountWs.readyState !== WebSocket.OPEN) {
|
||
throw new Error('信令服务未连接');
|
||
}
|
||
|
||
const turnResp = await fetch(API_BASE + '/turn_credentials', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Device-Id': 'web_' + Date.now() },
|
||
body: JSON.stringify({ fingerprint: 'web-client' })
|
||
});
|
||
const turnData = await turnResp.json();
|
||
const config = { iceServers: iceConfig.iceServers };
|
||
if (turnData.data && turnData.data.iceServers) {
|
||
config.iceServers = turnData.data.iceServers;
|
||
}
|
||
|
||
sendPeerConnection = new RTCPeerConnection(config);
|
||
sendDataChannel = sendPeerConnection.createDataChannel('fileTransfer', {
|
||
ordered: true,
|
||
maxRetransmits: 10
|
||
});
|
||
sendDataChannel.binaryType = 'arraybuffer';
|
||
|
||
const channelReady = new Promise((resolve, reject) => {
|
||
sendDataChannel.onopen = () => {
|
||
console.log('WebRTC DataChannel opened');
|
||
resolve();
|
||
};
|
||
sendDataChannel.onerror = (e) => reject(new Error('DataChannel错误'));
|
||
setTimeout(() => reject(new Error('DataChannel超时(30s)')), 30000);
|
||
});
|
||
|
||
sendPeerConnection.onicecandidate = (event) => {
|
||
if (event.candidate && accountWs && accountWs.readyState === WebSocket.OPEN) {
|
||
accountWs.send(JSON.stringify({
|
||
type: 'iceCandidate',
|
||
targetId: deviceId,
|
||
data: {
|
||
candidate: event.candidate.candidate,
|
||
sdpMid: event.candidate.sdpMid,
|
||
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
||
}
|
||
}));
|
||
}
|
||
};
|
||
|
||
sendPeerConnection.onconnectionstatechange = () => {
|
||
const state = sendPeerConnection?.connectionState;
|
||
if (state === 'failed' || state === 'disconnected') {
|
||
console.warn('WebRTC connection state:', state);
|
||
}
|
||
};
|
||
|
||
const offer = await sendPeerConnection.createOffer();
|
||
await sendPeerConnection.setLocalDescription(offer);
|
||
|
||
accountWs.send(JSON.stringify({
|
||
type: 'offer',
|
||
targetId: deviceId,
|
||
data: { sdp: offer.sdp, type: offer.type }
|
||
}));
|
||
|
||
await channelReady;
|
||
|
||
const meta = JSON.stringify({
|
||
type: 'file-meta',
|
||
name: file.name,
|
||
size: file.size,
|
||
mimeType: file.type || 'application/octet-stream'
|
||
});
|
||
sendDataChannel.send(meta);
|
||
|
||
let offset = 0;
|
||
let startTime = Date.now();
|
||
|
||
while (offset < file.size) {
|
||
if (transferCancelFlag) {
|
||
sendPeerConnection.close();
|
||
throw new Error('已取消');
|
||
}
|
||
|
||
const end = Math.min(offset + CHUNK_SIZE, file.size);
|
||
const blob = file.slice(offset, end);
|
||
const buffer = await blob.arrayBuffer();
|
||
|
||
while (sendDataChannel.bufferedAmount > CHUNK_SIZE * 8) {
|
||
await new Promise(r => setTimeout(r, 20));
|
||
}
|
||
|
||
sendDataChannel.send(buffer);
|
||
offset = end;
|
||
|
||
const elapsed = (Date.now() - startTime) / 1000;
|
||
const speed = elapsed > 0 ? offset / elapsed : 0;
|
||
updateTransferProgress(offset, file.size, speed);
|
||
}
|
||
|
||
const endMsg = JSON.stringify({ type: 'file-end', name: file.name });
|
||
sendDataChannel.send(endMsg);
|
||
|
||
setTimeout(() => {
|
||
if (sendPeerConnection) sendPeerConnection.close();
|
||
sendPeerConnection = null;
|
||
sendDataChannel = null;
|
||
}, 2000);
|
||
|
||
hideTransferOverlay();
|
||
}
|
||
|
||
async function sendLanFile() {
|
||
if (!lanSelectedFile || !lanSelectedDevice) return;
|
||
|
||
const device = lanDevices.find(d => d.id === lanSelectedDevice);
|
||
if (!device || !device.ip) {
|
||
alert('设备信息不完整');
|
||
return;
|
||
}
|
||
|
||
transferCancelFlag = false;
|
||
showTransferOverlay(lanSelectedFile.name, lanSelectedFile.size);
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', lanSelectedFile);
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', 'http://' + device.ip + ':' + device.port + '/api/send-file');
|
||
|
||
const done = new Promise((resolve, reject) => {
|
||
xhr.upload.onprogress = (e) => {
|
||
if (e.lengthComputable) {
|
||
const speed = 0;
|
||
updateTransferProgress(e.loaded, e.total, speed);
|
||
}
|
||
};
|
||
xhr.onload = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||
else reject(new Error('HTTP ' + xhr.status));
|
||
};
|
||
xhr.onerror = () => reject(new Error('网络错误'));
|
||
xhr.onabort = () => reject(new Error('已取消'));
|
||
});
|
||
|
||
xhr.send(formData);
|
||
await done;
|
||
hideTransferOverlay();
|
||
} catch (e) {
|
||
if (transferCancelFlag) {
|
||
hideTransferOverlay();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const prepareResp = await fetch('http://' + device.ip + ':' + device.port + '/api/localsend/v2/prepare-upload', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
info: {
|
||
alias: 'Web浏览器',
|
||
deviceType: 'web',
|
||
fingerprint: 'web_' + Date.now()
|
||
},
|
||
files: {
|
||
'file_1': {
|
||
id: 'file_1',
|
||
fileName: lanSelectedFile.name,
|
||
size: lanSelectedFile.size,
|
||
mimeType: lanSelectedFile.type || 'application/octet-stream'
|
||
}
|
||
}
|
||
})
|
||
});
|
||
|
||
if (!prepareResp.ok) throw new Error('prepare-upload失败');
|
||
|
||
const formData = new FormData();
|
||
formData.append('file_1', lanSelectedFile);
|
||
|
||
const uploadResp = await fetch('http://' + device.ip + ':' + device.port + '/api/localsend/v2/upload/file_1', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (!uploadResp.ok) throw new Error('upload失败');
|
||
hideTransferOverlay();
|
||
} catch (e2) {
|
||
hideTransferOverlay();
|
||
alert('局域网传输失败: ' + e2.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleSendOffer(msg) {
|
||
if (!sendPeerConnection) {
|
||
sendPeerConnection = new RTCPeerConnection(iceConfig);
|
||
sendPeerConnection.ondatachannel = (event) => {
|
||
sendDataChannel = event.channel;
|
||
setupSendDataChannel();
|
||
};
|
||
sendPeerConnection.onicecandidate = (event) => {
|
||
if (event.candidate && accountWs && accountWs.readyState === WebSocket.OPEN) {
|
||
accountWs.send(JSON.stringify({
|
||
type: 'iceCandidate',
|
||
targetId: msg.fromId || msg.from,
|
||
data: {
|
||
candidate: event.candidate.candidate,
|
||
sdpMid: event.candidate.sdpMid,
|
||
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
||
}
|
||
}));
|
||
}
|
||
};
|
||
}
|
||
|
||
sendPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.data))
|
||
.then(() => sendPeerConnection.createAnswer())
|
||
.then(answer => sendPeerConnection.setLocalDescription(answer))
|
||
.then(() => {
|
||
accountWs.send(JSON.stringify({
|
||
type: 'answer',
|
||
targetId: msg.fromId || msg.from,
|
||
data: { sdp: sendPeerConnection.localDescription.sdp, type: sendPeerConnection.localDescription.type }
|
||
}));
|
||
});
|
||
}
|
||
|
||
function handleSendAnswer(msg) {
|
||
if (sendPeerConnection && msg.data) {
|
||
sendPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.data));
|
||
}
|
||
}
|
||
|
||
function handleSendIceCandidate(msg) {
|
||
if (sendPeerConnection && msg.data) {
|
||
sendPeerConnection.addIceCandidate(new RTCIceCandidate(msg.data)).catch(() => {});
|
||
}
|
||
}
|
||
|
||
function setupSendDataChannel() {
|
||
sendDataChannel.binaryType = 'arraybuffer';
|
||
let meta = null;
|
||
let buffer = [];
|
||
let received = 0;
|
||
|
||
sendDataChannel.onmessage = (event) => {
|
||
if (typeof event.data === 'string') {
|
||
const parsed = JSON.parse(event.data);
|
||
if (parsed.type === 'file-meta') {
|
||
meta = parsed;
|
||
buffer = [];
|
||
received = 0;
|
||
} else if (parsed.type === 'file-end') {
|
||
const blob = new Blob(buffer);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = meta ? meta.name : 'file';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
buffer = [];
|
||
received = 0;
|
||
} else if (parsed.type === 'text') {
|
||
addChatMessage('account', parsed.content || parsed.text || '', false);
|
||
}
|
||
} else {
|
||
buffer.push(event.data);
|
||
received += event.data.byteLength;
|
||
if (meta) {
|
||
updateReceiveProgress(meta.name, received, meta.size);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
function handleWsRelayReceive(msg) {
|
||
const relayType = msg.data?.relayType;
|
||
const payload = msg.data?.payload;
|
||
const fromId = msg.fromId || msg.from;
|
||
|
||
if (relayType === 'file-meta') {
|
||
currentFileMeta = payload;
|
||
receiveBuffer = [];
|
||
receiveSize = 0;
|
||
addAccountFileItem(payload);
|
||
} else if (relayType === 'file-chunk') {
|
||
const binary = base64ToArrayBuffer(payload.data);
|
||
receiveBuffer.push(binary);
|
||
receiveSize += binary.byteLength;
|
||
if (currentFileMeta) {
|
||
updateReceiveProgress(currentFileMeta.name, receiveSize, currentFileMeta.size);
|
||
}
|
||
} else if (relayType === 'file-end') {
|
||
if (receiveBuffer.length > 0) {
|
||
const blob = new Blob(receiveBuffer);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = payload.name || (currentFileMeta ? currentFileMeta.name : 'file');
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
receiveBuffer = [];
|
||
receiveSize = 0;
|
||
} else if (relayType === 'text') {
|
||
const text = payload.text || payload.content || '';
|
||
addChatMessage('account', text, false);
|
||
}
|
||
}
|
||
|
||
function handleTransportNegotiate(msg) {
|
||
if (accountWs && accountWs.readyState === WebSocket.OPEN) {
|
||
accountWs.send(JSON.stringify({
|
||
type: 'transportNegotiateResponse',
|
||
targetId: msg.fromId || msg.from,
|
||
data: {
|
||
accepted: true,
|
||
transport: msg.data?.transport || 'wsRelay'
|
||
}
|
||
}));
|
||
}
|
||
}
|
||
|
||
function sendChatMessage(mode) {
|
||
let inputId, wsTarget;
|
||
if (mode === 'account') {
|
||
inputId = 'accountChatInput';
|
||
wsTarget = selectedDeviceId;
|
||
} else {
|
||
inputId = 'lanChatInput';
|
||
wsTarget = null;
|
||
}
|
||
|
||
const input = document.getElementById(inputId);
|
||
const text = input.value.trim();
|
||
if (!text) return;
|
||
|
||
addChatMessage(mode, text, true);
|
||
input.value = '';
|
||
|
||
if (mode === 'account' && accountWs && accountWs.readyState === WebSocket.OPEN && wsTarget) {
|
||
accountWs.send(JSON.stringify({
|
||
type: 'wsRelay',
|
||
targetId: wsTarget,
|
||
data: {
|
||
relayType: 'text',
|
||
payload: { text: text, timestamp: Date.now() }
|
||
}
|
||
}));
|
||
} else if (mode === 'lan' && lanSelectedDevice) {
|
||
const device = lanDevices.find(d => d.id === lanSelectedDevice);
|
||
if (device && device.ip) {
|
||
fetch('http://' + device.ip + ':' + device.port + '/api/send-text', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: text })
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
function addChatMessage(mode, text, isSent) {
|
||
const msg = { text, isSent, time: new Date() };
|
||
chatMessages[mode].push(msg);
|
||
|
||
let containerId;
|
||
if (mode === 'account') containerId = 'accountChatMessages';
|
||
else containerId = 'lanChatMessages';
|
||
|
||
const container = document.getElementById(containerId);
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'chat-bubble ' + (isSent ? 'sent' : 'received');
|
||
bubble.innerHTML = '<div>' + escapeHtml(text) + '</div><div class="time">' + formatTime(msg.time) + '</div>';
|
||
container.appendChild(bubble);
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function closeChat(mode) {
|
||
if (mode === 'account') {
|
||
document.getElementById('accountChatContainer').classList.remove('active');
|
||
} else {
|
||
document.getElementById('lanChatContainer').classList.remove('active');
|
||
}
|
||
}
|
||
|
||
function arrayBufferToBase64(buffer) {
|
||
const bytes = new Uint8Array(buffer);
|
||
let binary = '';
|
||
const chunkSize = 8192;
|
||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||
const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
|
||
binary += String.fromCharCode.apply(null, chunk);
|
||
}
|
||
return btoa(binary);
|
||
}
|
||
|
||
function base64ToArrayBuffer(base64) {
|
||
const binary = atob(base64);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) {
|
||
bytes[i] = binary.charCodeAt(i);
|
||
}
|
||
return bytes.buffer;
|
||
}
|
||
|
||
function showTransferOverlay(fileName, totalSize) {
|
||
document.getElementById('transferTitle').textContent = '正在传输';
|
||
document.getElementById('transferSubtitle').textContent = fileName;
|
||
document.getElementById('transferPct').textContent = '0%';
|
||
document.getElementById('transferSpeed').textContent = '—';
|
||
document.getElementById('transferTransferred').textContent = '0 B';
|
||
document.getElementById('transferTotal').textContent = formatSize(totalSize);
|
||
document.getElementById('transferCircle').style.strokeDashoffset = '326.73';
|
||
document.getElementById('transferOverlay').classList.add('active');
|
||
}
|
||
|
||
function updateTransferProgress(sent, total, speed) {
|
||
const pct = total > 0 ? Math.min(100, (sent / total * 100)) : 0;
|
||
document.getElementById('transferPct').textContent = pct.toFixed(0) + '%';
|
||
document.getElementById('transferSpeed').textContent = formatSpeed(speed);
|
||
document.getElementById('transferTransferred').textContent = formatSize(Math.min(sent, total));
|
||
const offset = 326.73 - (326.73 * pct / 100);
|
||
document.getElementById('transferCircle').style.strokeDashoffset = offset;
|
||
if (pct >= 100) {
|
||
document.getElementById('transferTitle').textContent = '传输完成 ✅';
|
||
}
|
||
}
|
||
|
||
function updateReceiveProgress(name, received, total) {
|
||
const id = 'acct-file-' + name.replace(/[^a-zA-Z0-9]/g, '_');
|
||
const item = document.getElementById(id);
|
||
if (item) {
|
||
const pct = total > 0 ? Math.min(100, (received / total * 100)).toFixed(1) : 0;
|
||
const bar = item.querySelector('.file-progress-bar');
|
||
if (bar) bar.style.width = pct + '%';
|
||
const sizeEl = item.querySelector('.file-size');
|
||
if (sizeEl) sizeEl.textContent = formatSize(received) + ' / ' + formatSize(total);
|
||
}
|
||
}
|
||
|
||
function addAccountFileItem(meta) {
|
||
const list = document.getElementById('pickupFilesList');
|
||
const item = document.createElement('div');
|
||
item.className = 'file-item';
|
||
item.id = 'acct-file-' + meta.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||
item.innerHTML = '<div class="file-icon">' + getFileIcon(meta.name) + '</div>' +
|
||
'<div class="file-info"><div class="file-name">' + escapeHtml(meta.name) + '</div>' +
|
||
'<div class="file-size">' + formatSize(meta.size) + '</div>' +
|
||
'<div class="file-progress"><div class="file-progress-bar" style="width:0%"></div></div></div>';
|
||
list.appendChild(item);
|
||
}
|
||
|
||
function hideTransferOverlay() {
|
||
setTimeout(() => {
|
||
document.getElementById('transferOverlay').classList.remove('active');
|
||
}, 800);
|
||
}
|
||
|
||
function cancelTransfer() {
|
||
transferCancelFlag = true;
|
||
if (sendPeerConnection) {
|
||
sendPeerConnection.close();
|
||
sendPeerConnection = null;
|
||
sendDataChannel = null;
|
||
}
|
||
document.getElementById('transferTitle').textContent = '已取消';
|
||
hideTransferOverlay();
|
||
}
|
||
|
||
async function connectRoom() {
|
||
const code = document.getElementById('roomCode').value.toUpperCase().trim();
|
||
if (code.length !== 6) {
|
||
showStatus('pickupStatus', 'error', '❌ 请输入6位取件码');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('connectBtn');
|
||
btn.disabled = true;
|
||
showStatus('pickupStatus', 'connecting', '⏳ 正在连接...');
|
||
|
||
try {
|
||
const infoResp = await fetch(API_BASE + '/room_status?code=' + code);
|
||
const infoData = await infoResp.json();
|
||
if (!infoData.data || !infoData.data.exists) {
|
||
showStatus('pickupStatus', 'error', '❌ 房间不存在或已过期');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
const turnResp = await fetch(API_BASE + '/turn_credentials', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Device-Id': 'web_' + Date.now() },
|
||
body: JSON.stringify({ fingerprint: 'web-client' })
|
||
});
|
||
const turnData = await turnResp.json();
|
||
if (turnData.data && turnData.data.iceServers) {
|
||
iceConfig.iceServers = turnData.data.iceServers;
|
||
}
|
||
|
||
connectWebSocket(code);
|
||
} catch (e) {
|
||
showStatus('pickupStatus', 'error', '❌ 连接失败: ' + e.message);
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function connectWebSocket(code) {
|
||
ws = new WebSocket(WS_URL);
|
||
|
||
ws.onopen = () => {
|
||
ws.send(JSON.stringify({
|
||
type: 'register',
|
||
data: {
|
||
deviceId: 'web_' + Date.now(),
|
||
alias: 'Web浏览器',
|
||
deviceType: 'browser',
|
||
protocol: 'xianyan-v1',
|
||
}
|
||
}));
|
||
ws.send(JSON.stringify({
|
||
type: 'join_room',
|
||
data: { code: code, role: 'receiver' }
|
||
}));
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
const msg = JSON.parse(event.data);
|
||
handleWsMessage(msg);
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
showStatus('pickupStatus', 'error', '❌ WebSocket连接失败');
|
||
document.getElementById('connectBtn').disabled = false;
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
showStatus('pickupStatus', 'error', '连接已断开');
|
||
document.getElementById('connectBtn').disabled = false;
|
||
};
|
||
}
|
||
|
||
function handleWsMessage(msg) {
|
||
switch (msg.type) {
|
||
case 'registered':
|
||
break;
|
||
case 'room_joined':
|
||
showStatus('pickupStatus', 'connecting', '⏳ 已加入房间,等待发送方...');
|
||
break;
|
||
case 'peer-joined':
|
||
showStatus('pickupStatus', 'connected', '✅ 发送方已连接,准备接收');
|
||
createPeerConnection();
|
||
break;
|
||
case 'offer':
|
||
handleOffer(msg);
|
||
break;
|
||
case 'iceCandidate':
|
||
handleIceCandidate(msg);
|
||
break;
|
||
case 'disconnection':
|
||
showStatus('pickupStatus', 'error', '对方已断开连接');
|
||
break;
|
||
case 'error':
|
||
showStatus('pickupStatus', 'error', '❌ ' + (msg.message || msg.data?.message || '未知错误'));
|
||
document.getElementById('connectBtn').disabled = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
function createPeerConnection() {
|
||
peerConnection = new RTCPeerConnection(iceConfig);
|
||
|
||
peerConnection.ondatachannel = (event) => {
|
||
dataChannel = event.channel;
|
||
setupDataChannel();
|
||
};
|
||
|
||
peerConnection.onicecandidate = (event) => {
|
||
if (event.candidate && ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({
|
||
type: 'iceCandidate',
|
||
targetId: findSenderId(),
|
||
data: {
|
||
candidate: event.candidate.candidate,
|
||
sdpMid: event.candidate.sdpMid,
|
||
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
||
}
|
||
}));
|
||
}
|
||
};
|
||
|
||
peerConnection.onconnectionstatechange = () => {
|
||
if (peerConnection.connectionState === 'connected') {
|
||
showStatus('pickupStatus', 'connected', '✅ P2P连接已建立');
|
||
} else if (peerConnection.connectionState === 'disconnected') {
|
||
showStatus('pickupStatus', 'error', 'P2P连接已断开');
|
||
}
|
||
};
|
||
}
|
||
|
||
function setupDataChannel() {
|
||
dataChannel.binaryType = 'arraybuffer';
|
||
|
||
dataChannel.onmessage = (event) => {
|
||
if (typeof event.data === 'string') {
|
||
const meta = JSON.parse(event.data);
|
||
if (meta.type === 'file-meta') {
|
||
currentFileMeta = meta;
|
||
receiveBuffer = [];
|
||
receiveSize = 0;
|
||
addFileItem(meta);
|
||
} else if (meta.type === 'file-end') {
|
||
saveFile();
|
||
}
|
||
} else {
|
||
receiveBuffer.push(event.data);
|
||
receiveSize += event.data.byteLength;
|
||
updateProgress(currentFileMeta?.name || 'unknown', receiveSize, currentFileMeta?.size || 0);
|
||
}
|
||
};
|
||
}
|
||
|
||
async function handleOffer(msg) {
|
||
if (!peerConnection) createPeerConnection();
|
||
|
||
try {
|
||
await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.data));
|
||
const answer = await peerConnection.createAnswer();
|
||
await peerConnection.setLocalDescription(answer);
|
||
|
||
ws.send(JSON.stringify({
|
||
type: 'answer',
|
||
targetId: msg.fromId,
|
||
data: { sdp: answer.sdp, type: answer.type }
|
||
}));
|
||
} catch (e) {
|
||
showStatus('pickupStatus', 'error', 'WebRTC协商失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function handleIceCandidate(msg) {
|
||
if (peerConnection && msg.data) {
|
||
try {
|
||
await peerConnection.addIceCandidate(new RTCIceCandidate(msg.data));
|
||
} catch (e) { }
|
||
}
|
||
}
|
||
|
||
function findSenderId() { return null; }
|
||
|
||
function addFileItem(meta) {
|
||
const list = document.getElementById('pickupFilesList');
|
||
const item = document.createElement('div');
|
||
item.className = 'file-item';
|
||
item.id = 'file-' + meta.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||
item.innerHTML = '<div class="file-icon">' + getFileIcon(meta.name) + '</div>' +
|
||
'<div class="file-info"><div class="file-name">' + escapeHtml(meta.name) + '</div>' +
|
||
'<div class="file-size">' + formatSize(meta.size) + '</div>' +
|
||
'<div class="file-progress"><div class="file-progress-bar" style="width:0%"></div></div></div>';
|
||
list.appendChild(item);
|
||
}
|
||
|
||
function updateProgress(name, received, total) {
|
||
const id = 'file-' + name.replace(/[^a-zA-Z0-9]/g, '_');
|
||
const item = document.getElementById(id);
|
||
if (item) {
|
||
const pct = total > 0 ? Math.min(100, (received / total * 100)).toFixed(1) : 0;
|
||
const bar = item.querySelector('.file-progress-bar');
|
||
if (bar) bar.style.width = pct + '%';
|
||
const sizeEl = item.querySelector('.file-size');
|
||
if (sizeEl) sizeEl.textContent = formatSize(received) + ' / ' + formatSize(total);
|
||
}
|
||
}
|
||
|
||
function saveFile() {
|
||
if (!currentFileMeta || receiveBuffer.length === 0) return;
|
||
const blob = new Blob(receiveBuffer);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = currentFileMeta.name;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
receiveBuffer = [];
|
||
receiveSize = 0;
|
||
}
|
||
|
||
function restoreLogin() {
|
||
const token = localStorage.getItem('xy_transfer_token');
|
||
const userId = localStorage.getItem('xy_transfer_userId');
|
||
const username = localStorage.getItem('xy_transfer_username');
|
||
if (token && userId) {
|
||
accountState = { token, userId, username: username || '用户', peerId: null };
|
||
showAccountPanel();
|
||
connectSignaling(token);
|
||
switchTab('account');
|
||
}
|
||
}
|
||
|
||
document.getElementById('roomCode').addEventListener('input', function(e) {
|
||
this.value = this.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||
});
|
||
|
||
document.getElementById('roomCode').addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') connectRoom();
|
||
});
|
||
|
||
document.getElementById('loginPassword').addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') loginWithAccount();
|
||
});
|
||
|
||
document.addEventListener('dragover', function(e) {
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('drop', function(e) {
|
||
e.preventDefault();
|
||
const activeTab = document.querySelector('.tab-item.active');
|
||
if (!activeTab) return;
|
||
const tabs = ['pickup', 'account', 'lan'];
|
||
const idx = Array.from(document.querySelectorAll('.tab-item')).indexOf(activeTab);
|
||
const tab = tabs[idx];
|
||
|
||
if (tab === 'account' && selectedDeviceId && e.dataTransfer.files.length > 0) {
|
||
selectedFile = e.dataTransfer.files[0];
|
||
document.getElementById('accountFileInput').files = e.dataTransfer.files;
|
||
onAccountFileSelected({ target: { files: e.dataTransfer.files } });
|
||
} else if (tab === 'lan' && lanSelectedDevice && e.dataTransfer.files.length > 0) {
|
||
lanSelectedFile = e.dataTransfer.files[0];
|
||
document.getElementById('lanFileInput').files = e.dataTransfer.files;
|
||
onLanFileSelected({ target: { files: e.dataTransfer.files } });
|
||
}
|
||
});
|
||
|
||
restoreLogin();
|