880 lines
33 KiB
TypeScript
880 lines
33 KiB
TypeScript
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>
|
||
);
|
||
}
|