实现快传,完善快传和网盘的功能,实现文件的互传等一系列功能

This commit is contained in:
yoyuzh
2026-03-20 14:16:18 +08:00
parent 944ab6dbf8
commit 43358e29d7
109 changed files with 5237 additions and 2465 deletions

View File

@@ -0,0 +1,691 @@
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
CheckCircle,
Copy,
DownloadCloud,
File as FileIcon,
Folder,
FolderPlus,
Link as LinkIcon,
Loader2,
Monitor,
Plus,
Send,
Shield,
Smartphone,
Trash2,
UploadCloud,
X,
} from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { useAuth } from '@/src/auth/AuthProvider';
import { Button } from '@/src/components/ui/button';
import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links';
import {
createTransferFileManifest,
createTransferFileManifestMessage,
createTransferCompleteMessage,
createTransferFileCompleteMessage,
createTransferFileId,
createTransferFileMetaMessage,
type TransferFileDescriptor,
SIGNAL_POLL_INTERVAL_MS,
TRANSFER_CHUNK_SIZE,
} from '@/src/lib/transfer-protocol';
import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime';
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
import { DEFAULT_TRANSFER_ICE_SERVERS, createTransferSession, pollTransferSignals, postTransferSignal } from '@/src/lib/transfer';
import type { TransferSessionResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import {
buildQrImageUrl,
canSendTransferFiles,
formatTransferSize,
resolveInitialTransferTab,
} from './transfer-state';
import TransferReceive from './TransferReceive';
type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'transferring' | 'completed' | 'error';
function parseJsonPayload<T>(payload: string): T | null {
try {
return JSON.parse(payload) as T;
} catch {
return null;
}
}
function getPhaseMessage(phase: SendPhase, errorMessage: string) {
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 { session: authSession } = useAuth();
const [searchParams] = useSearchParams();
const sessionId = searchParams.get('session');
const allowSend = canSendTransferFiles(Boolean(authSession?.token));
const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId));
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [session, setSession] = useState<TransferSessionResponse | null>(null);
const [sendPhase, setSendPhase] = useState<SendPhase>('idle');
const [sendProgress, setSendProgress] = useState(0);
const [sendError, setSendError] = useState('');
const [copied, setCopied] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const copiedTimerRef = useRef<number | null>(null);
const pollTimerRef = useRef<number | null>(null);
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const dataChannelRef = useRef<RTCDataChannel | null>(null);
const cursorRef = useRef(0);
const bootstrapIdRef = useRef(0);
const totalBytesRef = useRef(0);
const sentBytesRef = useRef(0);
const sendingStartedRef = useRef(false);
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
const manifestRef = useRef<TransferFileDescriptor[]>([]);
useEffect(() => {
if (!folderInputRef.current) {
return;
}
folderInputRef.current.setAttribute('webkitdirectory', '');
folderInputRef.current.setAttribute('directory', '');
}, []);
useEffect(() => {
return () => {
cleanupCurrentTransfer();
if (copiedTimerRef.current) {
window.clearTimeout(copiedTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!allowSend || sessionId) {
setActiveTab('receive');
}
}, [allowSend, sessionId]);
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) : '';
function cleanupCurrentTransfer() {
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;
}
cursorRef.current = 0;
sendingStartedRef.current = false;
pendingRemoteCandidatesRef.current = [];
}
function resetSenderState() {
cleanupCurrentTransfer();
setSession(null);
setSelectedFiles([]);
setSendPhase('idle');
setSendProgress(0);
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<HTMLInputElement>) {
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');
setSendProgress(0);
manifestRef.current = createTransferFileManifest(files);
totalBytesRef.current = 0;
sentBytesRef.current = 0;
try {
const createdSession = await createTransferSession(files);
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSession(createdSession);
setSendPhase('waiting');
await setupSenderPeer(createdSession, files, bootstrapId);
} catch (error) {
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSendPhase('error');
setSendError(error instanceof Error ? error.message : '快传会话创建失败');
}
}
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
const connection = new RTCPeerConnection({
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
});
const channel = connection.createDataChannel('portal-transfer', {
ordered: true,
});
peerConnectionRef.current = connection;
dataChannelRef.current = channel;
channel.binaryType = 'arraybuffer';
connection.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
void postTransferSignal(
createdSession.sessionId,
'sender',
'ice-candidate',
JSON.stringify(event.candidate.toJSON()),
);
};
connection.onconnectionstatechange = () => {
if (connection.connectionState === 'connected') {
setSendPhase((current) => (current === 'transferring' || current === 'completed' ? current : 'connecting'));
}
if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') {
setSendPhase('error');
setSendError('浏览器直连失败,请重新生成分享链接再试一次。');
}
};
channel.onopen = () => {
channel.send(createTransferFileManifestMessage(manifestRef.current));
};
channel.onmessage = (event) => {
if (typeof event.data !== 'string') {
return;
}
const message = parseJsonPayload<{type?: string; fileIds?: string[];}>(event.data);
if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds)) {
return;
}
if (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;
setSendProgress(0);
void sendSelectedFiles(channel, files, requestedFiles, bootstrapId);
};
channel.onerror = () => {
setSendPhase('error');
setSendError('数据通道建立失败,请重新开始本次快传。');
};
startSenderPolling(createdSession.sessionId, connection, bootstrapId);
const offer = await connection.createOffer();
await connection.setLocalDescription(offer);
await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer));
}
function startSenderPolling(sessionId: string, connection: RTCPeerConnection, 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 === 'answer' && !connection.currentRemoteDescription) {
const answer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
if (answer) {
await connection.setRemoteDescription(answer);
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(
connection,
pendingRemoteCandidatesRef.current,
);
}
continue;
}
if (item.type === 'ice-candidate') {
const candidate = parseJsonPayload<RTCIceCandidateInit>(item.payload);
if (candidate) {
pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(
connection,
pendingRemoteCandidatesRef.current,
candidate,
);
}
}
}
})
.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(
channel: RTCDataChannel,
files: File[],
requestedFiles: TransferFileDescriptor[],
bootstrapId: number,
) {
setSendPhase('transferring');
const filesById = new Map(files.map((file) => [createTransferFileId(file), file]));
for (const descriptor of requestedFiles) {
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') {
return;
}
const file = filesById.get(descriptor.id);
if (!file) {
continue;
}
channel.send(createTransferFileMetaMessage(descriptor));
for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) {
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') {
return;
}
const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer();
await waitForTransferChannelDrain(channel);
channel.send(chunk);
sentBytesRef.current += chunk.byteLength;
if (totalBytesRef.current > 0) {
setSendProgress(Math.min(
99,
Math.round((sentBytesRef.current / totalBytesRef.current) * 100),
));
}
}
channel.send(createTransferFileCompleteMessage(descriptor.id));
}
channel.send(createTransferCompleteMessage());
setSendProgress(100);
setSendPhase('completed');
}
return (
<div className="flex-1 flex flex-col items-center py-6 md:py-10">
<div className="w-full max-w-4xl">
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#336EFF] via-blue-500 to-cyan-400 shadow-lg shadow-[#336EFF]/20 mb-6">
<Send className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-3">P2P </h1>
<p className="text-slate-400"> P2P </p>
</div>
<div className="glass-panel border border-white/10 rounded-3xl overflow-hidden bg-[#0f172a]/80 backdrop-blur-xl shadow-2xl">
{allowSend ? (
<div className="flex border-b border-white/10">
<button
onClick={() => setActiveTab('send')}
className={cn(
'flex-1 py-5 text-center font-medium transition-colors relative',
activeTab === 'send' ? 'text-white' : 'text-slate-400 hover:text-slate-200 hover:bg-white/5',
)}
>
<div className="flex items-center justify-center gap-2">
<UploadCloud className="w-5 h-5" />
</div>
{activeTab === 'send' ? (
<motion.div layoutId="activeTransferTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#336EFF]" />
) : null}
</button>
<button
onClick={() => setActiveTab('receive')}
className={cn(
'flex-1 py-5 text-center font-medium transition-colors relative',
activeTab === 'receive' ? 'text-white' : 'text-slate-400 hover:text-slate-200 hover:bg-white/5',
)}
>
<div className="flex items-center justify-center gap-2">
<DownloadCloud className="w-5 h-5" />
</div>
{activeTab === 'receive' ? (
<motion.div layoutId="activeTransferTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#336EFF]" />
) : null}
</button>
</div>
) : null}
<div className="p-8 min-h-[420px] flex flex-col relative min-w-0">
<AnimatePresence mode="wait">
{activeTab === 'send' ? (
<motion.div
key="send"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.2 }}
className="flex-1 flex flex-col h-full min-w-0"
>
{selectedFiles.length === 0 ? (
<div
className="flex-1 border-2 border-dashed border-white/10 rounded-2xl flex flex-col items-center justify-center p-10 transition-colors hover:border-[#336EFF]/50 hover:bg-[#336EFF]/5"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="w-20 h-20 rounded-full bg-blue-500/10 flex items-center justify-center mb-6">
<UploadCloud className="w-10 h-10 text-[#336EFF]" />
</div>
<h3 className="text-xl font-medium text-white mb-2"></h3>
<p className="text-slate-400 mb-8 text-center max-w-md">
P2P
</p>
<div className="flex flex-col sm:flex-row items-center gap-4">
<Button onClick={() => fileInputRef.current?.click()} className="bg-[#336EFF] hover:bg-blue-600 text-white px-8">
<FileIcon className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={() => folderInputRef.current?.click()}
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300 px-8"
>
<Folder className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
) : (
<div className="flex-1 flex flex-col md:flex-row gap-8">
<div className="flex-1 flex flex-col items-center justify-center bg-black/20 rounded-2xl p-8 border border-white/5 relative min-w-0">
<button onClick={resetSenderState} className="absolute top-4 right-4 text-slate-500 hover:text-white transition-colors" aria-label="取消发送">
<X className="w-5 h-5" />
</button>
<h3 className="text-slate-400 text-sm font-medium mb-2 uppercase tracking-widest"></h3>
<div className="text-5xl md:text-6xl font-bold text-white tracking-[0.2em] mb-8 font-mono">
{session?.pickupCode ?? '......'}
</div>
{qrImageUrl ? (
<div className="bg-white p-4 rounded-2xl mb-6 shadow-[0_18px_48px_rgba(15,23,42,0.18)]">
<img src={qrImageUrl} alt="快传分享二维码" className="w-44 h-44 rounded-xl" />
</div>
) : null}
<div className="w-full max-w-xs rounded-2xl border border-white/10 bg-black/30 p-3 mb-4">
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-slate-500 mb-2">
<LinkIcon className="w-3.5 h-3.5" />
</div>
<div className="text-sm text-slate-200 font-mono truncate">{shareLink || '会话创建中...'}</div>
</div>
<Button
variant="outline"
className="w-full max-w-xs border-white/10 hover:bg-white/10 text-slate-200"
onClick={() => void copyToClipboard(shareLink)}
disabled={!shareLink}
>
{copied ? <CheckCircle className="w-4 h-4 mr-2 text-emerald-400" /> : <Copy className="w-4 h-4 mr-2" />}
{copied ? '已复制' : '复制链接'}
</Button>
</div>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center justify-between mb-4 gap-4">
<div>
<h3 className="text-lg font-medium text-white"></h3>
<span className="text-sm text-slate-400">{selectedFiles.length} {formatTransferSize(totalSize)}</span>
</div>
<Button
size="sm"
variant="outline"
className="h-8 border-white/10 hover:bg-white/10 text-slate-300 px-2 shrink-0"
onClick={() => folderInputRef.current?.click()}
>
<FolderPlus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-3 max-h-[300px] mb-4">
{selectedFiles.map((file, index) => (
<div key={`${file.name}-${index}`} className="flex items-center gap-3 bg-white/5 border border-white/5 rounded-xl p-3 group transition-colors hover:bg-white/10">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0">
<FileIcon className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{file.name}</p>
<p className="text-xs text-slate-500">{formatTransferSize(file.size)}</p>
</div>
<button
onClick={() => removeFile(index)}
className="p-2 text-slate-500 hover:text-red-400 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
title="移除文件"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<button
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 py-4 border-2 border-dashed border-white/10 rounded-xl text-slate-400 hover:text-white hover:border-white/30 hover:bg-white/5 transition-colors mb-6 shrink-0"
>
<Plus className="w-5 h-5" />
<span className="font-medium"></span>
</button>
<div className={cn(
'mt-auto rounded-xl p-4 flex items-start gap-4 border',
sendPhase === 'error'
? 'bg-rose-500/10 border-rose-500/20'
: sendPhase === 'completed'
? 'bg-emerald-500/10 border-emerald-500/20'
: 'bg-blue-500/10 border-blue-500/20',
)}>
{sendPhase === 'completed' ? (
<CheckCircle className="w-6 h-6 text-emerald-400 shrink-0" />
) : (
<Loader2 className={cn(
'w-6 h-6 shrink-0',
sendPhase === 'error' ? 'text-rose-400' : 'text-blue-400 animate-spin',
)} />
)}
<div className="min-w-0">
<p className={cn(
'text-sm font-medium',
sendPhase === 'error'
? 'text-rose-300'
: sendPhase === 'completed'
? 'text-emerald-300'
: 'text-blue-300',
)}>
{getPhaseMessage(sendPhase, sendError)}
</p>
<p className="text-xs text-slate-400 mt-1">
{sendProgress}%{session ? ` · 会话有效期至 ${new Date(session.expiresAt).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})}` : ''}
</p>
</div>
</div>
</div>
</div>
)}
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
<input type="file" multiple className="hidden" ref={folderInputRef} onChange={handleFileSelect} />
</motion.div>
) : (
<motion.div
key="receive"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="flex-1 flex flex-col h-full min-w-0 w-full"
>
<TransferReceive embedded />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12">
<div className="flex items-start gap-4 p-4 rounded-2xl bg-white/[0.02] border border-white/5">
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center shrink-0">
<Smartphone className="w-5 h-5 text-blue-400" />
</div>
<div>
<h4 className="text-sm font-medium text-slate-200 mb-1"></h4>
<p className="text-xs text-slate-500 leading-relaxed"></p>
</div>
</div>
<div className="flex items-start gap-4 p-4 rounded-2xl bg-white/[0.02] border border-white/5">
<div className="w-10 h-10 rounded-full bg-emerald-500/10 flex items-center justify-center shrink-0">
<Shield className="w-5 h-5 text-emerald-400" />
</div>
<div>
<h4 className="text-sm font-medium text-slate-200 mb-1"> P2P </h4>
<p className="text-xs text-slate-500 leading-relaxed"> WebRTC DataChannel </p>
</div>
</div>
<div className="flex items-start gap-4 p-4 rounded-2xl bg-white/[0.02] border border-white/5">
<div className="w-10 h-10 rounded-full bg-cyan-500/10 flex items-center justify-center shrink-0">
<Monitor className="w-5 h-5 text-cyan-400" />
</div>
<div>
<h4 className="text-sm font-medium text-slate-200 mb-1"></h4>
<p className="text-xs text-slate-500 leading-relaxed"></p>
</div>
</div>
</div>
</div>
</div>
);
}