添加快传7天离线传

This commit is contained in:
yoyuzh
2026-03-24 09:12:10 +08:00
parent e004e64009
commit b9ab1a7640
32 changed files with 1927 additions and 81 deletions

View File

@@ -36,19 +36,26 @@ import {
} 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, pollTransferSignals, postTransferSignal } from '@/src/lib/transfer';
import type { TransferSessionResponse } from '@/src/lib/types';
import {
DEFAULT_TRANSFER_ICE_SERVERS,
createTransferSession,
pollTransferSignals,
postTransferSignal,
uploadOfflineTransferFile,
} from '@/src/lib/transfer';
import type { TransferMode, TransferSessionResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import {
buildQrImageUrl,
canSendTransferFiles,
formatTransferSize,
getTransferModeSummary,
resolveInitialTransferTab,
} from './transfer-state';
import TransferReceive from './TransferReceive';
type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'transferring' | 'completed' | 'error';
type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'uploading' | 'transferring' | 'completed' | 'error';
function parseJsonPayload<T>(payload: string): T | null {
try {
@@ -58,7 +65,22 @@ function parseJsonPayload<T>(payload: string): T | null {
}
}
function getPhaseMessage(phase: SendPhase, errorMessage: string) {
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 连接...';
@@ -85,6 +107,7 @@ export default function Transfer() {
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);
@@ -129,11 +152,20 @@ export default function Transfer() {
}
}, [allowSend, sessionId]);
useEffect(() => {
if (selectedFiles.length === 0) {
return;
}
void bootstrapTransfer(selectedFiles);
}, [transferMode]);
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);
function cleanupCurrentTransfer() {
if (pollTimerRef.current) {
@@ -229,12 +261,17 @@ export default function Transfer() {
sentBytesRef.current = 0;
try {
const createdSession = await createTransferSession(files);
const createdSession = await createTransferSession(files, transferMode);
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSession(createdSession);
if (createdSession.mode === 'OFFLINE') {
await uploadOfflineFiles(createdSession, files, bootstrapId);
return;
}
setSendPhase('waiting');
await setupSenderPeer(createdSession, files, bootstrapId);
} catch (error) {
@@ -246,6 +283,42 @@ export default function Transfer() {
}
}
async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
setSendPhase('uploading');
totalBytesRef.current = files.reduce((sum, file) => sum + file.size, 0);
sentBytesRef.current = 0;
setSendProgress(0);
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) {
setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
}
});
}
setSendProgress(100);
setSendPhase('completed');
}
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
const connection = new RTCPeerConnection({
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
@@ -439,8 +512,8 @@ export default function Transfer() {
<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">P2P </h1>
<p className="text-slate-400"> P2P </p>
<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">
@@ -490,6 +563,38 @@ export default function Transfer() {
transition={{ duration: 0.2 }}
className="flex-1 flex flex-col h-full min-w-0"
>
<div className="mb-6 grid gap-3 md:grid-cols-2">
{(['ONLINE', 'OFFLINE'] as TransferMode[]).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>
{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"
@@ -501,7 +606,7 @@ export default function Transfer() {
</div>
<h3 className="text-xl font-medium text-white mb-2"></h3>
<p className="text-slate-400 mb-8 text-center max-w-md">
P2P
{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">
@@ -626,10 +731,10 @@ export default function Transfer() {
? 'text-emerald-300'
: 'text-blue-300',
)}>
{getPhaseMessage(sendPhase, sendError)}
{getPhaseMessage(transferMode, sendPhase, sendError)}
</p>
<p className="text-xs text-slate-400 mt-1">
{sendProgress}%{session ? ` · 会话有效期至 ${new Date(session.expiresAt).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})}` : ''}
{sendProgress}%{session ? ` · 会话有效期至 ${new Date(session.expiresAt).toLocaleString('zh-CN', {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}` : ''}
</p>
</div>
</div>
@@ -680,8 +785,8 @@ export default function Transfer() {
<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"></p>
<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>