import React, { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { CheckCircle, ChevronRight, Clock3, Copy, DownloadCloud, File as FileIcon, Folder, FolderPlus, Link as LinkIcon, Loader2, Monitor, Plus, Send, Shield, Smartphone, Trash2, UploadCloud, X, LogIn, } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '@/src/auth/AuthProvider'; import { Button } from '@/src/components/ui/button'; import { appendTransferRelayHint } from '@/src/lib/transfer-ice'; import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links'; import { createTransferFileManifest, createTransferFileManifestMessage, createTransferCompleteMessage, createTransferFileCompleteMessage, createTransferFileId, createTransferFileMetaMessage, type TransferFileDescriptor, SIGNAL_POLL_INTERVAL_MS, } from '@/src/lib/transfer-protocol'; import { shouldPublishTransferProgress, resolveTransferChunkSize, } from '@/src/lib/transfer-runtime'; import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer'; import { DEFAULT_TRANSFER_ICE_SERVERS, TRANSFER_HAS_RELAY_SUPPORT, createTransferSession, listMyOfflineTransferSessions, pollTransferSignals, postTransferSignal, uploadOfflineTransferFile, } from '@/src/lib/transfer'; import type { TransferMode, TransferSessionResponse } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; import { buildQrImageUrl, canSendTransferFiles, getAvailableTransferModes, formatTransferSize, getOfflineTransferSessionLabel, getOfflineTransferSessionSize, getTransferModeSummary, resolveInitialTransferTab, } from './transfer-state'; import TransferReceive from './TransferReceive'; type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'uploading' | 'transferring' | 'completed' | 'error'; function parseJsonPayload(payload: string): T | null { try { return JSON.parse(payload) as T; } catch { return null; } } function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: string) { if (mode === 'OFFLINE') { switch (phase) { case 'creating': return '正在创建离线快传会话并生成取件链接...'; case 'uploading': return '文件正在上传到站点存储,上传完成后 7 天内都可以反复接收。'; case 'completed': return '离线文件已上传完成,接收方现在可以多次下载或存入网盘。'; case 'error': return errorMessage || '离线快传初始化失败,请重试。'; default: return '拖拽文件后会生成离线取件码,并把文件上传到站点存储保留 7 天。'; } } switch (phase) { case 'creating': return '正在创建快传会话并准备 P2P 连接...'; case 'waiting': return '分享链接和二维码已经生成,等待接收端打开页面并选择要接收的文件。'; case 'connecting': return '接收端已进入页面,正在交换浏览器连接信息并同步文件清单...'; case 'transferring': return 'P2P 直连已建立,文件正在发送到对方浏览器。'; case 'completed': return '本次文件已发送完成,对方页面现在可以下载。'; case 'error': return errorMessage || '快传会话初始化失败,请重试。'; default: return '拖拽文件后会自动生成会话、二维码和公开接收页链接。'; } } export default function Transfer() { const navigate = useNavigate(); const { ready: authReady, session: authSession } = useAuth(); const [searchParams] = useSearchParams(); const sessionId = searchParams.get('session'); const isAuthenticated = Boolean(authSession?.token); const allowSend = canSendTransferFiles(isAuthenticated); const availableTransferModes = getAvailableTransferModes(isAuthenticated); const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId)); const [selectedFiles, setSelectedFiles] = useState([]); const [transferMode, setTransferMode] = useState('ONLINE'); const [session, setSession] = useState(null); const [sendPhase, setSendPhase] = useState('idle'); const [sendProgress, setSendProgress] = useState(0); const [sendError, setSendError] = useState(''); const [copied, setCopied] = useState(false); const [offlineHistory, setOfflineHistory] = useState([]); const [offlineHistoryLoading, setOfflineHistoryLoading] = useState(false); const [offlineHistoryError, setOfflineHistoryError] = useState(''); const [selectedOfflineSession, setSelectedOfflineSession] = useState(null); const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState(null); const fileInputRef = useRef(null); const folderInputRef = useRef(null); const copiedTimerRef = useRef(null); const historyCopiedTimerRef = useRef(null); const pollTimerRef = useRef(null); const peerRef = useRef(null); const cursorRef = useRef(0); const bootstrapIdRef = useRef(0); const totalBytesRef = useRef(0); const sentBytesRef = useRef(0); const lastSendProgressPublishAtRef = useRef(0); const lastPublishedSendProgressRef = useRef(0); const sendingStartedRef = useRef(false); const manifestRef = useRef([]); useEffect(() => { if (!folderInputRef.current) { return; } folderInputRef.current.setAttribute('webkitdirectory', ''); folderInputRef.current.setAttribute('directory', ''); }, []); useEffect(() => { return () => { cleanupCurrentTransfer(); if (copiedTimerRef.current) { window.clearTimeout(copiedTimerRef.current); } if (historyCopiedTimerRef.current) { window.clearTimeout(historyCopiedTimerRef.current); } }; }, []); useEffect(() => { if (!allowSend || sessionId) { setActiveTab('receive'); } }, [allowSend, sessionId]); useEffect(() => { if (!availableTransferModes.includes(transferMode)) { setTransferMode('ONLINE'); } }, [availableTransferModes, transferMode]); useEffect(() => { if (selectedFiles.length === 0) { return; } void bootstrapTransfer(selectedFiles); }, [transferMode]); useEffect(() => { if (!isAuthenticated) { setOfflineHistory([]); setOfflineHistoryError(''); setSelectedOfflineSession(null); return; } void loadOfflineHistory(); }, [isAuthenticated]); const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0); const shareLink = session ? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode()) : ''; const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : ''; const transferModeSummary = getTransferModeSummary(transferMode); const selectedOfflineSessionShareLink = selectedOfflineSession ? buildTransferShareUrl(window.location.origin, selectedOfflineSession.sessionId, getTransferRouterMode()) : ''; const selectedOfflineSessionQrImageUrl = selectedOfflineSessionShareLink ? buildQrImageUrl(selectedOfflineSessionShareLink) : ''; function navigateBackToLogin() { const nextPath = `${window.location.pathname}${window.location.search}`; navigate(`/login?next=${encodeURIComponent(nextPath)}`); } function isOfflineSessionReady(sessionToCheck: TransferSessionResponse) { return sessionToCheck.files.every((file) => file.uploaded); } async function loadOfflineHistory(options?: {silent?: boolean}) { if (!isAuthenticated) { return; } if (!options?.silent) { setOfflineHistoryLoading(true); } setOfflineHistoryError(''); try { const sessions = await listMyOfflineTransferSessions(); setOfflineHistory(sessions); setSelectedOfflineSession((current) => { if (!current) { return current; } return sessions.find((item) => item.sessionId === current.sessionId) ?? null; }); } catch (error) { setOfflineHistoryError(error instanceof Error ? error.message : '离线快传记录加载失败'); } finally { if (!options?.silent) { setOfflineHistoryLoading(false); } } } function cleanupCurrentTransfer() { if (pollTimerRef.current) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; } const peer = peerRef.current; peerRef.current = null; peer?.destroy(); cursorRef.current = 0; lastSendProgressPublishAtRef.current = 0; lastPublishedSendProgressRef.current = 0; sendingStartedRef.current = false; } function publishSendProgress(nextProgress: number, options?: {force?: boolean}) { const normalizedProgress = Math.max(0, Math.min(100, nextProgress)); const now = globalThis.performance?.now?.() ?? Date.now(); if (!options?.force && !shouldPublishTransferProgress({ nextProgress: normalizedProgress, previousProgress: lastPublishedSendProgressRef.current, now, lastPublishedAt: lastSendProgressPublishAtRef.current, })) { return; } lastSendProgressPublishAtRef.current = now; lastPublishedSendProgressRef.current = normalizedProgress; setSendProgress(normalizedProgress); } function resetSenderState() { cleanupCurrentTransfer(); setSession(null); setSelectedFiles([]); setSendPhase('idle'); publishSendProgress(0, {force: true}); setSendError(''); } async function copyToClipboard(text: string) { try { await navigator.clipboard.writeText(text); setCopied(true); if (copiedTimerRef.current) { window.clearTimeout(copiedTimerRef.current); } copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1800); } catch { setCopied(false); } } function ensureReadyState(nextFiles: File[]) { setSelectedFiles(nextFiles); if (nextFiles.length === 0) { resetSenderState(); return; } void bootstrapTransfer(nextFiles); } function appendFiles(files: FileList | File[]) { const nextFiles = [...selectedFiles, ...Array.from(files)]; ensureReadyState(nextFiles); } function handleFileSelect(event: React.ChangeEvent) { if (event.target.files?.length) { appendFiles(event.target.files); } event.target.value = ''; } function handleDragOver(event: React.DragEvent) { event.preventDefault(); } function handleDrop(event: React.DragEvent) { event.preventDefault(); if (event.dataTransfer.files?.length) { appendFiles(event.dataTransfer.files); } } function removeFile(indexToRemove: number) { ensureReadyState(selectedFiles.filter((_, index) => index !== indexToRemove)); } async function bootstrapTransfer(files: File[]) { const bootstrapId = bootstrapIdRef.current + 1; bootstrapIdRef.current = bootstrapId; cleanupCurrentTransfer(); setSendError(''); setSendPhase('creating'); publishSendProgress(0, {force: true}); manifestRef.current = createTransferFileManifest(files); totalBytesRef.current = 0; sentBytesRef.current = 0; try { const createdSession = await createTransferSession(files, transferMode); if (bootstrapIdRef.current !== bootstrapId) { return; } setSession(createdSession); if (createdSession.mode === 'OFFLINE') { void loadOfflineHistory({silent: true}); await uploadOfflineFiles(createdSession, files, bootstrapId); return; } setSendPhase('waiting'); await setupSenderPeer(createdSession, files, bootstrapId); } catch (error) { if (bootstrapIdRef.current !== bootstrapId) { return; } setSendPhase('error'); setSendError(error instanceof Error ? error.message : '快传会话创建失败'); } } async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) { setSendPhase('uploading'); totalBytesRef.current = files.reduce((sum, file) => sum + file.size, 0); sentBytesRef.current = 0; publishSendProgress(0, {force: true}); for (const [index, file] of files.entries()) { if (bootstrapIdRef.current !== bootstrapId) { return; } const sessionFile = createdSession.files[index]; if (!sessionFile?.id) { throw new Error('离线快传文件清单不完整,请重新开始本次发送。'); } let lastLoaded = 0; await uploadOfflineTransferFile(createdSession.sessionId, sessionFile.id, file, ({ loaded, total }) => { const delta = loaded - lastLoaded; lastLoaded = loaded; sentBytesRef.current += delta; if (loaded >= total) { sentBytesRef.current = Math.min(totalBytesRef.current, sentBytesRef.current); } if (totalBytesRef.current > 0) { publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100))); } }); } publishSendProgress(100, {force: true}); setSendPhase('completed'); void loadOfflineHistory({silent: true}); } async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) { const peer = createTransferPeer({ initiator: true, peerOptions: { config: { iceServers: DEFAULT_TRANSFER_ICE_SERVERS, }, }, onSignal: (payload) => { void postTransferSignal(createdSession.sessionId, 'sender', 'signal', payload); }, onConnect: () => { if (bootstrapIdRef.current !== bootstrapId) { return; } setSendPhase((current) => (current === 'transferring' || current === 'completed' ? current : 'connecting')); peer.send(createTransferFileManifestMessage(manifestRef.current)); }, onData: (payload) => { if (typeof payload !== 'string') { return; } const message = parseJsonPayload<{type?: string; fileIds?: string[]}>(payload); if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds) || sendingStartedRef.current) { return; } const requestedFiles = manifestRef.current.filter((item) => message.fileIds?.includes(item.id)); if (requestedFiles.length === 0) { return; } sendingStartedRef.current = true; totalBytesRef.current = requestedFiles.reduce((sum, file) => sum + file.size, 0); sentBytesRef.current = 0; publishSendProgress(0, {force: true}); void sendSelectedFiles(peer, files, requestedFiles, bootstrapId); }, onError: (error) => { if (bootstrapIdRef.current !== bootstrapId) { return; } setSendPhase('error'); setSendError(appendTransferRelayHint( error.message || '数据通道建立失败,请重新开始本次快传。', TRANSFER_HAS_RELAY_SUPPORT, )); }, }); peerRef.current = peer; startSenderPolling(createdSession.sessionId, bootstrapId); } function startSenderPolling(sessionId: string, bootstrapId: number) { let polling = false; pollTimerRef.current = window.setInterval(() => { if (polling || bootstrapIdRef.current !== bootstrapId) { return; } polling = true; void pollTransferSignals(sessionId, 'sender', cursorRef.current) .then(async (response) => { if (bootstrapIdRef.current !== bootstrapId) { return; } cursorRef.current = response.nextCursor; for (const item of response.items) { if (item.type === 'peer-joined') { setSendPhase((current) => (current === 'waiting' ? 'connecting' : current)); continue; } if (item.type === 'signal') { peerRef.current?.applyRemoteSignal(item.payload); } } }) .catch((error) => { if (bootstrapIdRef.current !== bootstrapId) { return; } setSendPhase('error'); setSendError(error instanceof Error ? error.message : '轮询连接状态失败'); }) .finally(() => { polling = false; }); }, SIGNAL_POLL_INTERVAL_MS); } async function sendSelectedFiles( peer: TransferPeerAdapter, files: File[], requestedFiles: TransferFileDescriptor[], bootstrapId: number, ) { setSendPhase('transferring'); const filesById = new Map(files.map((file) => [createTransferFileId(file), file])); const chunkSize = resolveTransferChunkSize(); for (const descriptor of requestedFiles) { if (bootstrapIdRef.current !== bootstrapId || !peer.connected) { return; } const file = filesById.get(descriptor.id); if (!file) { continue; } peer.send(createTransferFileMetaMessage(descriptor)); for (let offset = 0; offset < file.size; offset += chunkSize) { if (bootstrapIdRef.current !== bootstrapId || !peer.connected) { return; } const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer(); await peer.write(chunk); sentBytesRef.current += chunk.byteLength; if (totalBytesRef.current > 0) { publishSendProgress(Math.min( 99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100), )); } } peer.send(createTransferFileCompleteMessage(descriptor.id)); } peer.send(createTransferCompleteMessage()); publishSendProgress(100, {force: true}); setSendPhase('completed'); } async function copyOfflineSessionLink(sessionToCopy: TransferSessionResponse) { const sessionShareLink = buildTransferShareUrl( window.location.origin, sessionToCopy.sessionId, getTransferRouterMode(), ); await navigator.clipboard.writeText(sessionShareLink); setHistoryCopiedSessionId(sessionToCopy.sessionId); if (historyCopiedTimerRef.current) { window.clearTimeout(historyCopiedTimerRef.current); } historyCopiedTimerRef.current = window.setTimeout(() => { setHistoryCopiedSessionId((current) => (current === sessionToCopy.sessionId ? null : current)); }, 1800); } return (

快传

在线快传走浏览器 P2P 一次性传输,离线快传会把文件存到站点存储里保留 7 天,可被反复接收。

{allowSend ? (
) : null}
{authReady && !isAuthenticated ? (

当前无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。

) : null} {activeTab === 'send' ? ( {availableTransferModes.length > 1 ? (
{availableTransferModes.map((mode) => { const summary = getTransferModeSummary(mode); const active = transferMode === mode; return ( ); })}
) : null} {selectedFiles.length === 0 ? (

拖拽文件或文件夹到此处

{transferModeSummary.description}

) : (

取件码

{session?.pickupCode ?? '......'}
{qrImageUrl ? (
快传分享二维码
) : null}
分享链接
{shareLink || '会话创建中...'}

待发送文件

{selectedFiles.length} 个项目 • {formatTransferSize(totalSize)}
{selectedFiles.map((file, index) => (

{file.name}

{formatTransferSize(file.size)}

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

{getPhaseMessage(transferMode, sendPhase, sendError)}

发送进度 {sendProgress}%{session ? ` · 会话有效期至 ${new Date(session.expiresAt).toLocaleString('zh-CN', {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}` : ''}

)} {isAuthenticated ? (

我的离线快传

这里只保留未过期的离线快传记录,点击即可重新查看取件码和分享链接。

{offlineHistoryLoading && offlineHistory.length === 0 ? (
正在加载离线快传记录...
) : offlineHistoryError ? (
{offlineHistoryError}
) : offlineHistory.length === 0 ? (
你还没有发过离线快传。
) : (
{offlineHistory.map((historySession) => { const ready = isOfflineSessionReady(historySession); return ( ); })}
)}
) : null}
) : ( )}

扫码直达网页

二维码不承载文件本身,只负责把另一台设备带到公开接收页。

浏览器 P2P 传输

网页之间通过 WebRTC DataChannel 交换文件字节,后端只做信令和会话协调。

在线一次性,离线可重复

在线模式适合临时快传,离线模式会保留 7 天,接收后文件也不会立刻消失。

{selectedOfflineSession ? (

取件码

{selectedOfflineSession.pickupCode}
{selectedOfflineSessionQrImageUrl ? (
离线快传二维码
) : null}
分享链接
{selectedOfflineSessionShareLink}
{getOfflineTransferSessionLabel(selectedOfflineSession)} {selectedOfflineSession.files.length} 个项目 {getOfflineTransferSessionSize(selectedOfflineSession)} {isOfflineSessionReady(selectedOfflineSession) ? '文件已就绪,可重复接收' : '文件仍在上传中'}
有效期至 {new Date(selectedOfflineSession.expiresAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', })}
) : null}
); }