Files
xianyan/docs/toolsapi/public/transfer.js
Developer 228095f80a chore: 完成v6.7.0版本迭代更新
本次更新涵盖多个功能模块的优化与新增:
1. 新增Wi-Fi直连配对方式与协作画布模块
2. 完成设备管理重命名API与前端适配
3. 优化日签卡片空数据保护与UI细节
4. 新增剪贴板工具与每日运势会话
5. 修复应用锁恢复、语音消息录制等已知问题
6. 完善文件传输统计与配对逻辑
7. 更新安卓权限配置与iOS隐私描述
8. 新增自动化测试脚本与文档
9. 清理旧版审计报告与测试文件
2026-05-14 05:35:18 +08:00

1373 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 闲言传输 — 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();