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

1052 lines
44 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 { AnimatePresence, motion } from 'motion/react';
import {
CheckCircle,
ChevronRight,
Clock3,
Copy,
DownloadCloud,
File as FileIcon,
Folder,
FolderPlus,
Link as LinkIcon,
Loader2,
Monitor,
Plus,
Send,
Shield,
Smartphone,
Trash2,
UploadCloud,
X,
LogIn,
} from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/src/auth/AuthProvider';
import { Button } from '@/src/components/ui/button';
import { appendTransferRelayHint } from '@/src/lib/transfer-ice';
import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links';
import {
createTransferFileManifest,
createTransferFileManifestMessage,
createTransferCompleteMessage,
createTransferFileCompleteMessage,
createTransferFileId,
createTransferFileMetaMessage,
type TransferFileDescriptor,
SIGNAL_POLL_INTERVAL_MS,
} from '@/src/lib/transfer-protocol';
import {
shouldPublishTransferProgress,
resolveTransferChunkSize,
} from '@/src/lib/transfer-runtime';
import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer';
import {
DEFAULT_TRANSFER_ICE_SERVERS,
TRANSFER_HAS_RELAY_SUPPORT,
createTransferSession,
listMyOfflineTransferSessions,
pollTransferSignals,
postTransferSignal,
uploadOfflineTransferFile,
} from '@/src/lib/transfer';
import type { TransferMode, TransferSessionResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import {
buildQrImageUrl,
canSendTransferFiles,
getAvailableTransferModes,
formatTransferSize,
getOfflineTransferSessionLabel,
getOfflineTransferSessionSize,
getTransferModeSummary,
resolveInitialTransferTab,
} from './transfer-state';
import TransferReceive from './TransferReceive';
type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'uploading' | 'transferring' | 'completed' | 'error';
function parseJsonPayload<T>(payload: string): T | null {
try {
return JSON.parse(payload) as T;
} catch {
return null;
}
}
function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: string) {
if (mode === 'OFFLINE') {
switch (phase) {
case 'creating':
return '正在创建离线快传会话并生成取件链接...';
case 'uploading':
return '文件正在上传到站点存储,上传完成后 7 天内都可以反复接收。';
case 'completed':
return '离线文件已上传完成,接收方现在可以多次下载或存入网盘。';
case 'error':
return errorMessage || '离线快传初始化失败,请重试。';
default:
return '拖拽文件后会生成离线取件码,并把文件上传到站点存储保留 7 天。';
}
}
switch (phase) {
case 'creating':
return '正在创建快传会话并准备 P2P 连接...';
case 'waiting':
return '分享链接和二维码已经生成,等待接收端打开页面并选择要接收的文件。';
case 'connecting':
return '接收端已进入页面,正在交换浏览器连接信息并同步文件清单...';
case 'transferring':
return 'P2P 直连已建立,文件正在发送到对方浏览器。';
case 'completed':
return '本次文件已发送完成,对方页面现在可以下载。';
case 'error':
return errorMessage || '快传会话初始化失败,请重试。';
default:
return '拖拽文件后会自动生成会话、二维码和公开接收页链接。';
}
}
export default function Transfer() {
const navigate = useNavigate();
const { ready: authReady, session: authSession } = useAuth();
const [searchParams] = useSearchParams();
const sessionId = searchParams.get('session');
const isAuthenticated = Boolean(authSession?.token);
const allowSend = canSendTransferFiles(isAuthenticated);
const availableTransferModes = getAvailableTransferModes(isAuthenticated);
const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId));
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [transferMode, setTransferMode] = useState<TransferMode>('ONLINE');
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 [offlineHistory, setOfflineHistory] = useState<TransferSessionResponse[]>([]);
const [offlineHistoryLoading, setOfflineHistoryLoading] = useState(false);
const [offlineHistoryError, setOfflineHistoryError] = useState('');
const [selectedOfflineSession, setSelectedOfflineSession] = useState<TransferSessionResponse | null>(null);
const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const copiedTimerRef = useRef<number | null>(null);
const historyCopiedTimerRef = useRef<number | null>(null);
const pollTimerRef = useRef<number | null>(null);
const peerRef = useRef<TransferPeerAdapter | null>(null);
const cursorRef = useRef(0);
const bootstrapIdRef = useRef(0);
const totalBytesRef = useRef(0);
const sentBytesRef = useRef(0);
const lastSendProgressPublishAtRef = useRef(0);
const lastPublishedSendProgressRef = useRef(0);
const sendingStartedRef = useRef(false);
const manifestRef = useRef<TransferFileDescriptor[]>([]);
useEffect(() => {
if (!folderInputRef.current) {
return;
}
folderInputRef.current.setAttribute('webkitdirectory', '');
folderInputRef.current.setAttribute('directory', '');
}, []);
useEffect(() => {
return () => {
cleanupCurrentTransfer();
if (copiedTimerRef.current) {
window.clearTimeout(copiedTimerRef.current);
}
if (historyCopiedTimerRef.current) {
window.clearTimeout(historyCopiedTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!allowSend || sessionId) {
setActiveTab('receive');
}
}, [allowSend, sessionId]);
useEffect(() => {
if (!availableTransferModes.includes(transferMode)) {
setTransferMode('ONLINE');
}
}, [availableTransferModes, transferMode]);
useEffect(() => {
if (selectedFiles.length === 0) {
return;
}
void bootstrapTransfer(selectedFiles);
}, [transferMode]);
useEffect(() => {
if (!isAuthenticated) {
setOfflineHistory([]);
setOfflineHistoryError('');
setSelectedOfflineSession(null);
return;
}
void loadOfflineHistory();
}, [isAuthenticated]);
const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
const shareLink = session
? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode())
: '';
const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : '';
const transferModeSummary = getTransferModeSummary(transferMode);
const selectedOfflineSessionShareLink = selectedOfflineSession
? buildTransferShareUrl(window.location.origin, selectedOfflineSession.sessionId, getTransferRouterMode())
: '';
const selectedOfflineSessionQrImageUrl = selectedOfflineSessionShareLink
? buildQrImageUrl(selectedOfflineSessionShareLink)
: '';
function navigateBackToLogin() {
const nextPath = `${window.location.pathname}${window.location.search}`;
navigate(`/login?next=${encodeURIComponent(nextPath)}`);
}
function isOfflineSessionReady(sessionToCheck: TransferSessionResponse) {
return sessionToCheck.files.every((file) => file.uploaded);
}
async function loadOfflineHistory(options?: {silent?: boolean}) {
if (!isAuthenticated) {
return;
}
if (!options?.silent) {
setOfflineHistoryLoading(true);
}
setOfflineHistoryError('');
try {
const sessions = await listMyOfflineTransferSessions();
setOfflineHistory(sessions);
setSelectedOfflineSession((current) => {
if (!current) {
return current;
}
return sessions.find((item) => item.sessionId === current.sessionId) ?? null;
});
} catch (error) {
setOfflineHistoryError(error instanceof Error ? error.message : '离线快传记录加载失败');
} finally {
if (!options?.silent) {
setOfflineHistoryLoading(false);
}
}
}
function cleanupCurrentTransfer() {
if (pollTimerRef.current) {
window.clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
const peer = peerRef.current;
peerRef.current = null;
peer?.destroy();
cursorRef.current = 0;
lastSendProgressPublishAtRef.current = 0;
lastPublishedSendProgressRef.current = 0;
sendingStartedRef.current = false;
}
function publishSendProgress(nextProgress: number, options?: {force?: boolean}) {
const normalizedProgress = Math.max(0, Math.min(100, nextProgress));
const now = globalThis.performance?.now?.() ?? Date.now();
if (!options?.force && !shouldPublishTransferProgress({
nextProgress: normalizedProgress,
previousProgress: lastPublishedSendProgressRef.current,
now,
lastPublishedAt: lastSendProgressPublishAtRef.current,
})) {
return;
}
lastSendProgressPublishAtRef.current = now;
lastPublishedSendProgressRef.current = normalizedProgress;
setSendProgress(normalizedProgress);
}
function resetSenderState() {
cleanupCurrentTransfer();
setSession(null);
setSelectedFiles([]);
setSendPhase('idle');
publishSendProgress(0, {force: true});
setSendError('');
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (copiedTimerRef.current) {
window.clearTimeout(copiedTimerRef.current);
}
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
}
function ensureReadyState(nextFiles: File[]) {
setSelectedFiles(nextFiles);
if (nextFiles.length === 0) {
resetSenderState();
return;
}
void bootstrapTransfer(nextFiles);
}
function appendFiles(files: FileList | File[]) {
const nextFiles = [...selectedFiles, ...Array.from(files)];
ensureReadyState(nextFiles);
}
function handleFileSelect(event: React.ChangeEvent<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');
publishSendProgress(0, {force: true});
manifestRef.current = createTransferFileManifest(files);
totalBytesRef.current = 0;
sentBytesRef.current = 0;
try {
const createdSession = await createTransferSession(files, transferMode);
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSession(createdSession);
if (createdSession.mode === 'OFFLINE') {
void loadOfflineHistory({silent: true});
await uploadOfflineFiles(createdSession, files, bootstrapId);
return;
}
setSendPhase('waiting');
await setupSenderPeer(createdSession, files, bootstrapId);
} catch (error) {
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSendPhase('error');
setSendError(error instanceof Error ? error.message : '快传会话创建失败');
}
}
async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
setSendPhase('uploading');
totalBytesRef.current = files.reduce((sum, file) => sum + file.size, 0);
sentBytesRef.current = 0;
publishSendProgress(0, {force: true});
for (const [index, file] of files.entries()) {
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
const sessionFile = createdSession.files[index];
if (!sessionFile?.id) {
throw new Error('离线快传文件清单不完整,请重新开始本次发送。');
}
let lastLoaded = 0;
await uploadOfflineTransferFile(createdSession.sessionId, sessionFile.id, file, ({ loaded, total }) => {
const delta = loaded - lastLoaded;
lastLoaded = loaded;
sentBytesRef.current += delta;
if (loaded >= total) {
sentBytesRef.current = Math.min(totalBytesRef.current, sentBytesRef.current);
}
if (totalBytesRef.current > 0) {
publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
}
});
}
publishSendProgress(100, {force: true});
setSendPhase('completed');
void loadOfflineHistory({silent: true});
}
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
const peer = createTransferPeer({
initiator: true,
peerOptions: {
config: {
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
},
},
onSignal: (payload) => {
void postTransferSignal(createdSession.sessionId, 'sender', 'signal', payload);
},
onConnect: () => {
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSendPhase((current) => (current === 'transferring' || current === 'completed' ? current : 'connecting'));
peer.send(createTransferFileManifestMessage(manifestRef.current));
},
onData: (payload) => {
if (typeof payload !== 'string') {
return;
}
const message = parseJsonPayload<{type?: string; fileIds?: string[]}>(payload);
if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds) || sendingStartedRef.current) {
return;
}
const requestedFiles = manifestRef.current.filter((item) => message.fileIds?.includes(item.id));
if (requestedFiles.length === 0) {
return;
}
sendingStartedRef.current = true;
totalBytesRef.current = requestedFiles.reduce((sum, file) => sum + file.size, 0);
sentBytesRef.current = 0;
publishSendProgress(0, {force: true});
void sendSelectedFiles(peer, files, requestedFiles, bootstrapId);
},
onError: (error) => {
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSendPhase('error');
setSendError(appendTransferRelayHint(
error.message || '数据通道建立失败,请重新开始本次快传。',
TRANSFER_HAS_RELAY_SUPPORT,
));
},
});
peerRef.current = peer;
startSenderPolling(createdSession.sessionId, bootstrapId);
}
function startSenderPolling(sessionId: string, bootstrapId: number) {
let polling = false;
pollTimerRef.current = window.setInterval(() => {
if (polling || bootstrapIdRef.current !== bootstrapId) {
return;
}
polling = true;
void pollTransferSignals(sessionId, 'sender', cursorRef.current)
.then(async (response) => {
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
cursorRef.current = response.nextCursor;
for (const item of response.items) {
if (item.type === 'peer-joined') {
setSendPhase((current) => (current === 'waiting' ? 'connecting' : current));
continue;
}
if (item.type === 'signal') {
peerRef.current?.applyRemoteSignal(item.payload);
}
}
})
.catch((error) => {
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSendPhase('error');
setSendError(error instanceof Error ? error.message : '轮询连接状态失败');
})
.finally(() => {
polling = false;
});
}, SIGNAL_POLL_INTERVAL_MS);
}
async function sendSelectedFiles(
peer: TransferPeerAdapter,
files: File[],
requestedFiles: TransferFileDescriptor[],
bootstrapId: number,
) {
setSendPhase('transferring');
const filesById = new Map(files.map((file) => [createTransferFileId(file), file]));
const chunkSize = resolveTransferChunkSize();
for (const descriptor of requestedFiles) {
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) {
return;
}
const file = filesById.get(descriptor.id);
if (!file) {
continue;
}
peer.send(createTransferFileMetaMessage(descriptor));
for (let offset = 0; offset < file.size; offset += chunkSize) {
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) {
return;
}
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
await peer.write(chunk);
sentBytesRef.current += chunk.byteLength;
if (totalBytesRef.current > 0) {
publishSendProgress(Math.min(
99,
Math.round((sentBytesRef.current / totalBytesRef.current) * 100),
));
}
}
peer.send(createTransferFileCompleteMessage(descriptor.id));
}
peer.send(createTransferCompleteMessage());
publishSendProgress(100, {force: true});
setSendPhase('completed');
}
async function copyOfflineSessionLink(sessionToCopy: TransferSessionResponse) {
const sessionShareLink = buildTransferShareUrl(
window.location.origin,
sessionToCopy.sessionId,
getTransferRouterMode(),
);
await navigator.clipboard.writeText(sessionShareLink);
setHistoryCopiedSessionId(sessionToCopy.sessionId);
if (historyCopiedTimerRef.current) {
window.clearTimeout(historyCopiedTimerRef.current);
}
historyCopiedTimerRef.current = window.setTimeout(() => {
setHistoryCopiedSessionId((current) => (current === sessionToCopy.sessionId ? null : current));
}, 1800);
}
return (
<div className="flex-1 py-6 md:py-10">
<div className="mx-auto 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"></h1>
<p className="text-slate-400">线 P2P 线 7 </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">
{authReady && !isAuthenticated ? (
<div className="mb-6 flex flex-col gap-3 rounded-2xl border border-blue-400/15 bg-blue-500/10 px-4 py-4 text-sm text-blue-100 md:flex-row md:items-center md:justify-between">
<p className="leading-6">
线线线线线
</p>
<Button
type="button"
variant="outline"
onClick={navigateBackToLogin}
className="shrink-0 border border-white/10 bg-white/10 text-white hover:bg-white/15"
>
<LogIn className="mr-2 h-4 w-4" />
</Button>
</div>
) : null}
<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"
>
{availableTransferModes.length > 1 ? (
<div className="mb-6 grid gap-3 md:grid-cols-2">
{availableTransferModes.map((mode) => {
const summary = getTransferModeSummary(mode);
const active = transferMode === mode;
return (
<button
key={mode}
type="button"
onClick={() => setTransferMode(mode)}
className={cn(
'rounded-2xl border p-4 text-left transition-colors',
active
? 'border-blue-400/40 bg-blue-500/10'
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.05]',
)}
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-white">{summary.title}</p>
<span className={cn(
'rounded-full px-2.5 py-1 text-[11px] font-medium',
active ? 'bg-blue-400/15 text-blue-100' : 'bg-white/10 text-slate-300',
)}>
{mode === 'ONLINE' ? '一次接收' : '7 天多次'}
</span>
</div>
<p className="mt-2 text-sm leading-6 text-slate-400">{summary.description}</p>
</button>
);
})}
</div>
) : null}
{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">
{transferModeSummary.description}
</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(transferMode, sendPhase, sendError)}
</p>
<p className="text-xs text-slate-400 mt-1">
{sendProgress}%{session ? ` · 会话有效期至 ${new Date(session.expiresAt).toLocaleString('zh-CN', {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}` : ''}
</p>
</div>
</div>
</div>
</div>
)}
{isAuthenticated ? (
<div className="mt-8 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-white">线</h3>
<p className="mt-1 text-sm text-slate-400">
线
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void loadOfflineHistory()}
className="h-8 shrink-0 border-white/10 bg-transparent px-3 text-slate-300 hover:bg-white/10"
>
</Button>
</div>
{offlineHistoryLoading && offlineHistory.length === 0 ? (
<div className="rounded-2xl border border-white/5 bg-black/10 px-4 py-10 text-center text-sm text-slate-400">
线...
</div>
) : offlineHistoryError ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/10 px-4 py-4 text-sm text-rose-200">
{offlineHistoryError}
</div>
) : offlineHistory.length === 0 ? (
<div className="rounded-2xl border border-white/5 bg-black/10 px-4 py-10 text-center text-sm text-slate-400">
线
</div>
) : (
<div className="grid gap-3">
{offlineHistory.map((historySession) => {
const ready = isOfflineSessionReady(historySession);
return (
<button
key={historySession.sessionId}
type="button"
onClick={() => setSelectedOfflineSession(historySession)}
className="group flex w-full items-center justify-between gap-4 rounded-2xl border border-white/8 bg-black/10 px-4 py-4 text-left transition-colors hover:border-[#336EFF]/30 hover:bg-white/[0.04]"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<p className="truncate text-sm font-semibold text-white">
{getOfflineTransferSessionLabel(historySession)}
</p>
<span className={cn(
'rounded-full px-2.5 py-1 text-[11px] font-medium',
ready ? 'bg-emerald-500/15 text-emerald-200' : 'bg-amber-500/15 text-amber-100',
)}>
{ready ? '可接收' : '上传中'}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-400">
<span> {historySession.pickupCode}</span>
<span>{historySession.files.length} </span>
<span>{getOfflineTransferSessionSize(historySession)}</span>
<span>
{new Date(historySession.expiresAt).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
<ChevronRight className="h-5 w-5 shrink-0 text-slate-500 transition-colors group-hover:text-white" />
</button>
);
})}
</div>
)}
</div>
) : null}
<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">线线 7 </p>
</div>
</div>
</div>
</div>
<AnimatePresence>
{selectedOfflineSession ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-[#020817]/75 px-4 py-6 backdrop-blur-md"
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 16 }}
transition={{ duration: 0.18 }}
className="relative w-full max-w-[34rem] overflow-hidden rounded-[2rem] border border-white/10 bg-[#0d1528]/95 p-8 shadow-[0_30px_120px_rgba(0,0,0,0.45)]"
>
<button
type="button"
onClick={() => setSelectedOfflineSession(null)}
className="absolute right-5 top-5 rounded-full p-2 text-slate-500 transition-colors hover:bg-white/5 hover:text-white"
aria-label="关闭离线快传详情"
>
<X className="h-7 w-7" />
</button>
<div className="text-center">
<p className="text-sm tracking-[0.3em] text-slate-400"></p>
<div className="mt-5 font-mono text-[4.5rem] font-bold leading-none tracking-[0.32em] text-white">
{selectedOfflineSession.pickupCode}
</div>
</div>
{selectedOfflineSessionQrImageUrl ? (
<div className="mx-auto mt-10 w-fit rounded-[2rem] bg-white p-5 shadow-[0_18px_60px_rgba(15,23,42,0.32)]">
<img
src={selectedOfflineSessionQrImageUrl}
alt="离线快传二维码"
className="h-64 w-64 rounded-2xl"
/>
</div>
) : null}
<div className="mt-8 rounded-[1.7rem] border border-white/10 bg-[#0a1122] px-5 py-4">
<div className="mb-3 flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-slate-500">
<LinkIcon className="h-4 w-4" />
</div>
<div className="truncate text-[1.05rem] text-slate-100">
{selectedOfflineSessionShareLink}
</div>
</div>
<Button
type="button"
variant="outline"
className="mt-5 h-16 w-full rounded-[1.35rem] border-white/10 bg-transparent text-xl text-white hover:bg-white/5"
onClick={() => void copyOfflineSessionLink(selectedOfflineSession)}
>
<Copy className="mr-3 h-6 w-6" />
{historyCopiedSessionId === selectedOfflineSession.sessionId ? '已复制链接' : '复制链接'}
</Button>
<div className="mt-6 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-slate-400">
<span>{getOfflineTransferSessionLabel(selectedOfflineSession)}</span>
<span>{selectedOfflineSession.files.length} </span>
<span>{getOfflineTransferSessionSize(selectedOfflineSession)}</span>
<span className={cn(
isOfflineSessionReady(selectedOfflineSession) ? 'text-emerald-300' : 'text-amber-200',
)}>
{isOfflineSessionReady(selectedOfflineSession) ? '文件已就绪,可重复接收' : '文件仍在上传中'}
</span>
</div>
<div className="mt-3 flex items-center gap-2 text-sm text-slate-500">
<Clock3 className="h-4 w-4" />
{new Date(selectedOfflineSession.expiresAt).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}