587 lines
28 KiB
TypeScript
587 lines
28 KiB
TypeScript
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';
|
||
|
||
// 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 { 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) {
|
||
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; }
|
||
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();
|
||
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'); 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 (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; publishSendProgress(0, {force: true});
|
||
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) 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(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting'));
|
||
peer.send(createTransferFileManifestMessage(manifestRef.current));
|
||
},
|
||
onData: (payload) => {
|
||
if (typeof payload !== 'string') return;
|
||
const msg = parseJsonPayload<{type?: string; fileIds?: string[]}>(payload);
|
||
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; 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((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 === 'signal') {
|
||
peerRef.current?.applyRemoteSignal(item.payload);
|
||
}
|
||
}
|
||
})
|
||
.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(
|
||
peer: TransferPeerAdapter,
|
||
files: File[],
|
||
requestedFiles: TransferFileDescriptor[],
|
||
bootstrapId: number,
|
||
) {
|
||
setSendPhase('transferring');
|
||
const filesById = new Map(files.map((f) => [createTransferFileId(f), f]));
|
||
const chunkSize = resolveTransferChunkSize();
|
||
|
||
for (const desc of requestedFiles) {
|
||
if (bootstrapIdRef.current !== bootstrapId || !peer.connected) return;
|
||
const file = filesById.get(desc.id);
|
||
if (!file) continue;
|
||
|
||
peer.send(createTransferFileMetaMessage(desc));
|
||
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(desc.id));
|
||
}
|
||
peer.send(createTransferCompleteMessage());
|
||
publishSendProgress(100, {force: true}); 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">
|
||
{authReady && !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">无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。</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>
|
||
);
|
||
}
|