实现快传,完善快传和网盘的功能,实现文件的互传等一系列功能
This commit is contained in:
879
front/src/pages/TransferReceive.tsx
Normal file
879
front/src/pages/TransferReceive.tsx
Normal file
@@ -0,0 +1,879 @@
|
||||
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<T>(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<TransferSessionResponse | null>(null);
|
||||
const [files, setFiles] = useState<DownloadableFile[]>([]);
|
||||
const [phase, setPhase] = useState<ReceivePhase>('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<string | null>(null);
|
||||
const [savingFileId, setSavingFileId] = useState<string | null>(null);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const [savePathPickerFileId, setSavePathPickerFileId] = useState<string | null>(null);
|
||||
const [saveRootPath, setSaveRootPath] = useState('/下载');
|
||||
|
||||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const cursorRef = useRef(0);
|
||||
const lifecycleIdRef = useRef(0);
|
||||
const currentFileIdRef = useRef<string | null>(null);
|
||||
const totalBytesRef = useRef(0);
|
||||
const receivedBytesRef = useRef(0);
|
||||
const downloadUrlsRef = useRef<string[]>([]);
|
||||
const requestedFileIdsRef = useRef<string[]>([]);
|
||||
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
|
||||
const archiveBuiltRef = useRef(false);
|
||||
const completedFilesRef = useRef(new Map<string, {
|
||||
name: string;
|
||||
relativePath: string;
|
||||
blob: Blob;
|
||||
contentType: string;
|
||||
}>());
|
||||
const incomingFilesRef = useRef(new Map<string, IncomingTransferFile>());
|
||||
|
||||
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<RTCSessionDescriptionInit>(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<RTCIceCandidateInit>(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 ? (
|
||||
<div className="text-center mb-10">
|
||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-400 shadow-lg shadow-emerald-500/20">
|
||||
<DownloadCloud className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-3">网页接收页</h1>
|
||||
<p className="text-slate-400">你现在打开的是公开接收链接,先选文件,再通过浏览器 P2P 通道接收并下载。</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={embedded ? '' : 'glass-panel rounded-3xl border border-white/10 bg-[#0f172a]/80 shadow-2xl overflow-hidden'}>
|
||||
<div className={embedded ? '' : 'p-8'}>
|
||||
{!sessionId ? (
|
||||
<div className="mx-auto flex max-w-sm flex-col items-center">
|
||||
<div className="mb-8 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<DownloadCloud className="h-10 w-10 text-emerald-400" />
|
||||
</div>
|
||||
<h2 className="mb-6 text-xl font-medium">输入取件码打开接收页</h2>
|
||||
<div className="w-full mb-6">
|
||||
<Input
|
||||
value={receiveCode}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full h-12 text-lg bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
disabled={receiveCode.length !== 6 || lookupBusy}
|
||||
onClick={() => void handleLookupByCode()}
|
||||
>
|
||||
{lookupBusy ? '正在查找...' : '进入接收会话'}
|
||||
</Button>
|
||||
{errorMessage ? (
|
||||
<div className="mt-4 w-full rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-8 md:grid-cols-[1.08fr_0.92fr]">
|
||||
<div className="rounded-2xl border border-white/5 bg-black/20 p-6">
|
||||
<div className="flex items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">当前会话</p>
|
||||
<h2 className="text-2xl font-semibold mt-2">{transferSession?.pickupCode ?? '连接中...'}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
if (sessionId) {
|
||||
void startReceivingSession(sessionId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
重新连接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
{phase === 'completed' ? (
|
||||
<CheckCircle className="h-6 w-6 text-emerald-400" />
|
||||
) : (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-emerald-400" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{phase === 'joining' && '正在加入快传会话...'}
|
||||
{phase === 'waiting' && (files.length === 0
|
||||
? 'P2P 已连通,正在同步文件清单...'
|
||||
: requestSubmitted
|
||||
? '已提交接收请求,等待发送端开始推送...'
|
||||
: '文件清单已同步,请勾选要接收的文件。')}
|
||||
{phase === 'connecting' && 'P2P 通道协商中...'}
|
||||
{phase === 'receiving' && '文件正在接收...'}
|
||||
{phase === 'completed' && (archiveUrl ? '接收完成,ZIP 已准备好下载' : '接收完成,下面可以下载文件')}
|
||||
{phase === 'error' && '接收失败'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{errorMessage || `总进度 ${overallProgress}%`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-black/40">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-cyan-400" style={{width: `${overallProgress}%`}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{archiveUrl ? (
|
||||
<div className="mt-5 rounded-2xl border border-cyan-400/20 bg-cyan-500/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-500/15">
|
||||
<Archive className="h-5 w-5 text-cyan-300" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white">全部文件 ZIP 已生成</p>
|
||||
<p className="mt-1 text-xs text-slate-300">{archiveName}</p>
|
||||
</div>
|
||||
<a
|
||||
href={archiveUrl}
|
||||
download={archiveName}
|
||||
className="rounded-lg border border-white/10 px-3 py-2 text-xs text-slate-100 transition-colors hover:bg-white/10"
|
||||
>
|
||||
下载 ZIP
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{saveMessage ? (
|
||||
<div className="mt-5 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{saveMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/5 bg-black/20 p-6">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">可接收文件</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{requestSubmitted
|
||||
? `已请求 ${requestedFiles.length} 项`
|
||||
: `已选择 ${selectedFiles.length} 项 · ${formatTransferSize(selectedSize)}`}
|
||||
</p>
|
||||
</div>
|
||||
{!requestSubmitted && files.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
||||
onClick={() => toggleSelectAll(true)}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
||||
onClick={() => toggleSelectAll(false)}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!requestSubmitted && files.length > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
disabled={!canSubmitSelection}
|
||||
onClick={() => void submitReceiveRequest(false)}
|
||||
>
|
||||
接收选中项
|
||||
</Button>
|
||||
{canZipAllFiles ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-cyan-400/20 bg-cyan-500/10 text-cyan-100 hover:bg-cyan-500/15"
|
||||
disabled={!dataChannelRef.current || dataChannelRef.current.readyState !== 'open'}
|
||||
onClick={() => void submitReceiveRequest(true, files.map((file) => file.id))}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
全部下载 ZIP
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{files.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-5 text-sm text-slate-500">
|
||||
连接建立后会先同步文件清单,你可以在这里先勾选想接收的内容。
|
||||
</div>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<div key={file.id} className="rounded-xl border border-white/5 bg-white/[0.03] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{!requestSubmitted ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 text-slate-300 hover:text-white"
|
||||
onClick={() => toggleFileSelection(file.id)}
|
||||
aria-label={file.selected ? '取消选择文件' : '选择文件'}
|
||||
>
|
||||
{file.selected ? <CheckSquare className="h-5 w-5 text-emerald-400" /> : <Square className="h-5 w-5" />}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/10 shrink-0">
|
||||
<FileIcon className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-100">{file.name}</p>
|
||||
<p className="truncate text-xs text-slate-500 mt-1">
|
||||
{file.relativePath !== file.name ? `${file.relativePath} · ` : ''}
|
||||
{formatTransferSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{requestSubmitted ? (
|
||||
file.requested ? (
|
||||
file.downloadUrl ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={file.downloadUrl}
|
||||
download={file.name}
|
||||
className="rounded-lg border border-white/10 px-3 py-2 text-xs text-slate-200 transition-colors hover:bg-white/10"
|
||||
>
|
||||
下载
|
||||
</a>
|
||||
{authSession?.token ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
||||
disabled={savingFileId === file.id || file.savedToNetdisk}
|
||||
onClick={() => setSavePathPickerFileId(file.id)}
|
||||
>
|
||||
{file.savedToNetdisk ? '已存入网盘' : savingFileId === file.id ? '存入中...' : '存入网盘'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-emerald-300">{file.progress}%</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">未接收</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{requestSubmitted && file.requested ? (
|
||||
<div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-black/40">
|
||||
<div className="h-full rounded-full bg-emerald-400" style={{width: `${file.progress}%`}} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!embedded ? (
|
||||
<div className="mt-10 grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<Shield className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-slate-100 mb-1">后端只做信令</h4>
|
||||
<p className="text-xs leading-6 text-slate-500">当前页面通过后端交换 offer、answer 和 ICE candidate,但文件字节不走服务器中转。</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-cyan-500/10">
|
||||
<Archive className="h-5 w-5 text-cyan-400" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-slate-100 mb-1">先选文件,再接收下载</h4>
|
||||
<p className="text-xs leading-6 text-slate-500">文件清单会先同步到页面,多文件可以勾选接收,整包接收时会在浏览器内直接生成 ZIP。</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<NetdiskPathPickerModal
|
||||
isOpen={Boolean(savePathPickerFileId)}
|
||||
title="选择存入位置"
|
||||
description="选择保存到网盘的根目录,快传里的相对目录结构会继续保留。"
|
||||
initialPath={saveRootPath}
|
||||
confirmLabel="存入这里"
|
||||
confirmPathPreview={(path) => {
|
||||
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 (
|
||||
<div className="min-h-screen bg-[#07101D] px-4 py-8 text-white">
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
{panelContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user