import React, { useEffect, useRef, useState } from 'react'; import { Archive, CheckCircle, CheckSquare, DownloadCloud, File as FileIcon, Loader2, RefreshCcw, Shield, Square, } from 'lucide-react'; import { useSearchParams } from 'react-router-dom'; import { useAuth } from '@/src/auth/AuthProvider'; import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal'; import { Button } from '@/src/components/ui/button'; import { Input } from '@/src/components/ui/input'; import { buildTransferArchiveFileName, createTransferZipArchive } from '@/src/lib/transfer-archive'; import { resolveNetdiskSaveDirectory, saveFileToNetdisk } from '@/src/lib/netdisk-upload'; import { createTransferReceiveRequestMessage, parseTransferControlMessage, SIGNAL_POLL_INTERVAL_MS, toTransferChunk, type TransferFileDescriptor, } from '@/src/lib/transfer-protocol'; import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling'; import { DEFAULT_TRANSFER_ICE_SERVERS, joinTransferSession, lookupTransferSession, pollTransferSignals, postTransferSignal } from '@/src/lib/transfer'; import type { TransferSessionResponse } from '@/src/lib/types'; import { canArchiveTransferSelection, formatTransferSize, sanitizeReceiveCode } from './transfer-state'; type ReceivePhase = 'idle' | 'joining' | 'waiting' | 'connecting' | 'receiving' | 'completed' | 'error'; interface DownloadableFile extends TransferFileDescriptor { progress: number; selected: boolean; requested: boolean; downloadUrl?: string; savedToNetdisk?: boolean; } interface IncomingTransferFile extends TransferFileDescriptor { chunks: Uint8Array[]; receivedBytes: number; } function parseJsonPayload(payload: string): T | null { try { return JSON.parse(payload) as T; } catch { return null; } } interface TransferReceiveProps { embedded?: boolean; } export default function TransferReceive({ embedded = false }: TransferReceiveProps) { const { session: authSession } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const [receiveCode, setReceiveCode] = useState(searchParams.get('code') ?? ''); const [transferSession, setTransferSession] = useState(null); const [files, setFiles] = useState([]); const [phase, setPhase] = useState('idle'); const [errorMessage, setErrorMessage] = useState(''); const [overallProgress, setOverallProgress] = useState(0); const [lookupBusy, setLookupBusy] = useState(false); const [requestSubmitted, setRequestSubmitted] = useState(false); const [archiveRequested, setArchiveRequested] = useState(false); const [archiveName, setArchiveName] = useState(buildTransferArchiveFileName('快传文件')); const [archiveUrl, setArchiveUrl] = useState(null); const [savingFileId, setSavingFileId] = useState(null); const [saveMessage, setSaveMessage] = useState(''); const [savePathPickerFileId, setSavePathPickerFileId] = useState(null); const [saveRootPath, setSaveRootPath] = useState('/下载'); const peerConnectionRef = useRef(null); const dataChannelRef = useRef(null); const pollTimerRef = useRef(null); const cursorRef = useRef(0); const lifecycleIdRef = useRef(0); const currentFileIdRef = useRef(null); const totalBytesRef = useRef(0); const receivedBytesRef = useRef(0); const downloadUrlsRef = useRef([]); const requestedFileIdsRef = useRef([]); const pendingRemoteCandidatesRef = useRef([]); const archiveBuiltRef = useRef(false); const completedFilesRef = useRef(new Map()); const incomingFilesRef = useRef(new Map()); useEffect(() => { return () => { cleanupReceiver(); }; }, []); useEffect(() => { const sessionId = searchParams.get('session'); if (!sessionId) { setTransferSession(null); setFiles([]); setPhase('idle'); setOverallProgress(0); setRequestSubmitted(false); setArchiveRequested(false); setArchiveUrl(null); return; } void startReceivingSession(sessionId); }, [searchParams]); function cleanupReceiver() { if (pollTimerRef.current) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; } if (dataChannelRef.current) { dataChannelRef.current.close(); dataChannelRef.current = null; } if (peerConnectionRef.current) { peerConnectionRef.current.close(); peerConnectionRef.current = null; } for (const url of downloadUrlsRef.current) { URL.revokeObjectURL(url); } downloadUrlsRef.current = []; completedFilesRef.current.clear(); incomingFilesRef.current.clear(); currentFileIdRef.current = null; cursorRef.current = 0; receivedBytesRef.current = 0; totalBytesRef.current = 0; requestedFileIdsRef.current = []; pendingRemoteCandidatesRef.current = []; archiveBuiltRef.current = false; } async function startReceivingSession(sessionId: string) { const lifecycleId = lifecycleIdRef.current + 1; lifecycleIdRef.current = lifecycleId; cleanupReceiver(); setPhase('joining'); setErrorMessage(''); setFiles([]); setOverallProgress(0); setRequestSubmitted(false); setArchiveRequested(false); setArchiveName(buildTransferArchiveFileName('快传文件')); setArchiveUrl(null); setSavingFileId(null); setSaveMessage(''); try { const joinedSession = await joinTransferSession(sessionId); if (lifecycleIdRef.current !== lifecycleId) { return; } setTransferSession(joinedSession); setArchiveName(buildTransferArchiveFileName(`快传-${joinedSession.pickupCode}`)); const connection = new RTCPeerConnection({ iceServers: DEFAULT_TRANSFER_ICE_SERVERS, }); peerConnectionRef.current = connection; connection.onicecandidate = (event) => { if (!event.candidate) { return; } void postTransferSignal( joinedSession.sessionId, 'receiver', 'ice-candidate', JSON.stringify(event.candidate.toJSON()), ); }; connection.onconnectionstatechange = () => { if (connection.connectionState === 'connected') { setPhase((current) => (current === 'completed' ? current : 'connecting')); } if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') { setPhase('error'); setErrorMessage('浏览器之间的直连失败,请重新打开分享链接。'); } }; connection.ondatachannel = (event) => { const channel = event.channel; dataChannelRef.current = channel; channel.binaryType = 'arraybuffer'; channel.onopen = () => { setPhase((current) => (current === 'completed' ? current : 'connecting')); }; channel.onmessage = (messageEvent) => { void handleIncomingMessage(messageEvent.data); }; }; startReceiverPolling(joinedSession.sessionId, connection, lifecycleId); setPhase('waiting'); } catch (error) { if (lifecycleIdRef.current !== lifecycleId) { return; } setPhase('error'); setErrorMessage(error instanceof Error ? error.message : '快传会话打开失败'); } } function startReceiverPolling(sessionId: string, connection: RTCPeerConnection, lifecycleId: number) { let polling = false; pollTimerRef.current = window.setInterval(() => { if (polling || lifecycleIdRef.current !== lifecycleId) { return; } polling = true; void pollTransferSignals(sessionId, 'receiver', cursorRef.current) .then(async (response) => { if (lifecycleIdRef.current !== lifecycleId) { return; } cursorRef.current = response.nextCursor; for (const item of response.items) { if (item.type === 'offer') { const offer = parseJsonPayload(item.payload); if (!offer) { continue; } setPhase('connecting'); await connection.setRemoteDescription(offer); pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates( connection, pendingRemoteCandidatesRef.current, ); const answer = await connection.createAnswer(); await connection.setLocalDescription(answer); await postTransferSignal(sessionId, 'receiver', 'answer', JSON.stringify(answer)); continue; } if (item.type === 'ice-candidate') { const candidate = parseJsonPayload(item.payload); if (candidate) { pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate( connection, pendingRemoteCandidatesRef.current, candidate, ); } } } }) .catch((error) => { if (lifecycleIdRef.current !== lifecycleId) { return; } setPhase('error'); setErrorMessage(error instanceof Error ? error.message : '轮询传输信令失败'); }) .finally(() => { polling = false; }); }, SIGNAL_POLL_INTERVAL_MS); } async function finalizeArchiveDownload() { if (!archiveRequested || archiveBuiltRef.current || requestedFileIdsRef.current.length === 0) { return; } const archiveEntries = requestedFileIdsRef.current.map((fileId) => completedFilesRef.current.get(fileId)).filter(Boolean); if (archiveEntries.length !== requestedFileIdsRef.current.length) { return; } const archive = await createTransferZipArchive( archiveEntries.map((entry) => ({ name: entry.name, relativePath: entry.relativePath, data: entry.blob, })), ); const nextArchiveUrl = URL.createObjectURL(archive); downloadUrlsRef.current.push(nextArchiveUrl); archiveBuiltRef.current = true; setArchiveUrl(nextArchiveUrl); } async function handleIncomingMessage(data: string | ArrayBuffer | Blob) { if (typeof data === 'string') { const message = parseTransferControlMessage(data); if (!message) { return; } if (message.type === 'manifest') { setFiles(message.files.map((file) => ({ ...file, progress: 0, selected: true, requested: false, savedToNetdisk: false, }))); setPhase((current) => (current === 'receiving' || current === 'completed' ? current : 'waiting')); return; } if (message.type === 'file-meta') { currentFileIdRef.current = message.id; incomingFilesRef.current.set(message.id, { ...message, chunks: [], receivedBytes: 0, }); setFiles((current) => current.map((file) => file.id === message.id ? { ...file, requested: true, progress: 0, } : file, ), ); return; } if (message.type === 'file-complete' && message.id) { finalizeDownloadableFile(message.id); currentFileIdRef.current = null; await finalizeArchiveDownload(); return; } if (message.type === 'transfer-complete') { await finalizeArchiveDownload(); setOverallProgress(100); setPhase('completed'); } return; } const activeFileId = currentFileIdRef.current; if (!activeFileId) { return; } const targetFile = incomingFilesRef.current.get(activeFileId); if (!targetFile) { return; } const chunk = await toTransferChunk(data); targetFile.chunks.push(chunk); targetFile.receivedBytes += chunk.byteLength; receivedBytesRef.current += chunk.byteLength; setPhase('receiving'); if (totalBytesRef.current > 0) { setOverallProgress(Math.min(99, Math.round((receivedBytesRef.current / totalBytesRef.current) * 100))); } setFiles((current) => current.map((file) => file.id === activeFileId ? { ...file, progress: Math.min(99, Math.round((targetFile.receivedBytes / Math.max(targetFile.size, 1)) * 100)), } : file, ), ); } function finalizeDownloadableFile(fileId: string) { const targetFile = incomingFilesRef.current.get(fileId); if (!targetFile) { return; } const blob = new Blob(targetFile.chunks, { type: targetFile.contentType, }); const downloadUrl = URL.createObjectURL(blob); downloadUrlsRef.current.push(downloadUrl); completedFilesRef.current.set(fileId, { name: targetFile.name, relativePath: targetFile.relativePath, blob, contentType: targetFile.contentType, }); setFiles((current) => current.map((file) => file.id === fileId ? { ...file, progress: 100, requested: true, downloadUrl, savedToNetdisk: false, } : file, ), ); } async function saveCompletedFile(fileId: string, rootPath: string) { const completedFile = completedFilesRef.current.get(fileId); if (!completedFile) { return; } setSavingFileId(fileId); setSaveMessage(''); try { const netdiskFile = new File([completedFile.blob], completedFile.name, { type: completedFile.contentType || completedFile.blob.type || 'application/octet-stream', }); const targetPath = resolveNetdiskSaveDirectory(completedFile.relativePath, rootPath); const savedFile = await saveFileToNetdisk(netdiskFile, targetPath); setFiles((current) => current.map((file) => file.id === fileId ? { ...file, savedToNetdisk: true, } : file, ), ); setSaveMessage(`${savedFile.filename} 已存入网盘 ${savedFile.path}`); } catch (requestError) { setErrorMessage(requestError instanceof Error ? requestError.message : '存入网盘失败'); throw requestError; } finally { setSavingFileId(null); } } function toggleFileSelection(fileId: string) { if (requestSubmitted) { return; } setFiles((current) => current.map((file) => file.id === fileId ? { ...file, selected: !file.selected, } : file, ), ); } function toggleSelectAll(nextSelected: boolean) { if (requestSubmitted) { return; } setFiles((current) => current.map((file) => ({ ...file, selected: nextSelected, })), ); } async function submitReceiveRequest(archive: boolean, fileIds?: string[]) { const channel = dataChannelRef.current; if (!channel || channel.readyState !== 'open') { setPhase('error'); setErrorMessage('P2P 通道尚未准备好,请稍后再试。'); return; } const requestedIds = fileIds ?? files.filter((file) => file.selected).map((file) => file.id); if (requestedIds.length === 0) { setErrorMessage('请先选择至少一个文件。'); return; } const requestedSet = new Set(requestedIds); const requestedBytes = files .filter((file) => requestedSet.has(file.id)) .reduce((sum, file) => sum + file.size, 0); requestedFileIdsRef.current = requestedIds; totalBytesRef.current = requestedBytes; receivedBytesRef.current = 0; archiveBuiltRef.current = false; setOverallProgress(0); setArchiveRequested(archive); setArchiveUrl(null); setRequestSubmitted(true); setErrorMessage(''); setFiles((current) => current.map((file) => ({ ...file, selected: requestedSet.has(file.id), requested: requestedSet.has(file.id), progress: requestedSet.has(file.id) ? 0 : file.progress, })), ); channel.send(createTransferReceiveRequestMessage(requestedIds, archive)); setPhase('waiting'); } async function handleLookupByCode() { setLookupBusy(true); setErrorMessage(''); try { const result = await lookupTransferSession(receiveCode); setSearchParams({ session: result.sessionId, }); } catch (error) { setPhase('error'); setErrorMessage(error instanceof Error ? error.message : '取件码无效或会话已过期'); } finally { setLookupBusy(false); } } const sessionId = searchParams.get('session'); const selectedFiles = files.filter((file) => file.selected); const requestedFiles = files.filter((file) => file.requested); const selectedSize = selectedFiles.reduce((sum, file) => sum + file.size, 0); const canZipAllFiles = canArchiveTransferSelection(files); const hasSelectableFiles = selectedFiles.length > 0; const canSubmitSelection = Boolean(dataChannelRef.current && dataChannelRef.current.readyState === 'open' && hasSelectableFiles); const panelContent = ( <> {!embedded ? (

网页接收页

你现在打开的是公开接收链接,先选文件,再通过浏览器 P2P 通道接收并下载。

) : null}
{!sessionId ? (

输入取件码打开接收页

setReceiveCode(sanitizeReceiveCode(event.target.value))} placeholder="例如: 849201" className="h-16 bg-black/20 border-white/10 text-center text-3xl tracking-[0.5em] font-mono text-white" />
{errorMessage ? (
{errorMessage}
) : null}
) : (

当前会话

{transferSession?.pickupCode ?? '连接中...'}

{phase === 'completed' ? ( ) : ( )}

{phase === 'joining' && '正在加入快传会话...'} {phase === 'waiting' && (files.length === 0 ? 'P2P 已连通,正在同步文件清单...' : requestSubmitted ? '已提交接收请求,等待发送端开始推送...' : '文件清单已同步,请勾选要接收的文件。')} {phase === 'connecting' && 'P2P 通道协商中...'} {phase === 'receiving' && '文件正在接收...'} {phase === 'completed' && (archiveUrl ? '接收完成,ZIP 已准备好下载' : '接收完成,下面可以下载文件')} {phase === 'error' && '接收失败'}

{errorMessage || `总进度 ${overallProgress}%`}

{archiveUrl ? (

全部文件 ZIP 已生成

{archiveName}

下载 ZIP
) : null} {saveMessage ? (
{saveMessage}
) : null}

可接收文件

{requestSubmitted ? `已请求 ${requestedFiles.length} 项` : `已选择 ${selectedFiles.length} 项 · ${formatTransferSize(selectedSize)}`}

{!requestSubmitted && files.length > 0 ? (
) : null}
{!requestSubmitted && files.length > 0 ? (
{canZipAllFiles ? ( ) : null}
) : null}
{files.length === 0 ? (
连接建立后会先同步文件清单,你可以在这里先勾选想接收的内容。
) : ( files.map((file) => (
{!requestSubmitted ? ( ) : null}

{file.name}

{file.relativePath !== file.name ? `${file.relativePath} · ` : ''} {formatTransferSize(file.size)}

{requestSubmitted ? ( file.requested ? ( file.downloadUrl ? (
下载 {authSession?.token ? ( ) : null}
) : ( {file.progress}% ) ) : ( 未接收 ) ) : null}
{requestSubmitted && file.requested ? (
) : null}
)) )}
)}
{!embedded ? (

后端只做信令

当前页面通过后端交换 offer、answer 和 ICE candidate,但文件字节不走服务器中转。

先选文件,再接收下载

文件清单会先同步到页面,多文件可以勾选接收,整包接收时会在浏览器内直接生成 ZIP。

) : null} { const completedFile = savePathPickerFileId ? completedFilesRef.current.get(savePathPickerFileId) : null; return completedFile ? resolveNetdiskSaveDirectory(completedFile.relativePath, path) : path; }} onClose={() => setSavePathPickerFileId(null)} onConfirm={async (path) => { if (!savePathPickerFileId) { return; } setSaveRootPath(path); await saveCompletedFile(savePathPickerFileId, path); setSavePathPickerFileId(null); }} /> ); if (embedded) { return panelContent; } return (
{panelContent}
); }