Add offline transfer history and mobile app support

This commit is contained in:
yoyuzh
2026-04-02 17:31:28 +08:00
parent 2cdda3c305
commit f02ff9342f
17 changed files with 2600 additions and 2 deletions

View File

@@ -0,0 +1,564 @@
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 { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links';
import {
createTransferFileManifest,
createTransferFileManifestMessage,
createTransferCompleteMessage,
createTransferFileCompleteMessage,
createTransferFileId,
createTransferFileMetaMessage,
type TransferFileDescriptor,
SIGNAL_POLL_INTERVAL_MS,
TRANSFER_CHUNK_SIZE,
} from '@/src/lib/transfer-protocol';
import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime';
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
import {
DEFAULT_TRANSFER_ICE_SERVERS,
createTransferSession,
listMyOfflineTransferSessions,
pollTransferSignals,
postTransferSignal,
uploadOfflineTransferFile,
} from '@/src/lib/transfer';
import type { TransferMode, TransferSessionResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
// We reuse the state and sub-component
import {
buildQrImageUrl,
canSendTransferFiles,
getAvailableTransferModes,
formatTransferSize,
getOfflineTransferSessionLabel,
getOfflineTransferSessionSize,
getTransferModeSummary,
resolveInitialTransferTab,
} from '@/src/pages/transfer-state';
import TransferReceive from '@/src/pages/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 MobileTransfer() {
const navigate = useNavigate();
const { 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 peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const dataChannelRef = useRef<RTCDataChannel | null>(null);
const cursorRef = useRef(0);
const bootstrapIdRef = useRef(0);
const totalBytesRef = useRef(0);
const sentBytesRef = useRef(0);
const sendingStartedRef = useRef(false);
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
const manifestRef = useRef<TransferFileDescriptor[]>([]);
useEffect(() => {
if (folderInputRef.current) {
folderInputRef.current.setAttribute('webkitdirectory', '');
folderInputRef.current.setAttribute('directory', '');
}
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) 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((f) => f.uploaded);
}
async function loadOfflineHistory(options?: {silent?: boolean}) {
if (!isAuthenticated) return;
if (!options?.silent) setOfflineHistoryLoading(true);
setOfflineHistoryError('');
try {
const sessions = await listMyOfflineTransferSessions();
setOfflineHistory(sessions);
setSelectedOfflineSession((curr) => curr ? (sessions.find((item) => item.sessionId === curr.sessionId) ?? null) : null);
} catch (e) {
setOfflineHistoryError(e instanceof Error ? e.message : '离线快传记录加载失败');
} finally {
if (!options?.silent) setOfflineHistoryLoading(false);
}
}
function cleanupCurrentTransfer() {
if (pollTimerRef.current) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
if (dataChannelRef.current) { dataChannelRef.current.close(); dataChannelRef.current = null; }
if (peerConnectionRef.current) { peerConnectionRef.current.close(); peerConnectionRef.current = null; }
cursorRef.current = 0; sendingStartedRef.current = false; pendingRemoteCandidatesRef.current = [];
}
function resetSenderState() {
cleanupCurrentTransfer();
setSession(null); setSelectedFiles([]); setSendPhase('idle'); setSendProgress(0); setSendError('');
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (copiedTimerRef.current) window.clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1800);
} catch { setCopied(false); }
}
function ensureReadyState(nextFiles: File[]) {
setSelectedFiles(nextFiles);
if (nextFiles.length === 0) resetSenderState();
else void bootstrapTransfer(nextFiles);
}
function appendFiles(files: FileList | File[]) {
ensureReadyState([...selectedFiles, ...Array.from(files)]);
}
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files?.length) appendFiles(e.target.files);
e.target.value = '';
}
function removeFile(idx: number) {
ensureReadyState(selectedFiles.filter((_, i) => i !== idx));
}
async function bootstrapTransfer(files: File[]) {
const bootstrapId = bootstrapIdRef.current + 1;
bootstrapIdRef.current = bootstrapId;
cleanupCurrentTransfer();
setSendError(''); setSendPhase('creating'); setSendProgress(0);
manifestRef.current = createTransferFileManifest(files);
totalBytesRef.current = 0; sentBytesRef.current = 0;
try {
const createdSession = await createTransferSession(files, 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 (e) {
if (bootstrapIdRef.current !== bootstrapId) return;
setSendPhase('error');
setSendError(e instanceof Error ? e.message : '快传会话创建失败');
}
}
async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
setSendPhase('uploading');
totalBytesRef.current = files.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0);
for (const [idx, file] of files.entries()) {
if (bootstrapIdRef.current !== bootstrapId) return;
const sessionFile = createdSession.files[idx];
if (!sessionFile?.id) throw new Error('单次传送配置异常,请重新传输。');
let lastLoaded = 0;
await uploadOfflineTransferFile(createdSession.sessionId, sessionFile.id, file, ({ loaded, total }) => {
sentBytesRef.current += (loaded - lastLoaded); lastLoaded = loaded;
if (loaded >= total) sentBytesRef.current = Math.min(totalBytesRef.current, sentBytesRef.current);
if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
});
}
setSendProgress(100); setSendPhase('completed');
void loadOfflineHistory({silent: true});
}
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
const conn = new RTCPeerConnection({ iceServers: DEFAULT_TRANSFER_ICE_SERVERS });
const channel = conn.createDataChannel('portal-transfer', { ordered: true });
peerConnectionRef.current = conn; dataChannelRef.current = channel; channel.binaryType = 'arraybuffer';
conn.onicecandidate = (e) => {
if (e.candidate) void postTransferSignal(createdSession.sessionId, 'sender', 'ice-candidate', JSON.stringify(e.candidate.toJSON()));
};
conn.onconnectionstatechange = () => {
if (conn.connectionState === 'connected') setSendPhase(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting'));
if (conn.connectionState === 'failed' || conn.connectionState === 'disconnected') { setSendPhase('error'); setSendError('浏览器直连失败'); }
};
channel.onopen = () => channel.send(createTransferFileManifestMessage(manifestRef.current));
channel.onmessage = (e) => {
if (typeof e.data !== 'string') return;
const msg = parseJsonPayload<{type?: string; fileIds?: string[];}>(e.data);
if (!msg || msg.type !== 'receive-request' || !Array.isArray(msg.fileIds) || sendingStartedRef.current) return;
const requestedFiles = manifestRef.current.filter((item) => msg.fileIds?.includes(item.id));
if (requestedFiles.length === 0) return;
sendingStartedRef.current = true;
totalBytesRef.current = requestedFiles.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0);
void sendSelectedFiles(channel, files, requestedFiles, bootstrapId);
};
channel.onerror = () => { setSendPhase('error'); setSendError('数据通道建立失败'); };
startSenderPolling(createdSession.sessionId, conn, bootstrapId);
const offer = await conn.createOffer();
await conn.setLocalDescription(offer);
await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer));
}
function startSenderPolling(sessionId: string, conn: RTCPeerConnection, bootstrapId: number) {
let polling = false;
pollTimerRef.current = window.setInterval(() => {
if (polling || bootstrapIdRef.current !== bootstrapId) return;
polling = true;
void pollTransferSignals(sessionId, 'sender', cursorRef.current)
.then(async (res) => {
if (bootstrapIdRef.current !== bootstrapId) return;
cursorRef.current = res.nextCursor;
for (const item of res.items) {
if (item.type === 'peer-joined') {
setSendPhase(cur => (cur === 'waiting' ? 'connecting' : cur));
continue;
}
if (item.type === 'answer' && !conn.currentRemoteDescription) {
const answer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
if (answer) {
await conn.setRemoteDescription(answer);
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(conn, pendingRemoteCandidatesRef.current);
}
continue;
}
if (item.type === 'ice-candidate') {
const cand = parseJsonPayload<RTCIceCandidateInit>(item.payload);
if (cand) pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(conn, pendingRemoteCandidatesRef.current, cand);
}
}
})
.catch(e => {
if (bootstrapIdRef.current !== bootstrapId) return;
setSendPhase('error'); setSendError(e instanceof Error ? e.message : '状态轮询失败');
})
.finally(() => polling = false);
}, SIGNAL_POLL_INTERVAL_MS);
}
async function sendSelectedFiles(channel: RTCDataChannel, files: File[], requestedFiles: TransferFileDescriptor[], bootstrapId: number) {
setSendPhase('transferring');
const filesById = new Map(files.map((f) => [createTransferFileId(f), f]));
for (const desc of requestedFiles) {
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return;
const file = filesById.get(desc.id);
if (!file) continue;
channel.send(createTransferFileMetaMessage(desc));
for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) {
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return;
const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer();
await waitForTransferChannelDrain(channel);
channel.send(chunk);
sentBytesRef.current += chunk.byteLength;
if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
}
channel.send(createTransferFileCompleteMessage(desc.id));
}
channel.send(createTransferCompleteMessage());
setSendProgress(100); setSendPhase('completed');
}
async function copyOfflineSessionLink(s: TransferSessionResponse) {
const sl = buildTransferShareUrl(window.location.origin, s.sessionId, getTransferRouterMode());
await navigator.clipboard.writeText(sl);
setHistoryCopiedSessionId(s.sessionId);
if (historyCopiedTimerRef.current) window.clearTimeout(historyCopiedTimerRef.current);
historyCopiedTimerRef.current = window.setTimeout(() => {
setHistoryCopiedSessionId((curr) => (curr === s.sessionId ? null : curr));
}, 1800);
}
return (
<div className="relative flex flex-col min-h-full overflow-hidden bg-[#07101D]">
<div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute top-[-18%] left-[-22%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
<div className="absolute top-[10%] right-[-18%] h-80 w-80 rounded-full bg-cyan-500 opacity-16 mix-blend-screen blur-[96px] animate-blob animation-delay-2000" />
<div className="absolute bottom-[-18%] left-[14%] h-80 w-80 rounded-full bg-purple-600 opacity-18 mix-blend-screen blur-[100px] animate-blob animation-delay-4000" />
</div>
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
<input type="file" multiple className="hidden" ref={folderInputRef} onChange={handleFileSelect} />
{/* 顶部标题区 */}
<div className="relative z-10 overflow-hidden bg-[url('/noise.png')] px-5 pt-8 pb-4">
<div className="absolute top-[-50%] right-[-10%] h-[150%] w-[120%] rounded-full bg-[#336EFF] opacity-15 mix-blend-screen blur-[80px]" />
<div className="relative z-10 font-bold text-2xl tracking-wide flex items-center">
<Send className="mr-3 w-6 h-6 text-cyan-400" />
</div>
</div>
{allowSend && (
<div className="relative z-10 flex bg-[#0f172a] shadow-md border-b border-white/5 mx-4 mt-2 rounded-2xl overflow-hidden glass-panel shrink-0">
<button
onClick={() => setActiveTab('send')}
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
activeTab === 'send' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
>
<UploadCloud className="w-4 h-4" />
</button>
<button
onClick={() => setActiveTab('receive')}
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
activeTab === 'receive' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
>
<DownloadCloud className="w-4 h-4" />
</button>
</div>
)}
<div className="relative z-10 flex-1 flex flex-col p-4 min-w-0 pb-24">
{!isAuthenticated && (
<div className="mb-4 flex flex-col gap-2 rounded-xl bg-blue-500/10 px-4 py-3 text-xs text-blue-100/90 border border-blue-400/10">
<p className="leading-relaxed">线线7</p>
<Button variant="outline" size="sm" onClick={navigateBackToLogin} className="w-full bg-white/5 border-white/10 text-white mt-1">
<LogIn className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
)}
<AnimatePresence mode="wait">
{activeTab === 'send' ? (
<motion.div key="send" initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 10 }} className="flex-1 flex flex-col min-w-0">
{selectedFiles.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center mt-2 glass-panel rounded-3xl p-6 border border-white/5 overflow-hidden">
<div className="w-20 h-20 rounded-full bg-blue-500/10 border-4 border-blue-400/10 shadow-xl flex items-center justify-center mb-6">
<Plus className="w-10 h-10 text-blue-400" />
</div>
<h3 className="text-xl font-bold text-white mb-2"></h3>
<p className="text-sm text-slate-400 text-center mb-8 px-4 leading-relaxed">
线P2P链接<br/>线7
</p>
{availableTransferModes.length > 1 && (
<div className="w-full max-w-sm mb-8 bg-black/20 rounded-xl p-1 flex">
{availableTransferModes.map(mode => (
<button key={mode} onClick={() => setTransferMode(mode)} className={cn("flex-1 text-xs py-2 rounded-lg transition-all", transferMode === mode ? "bg-blue-500 text-white font-medium" : "text-slate-400")}>
{mode === 'ONLINE' ? '在线(一次性)' : '离线(存7天)'}
</button>
))}
</div>
)}
<div className="flex gap-4 w-full max-w-sm">
<Button className="flex-1 rounded-xl bg-[#336EFF] hover:bg-blue-600 h-14" onClick={() => fileInputRef.current?.click()}>
<FileIcon className="mr-2 h-5 w-5" />
</Button>
<Button className="flex-1 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 text-white h-14" onClick={() => folderInputRef.current?.click()}>
<Folder className="mr-2 h-5 w-5" />
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="glass-panel p-5 rounded-2xl flex flex-col items-center text-center">
<button onClick={resetSenderState} className="absolute right-6 top-6 p-2 rounded-full bg-black/40 text-slate-400"><X className="w-4 h-4"/></button>
<p className="text-xs text-slate-400 uppercase tracking-widest mb-1.5 flex items-center gap-1">
<LinkIcon className="w-3 h-3"/>
</p>
<div className="text-4xl md:text-5xl font-mono tracking-widest text-[#336EFF] font-bold mb-4 bg-blue-500/10 px-4 py-2 rounded-2xl border border-blue-400/20">{session?.pickupCode ?? '...'}</div>
{qrImageUrl && <img src={qrImageUrl} className="w-32 h-32 rounded-xl border-4 border-white/10 shadow-xl mb-4" />}
<div className="w-full">
<Button size="sm" variant="outline" className="w-full border-white/10 bg-black/40 text-slate-300" onClick={() => 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>
{/* 状态区 */}
<div className={cn("rounded-2xl p-4 flex gap-3 items-center border",
sendPhase === 'error' ? 'bg-rose-500/10 border-rose-500/20 text-rose-300' :
sendPhase === 'completed' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' :
'bg-[#336EFF]/10 border-blue-500/20 text-blue-300'
)}>
{sendPhase === 'completed' ? <CheckCircle className="w-6 h-6 shrink-0"/> : <Loader2 className={cn("w-6 h-6 shrink-0", sendPhase === 'error' ? 'text-rose-400' : 'animate-spin')}/>}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium leading-relaxed">{getPhaseMessage(transferMode, sendPhase, sendError)}</p>
{sendPhase !== 'error' && sendPhase !== 'completed' && <div className="mt-2 h-1.5 bg-black/40 rounded-full overflow-hidden"><div className="h-full bg-blue-500" style={{ width: `${sendProgress}%` }}/></div>}
</div>
</div>
{/* 文件列表 */}
<div className="glass-panel rounded-2xl p-2.5 max-h-[40vh] overflow-y-auto">
<p className="text-xs text-slate-500 mb-2 px-2.5 pt-2"> {selectedFiles.length} / {formatTransferSize(totalSize)}</p>
{selectedFiles.map((f, i) => (
<div key={i} className="flex px-2.5 py-2 items-center gap-3 bg-white/[0.03] rounded-xl mb-1 hover:bg-white/5 active:bg-white/10 transition-colors">
<div className="p-1.5 rounded-lg bg-black/20"><FileIcon className="w-4 h-4 text-[#336EFF]" /></div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-slate-200 truncate">{f.name}</p>
<p className="text-[10px] text-slate-500">{formatTransferSize(f.size)}</p>
</div>
<button onClick={() => removeFile(i)} className="p-2 -mr-2 text-slate-500"><Trash2 className="w-3.5 h-3.5"/></button>
</div>
))}
</div>
</div>
)}
{/* 离线区简略版 */}
{isAuthenticated && activeTab === 'send' && selectedFiles.length === 0 && (
<div className="mt-8">
<h3 className="text-sm font-semibold text-slate-200 ml-2 mb-3">线</h3>
<div className="space-y-2">
{offlineHistoryLoading && offlineHistory.length === 0 ? <p className="text-xs text-center text-slate-500 py-4">...</p> :
offlineHistory.length === 0 ? <p className="text-xs text-center text-slate-500 py-4">线</p> :
offlineHistory.map(session => (
<div key={session.sessionId} onClick={() => setSelectedOfflineSession(session)} className="glass-panel p-3 rounded-xl flex items-center justify-between hover:bg-white/5 active:bg-white/10">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200">{session.pickupCode} <span className="text-xs ml-2 text-slate-500">{getOfflineTransferSessionLabel(session)}</span></p>
<p className="text-[10px] text-slate-500 mt-1">{isOfflineSessionReady(session) ? '可接收' : '处理中'} {session.files.length} {getOfflineTransferSessionSize(session)}</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-500"/>
</div>
))}
</div>
</div>
)}
</motion.div>
) : (
<motion.div key="receive" initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -10 }} className="flex-1 flex flex-col bg-transparent">
<TransferReceive embedded />
</motion.div>
)}
</AnimatePresence>
</div>
{/* Offline History Modal Mobile */}
<AnimatePresence>
{selectedOfflineSession && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 bg-[#0f172a]/95 backdrop-blur flex flex-col p-6 items-center justify-center">
<button onClick={() => setSelectedOfflineSession(null)} className="absolute top-6 right-6 p-2 bg-white/10 rounded-full"><X className="w-5 h-5 text-white"/></button>
<h3 className="text-xs text-slate-400 tracking-[0.3em] font-medium uppercase mb-4"></h3>
<div className="text-6xl font-mono text-cyan-400 font-bold tracking-[0.1em] bg-cyan-900/20 px-6 py-4 rounded-3xl border border-cyan-500/20 mb-8 shadow-[0_0_40px_rgba(34,211,238,0.2)]">
{selectedOfflineSession.pickupCode}
</div>
{selectedOfflineSessionQrImageUrl && <img src={selectedOfflineSessionQrImageUrl} className="w-48 h-48 rounded-2xl mb-8 border-4 border-white shadow-2xl" />}
<Button className="w-full max-w-sm rounded-xl h-14 bg-[#336EFF] text-base" onClick={() => copyOfflineSessionLink(selectedOfflineSession)}>
{historyCopiedSessionId === selectedOfflineSession.sessionId ? '链接已复制' : '复制提取链接'}
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}