Files
my_site/front/src/pages/TransferReceive.tsx

880 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"> offeranswer 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>
);
}