添加快传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

@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildOfflineTransferDownloadUrl, toTransferFilePayload } from './transfer';
test('toTransferFilePayload keeps relative folder paths for transfer files', () => {
const report = new File(['hello'], 'report.pdf', {
type: 'application/pdf',
});
Object.defineProperty(report, 'webkitRelativePath', {
configurable: true,
value: '课程资料/第一周/report.pdf',
});
assert.deepEqual(toTransferFilePayload([report]), [
{
name: 'report.pdf',
relativePath: '课程资料/第一周/report.pdf',
size: 5,
contentType: 'application/pdf',
},
]);
});
test('buildOfflineTransferDownloadUrl points to the public offline download endpoint', () => {
assert.equal(
buildOfflineTransferDownloadUrl('session-1', 'file-1'),
'/api/transfer/sessions/session-1/files/file-1/download',
);
});

View File

@@ -1,4 +1,7 @@
import type { FileMetadata, TransferMode } from './types';
import { apiRequest } from './api';
import { apiUploadRequest } from './api';
import { getTransferFileRelativePath } from './transfer-protocol';
import type {
LookupTransferSessionResponse,
PollTransferSignalsResponse,
@@ -13,15 +16,17 @@ export const DEFAULT_TRANSFER_ICE_SERVERS: RTCIceServer[] = [
export function toTransferFilePayload(files: File[]) {
return files.map((file) => ({
name: file.name,
relativePath: getTransferFileRelativePath(file),
size: file.size,
contentType: file.type || 'application/octet-stream',
}));
}
export function createTransferSession(files: File[]) {
export function createTransferSession(files: File[], mode: TransferMode) {
return apiRequest<TransferSessionResponse>('/transfer/sessions', {
method: 'POST',
body: {
mode,
files: toTransferFilePayload(files),
},
});
@@ -39,6 +44,38 @@ export function joinTransferSession(sessionId: string) {
});
}
export function uploadOfflineTransferFile(
sessionId: string,
fileId: string,
file: File,
onProgress?: (progress: {loaded: number; total: number}) => void,
) {
const body = new FormData();
body.append('file', file);
return apiUploadRequest<void>(`/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/content`, {
body,
onProgress,
});
}
export function buildOfflineTransferDownloadUrl(sessionId: string, fileId: string) {
const apiBaseUrl = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
return `${apiBaseUrl}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`;
}
export function importOfflineTransferFile(sessionId: string, fileId: string, path: string) {
return apiRequest<FileMetadata>(
`/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/import`,
{
method: 'POST',
body: {
path,
},
},
);
}
export function postTransferSignal(sessionId: string, role: 'sender' | 'receiver', type: string, payload: string) {
return apiRequest<void>(`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}`, {
method: 'POST',

View File

@@ -106,15 +106,21 @@ export interface FileShareDetailsResponse {
createdAt: string;
}
export type TransferMode = 'ONLINE' | 'OFFLINE';
export interface TransferFileItem {
id?: string | null;
name: string;
relativePath: string;
size: number;
contentType: string;
uploaded?: boolean | null;
}
export interface TransferSessionResponse {
sessionId: string;
pickupCode: string;
mode: TransferMode;
expiresAt: string;
files: TransferFileItem[];
}
@@ -122,6 +128,7 @@ export interface TransferSessionResponse {
export interface LookupTransferSessionResponse {
sessionId: string;
pickupCode: string;
mode: TransferMode;
expiresAt: string;
}

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>

View File

@@ -26,7 +26,15 @@ import {
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 {
buildOfflineTransferDownloadUrl,
DEFAULT_TRANSFER_ICE_SERVERS,
importOfflineTransferFile,
joinTransferSession,
lookupTransferSession,
pollTransferSignals,
postTransferSignal,
} from '@/src/lib/transfer';
import type { TransferSessionResponse } from '@/src/lib/types';
import { canArchiveTransferSelection, formatTransferSize, sanitizeReceiveCode } from './transfer-state';
@@ -164,7 +172,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
setArchiveName(buildTransferArchiveFileName('快传文件'));
setArchiveUrl(null);
setSavingFileId(null);
setSaveMessage('');
setSaveMessage('');
try {
const joinedSession = await joinTransferSession(sessionId);
@@ -175,6 +183,27 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
setTransferSession(joinedSession);
setArchiveName(buildTransferArchiveFileName(`快传-${joinedSession.pickupCode}`));
if (joinedSession.mode === 'OFFLINE') {
const offlineFiles = joinedSession.files.map((file) => ({
id: file.id ?? file.relativePath,
name: file.name,
size: file.size,
contentType: file.contentType,
relativePath: file.relativePath,
progress: file.uploaded ? 100 : 0,
selected: true,
requested: true,
downloadUrl: file.id ? buildOfflineTransferDownloadUrl(joinedSession.sessionId, file.id) : undefined,
savedToNetdisk: false,
}));
setFiles(offlineFiles);
setRequestSubmitted(true);
setOverallProgress(offlineFiles.length > 0 ? 100 : 0);
setPhase('completed');
return;
}
const connection = new RTCPeerConnection({
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
});
@@ -567,6 +596,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
const canZipAllFiles = canArchiveTransferSelection(files);
const hasSelectableFiles = selectedFiles.length > 0;
const canSubmitSelection = Boolean(dataChannelRef.current && dataChannelRef.current.readyState === 'open' && hasSelectableFiles);
const isOfflineSession = transferSession?.mode === 'OFFLINE';
const panelContent = (
<>
@@ -576,7 +606,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
<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>
<p className="text-slate-400">线 P2P线 7 </p>
</div>
) : null}
@@ -650,11 +680,17 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
: '文件清单已同步,请勾选要接收的文件。')}
{phase === 'connecting' && 'P2P 通道协商中...'}
{phase === 'receiving' && '文件正在接收...'}
{phase === 'completed' && (archiveUrl ? '接收完成ZIP 已准备好下载' : '接收完成,下面可以下载文件')}
{phase === 'completed' && (isOfflineSession
? '离线文件已就绪7 天内可以重复下载或存入网盘'
: archiveUrl
? '接收完成ZIP 已准备好下载'
: '接收完成,下面可以下载文件')}
{phase === 'error' && '接收失败'}
</p>
<p className="text-xs text-slate-400 mt-1">
{errorMessage || `总进度 ${overallProgress}%`}
{errorMessage || (isOfflineSession && transferSession
? `离线有效期至 ${new Date(transferSession.expiresAt).toLocaleString('zh-CN', {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'})}`
: `总进度 ${overallProgress}%`)}
</p>
</div>
</div>
@@ -696,7 +732,9 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
<div>
<h3 className="text-lg font-medium"></h3>
<p className="mt-1 text-xs text-slate-500">
{requestSubmitted
{isOfflineSession
? `离线模式 · ${files.length}`
: requestSubmitted
? `已请求 ${requestedFiles.length}`
: `已选择 ${selectedFiles.length} 项 · ${formatTransferSize(selectedSize)}`}
</p>
@@ -749,7 +787,7 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
<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">
{isOfflineSession ? '离线文件上传完成后,会直接在这里显示可下载清单。' : '连接建立后会先同步文件清单,你可以在这里先勾选想接收的内容。'}
</div>
) : (
files.map((file) => (
@@ -831,15 +869,15 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
<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>
<h4 className="text-sm font-medium text-slate-100 mb-1">线 P2P线</h4>
<p className="text-xs leading-6 text-slate-500">线线</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>
<h4 className="text-sm font-medium text-slate-100 mb-1">线 7 </h4>
<p className="text-xs leading-6 text-slate-500">线</p>
</div>
</div>
) : null}
@@ -851,7 +889,11 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
initialPath={saveRootPath}
confirmLabel="存入这里"
confirmPathPreview={(path) => {
const offlineFile = savePathPickerFileId ? files.find((file) => file.id === savePathPickerFileId) : null;
const completedFile = savePathPickerFileId ? completedFilesRef.current.get(savePathPickerFileId) : null;
if (offlineFile) {
return resolveNetdiskSaveDirectory(offlineFile.relativePath, path);
}
return completedFile ? resolveNetdiskSaveDirectory(completedFile.relativePath, path) : path;
}}
onClose={() => setSavePathPickerFileId(null)}
@@ -860,7 +902,22 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
return;
}
setSaveRootPath(path);
await saveCompletedFile(savePathPickerFileId, path);
if (isOfflineSession && transferSession) {
const savedFile = await importOfflineTransferFile(transferSession.sessionId, savePathPickerFileId, path);
setFiles((current) =>
current.map((file) =>
file.id === savePathPickerFileId
? {
...file,
savedToNetdisk: true,
}
: file,
),
);
setSaveMessage(`${savedFile.filename} 已存入网盘 ${savedFile.path}`);
} else {
await saveCompletedFile(savePathPickerFileId, path);
}
setSavePathPickerFileId(null);
}}
/>

View File

@@ -8,6 +8,7 @@ import {
canSendTransferFiles,
createMockTransferCode,
formatTransferSize,
getTransferModeSummary,
resolveInitialTransferTab,
sanitizeReceiveCode,
} from './transfer-state';
@@ -60,6 +61,13 @@ test('canSendTransferFiles requires an authenticated session', () => {
assert.equal(canSendTransferFiles(false), false);
});
test('getTransferModeSummary describes the offline seven-day retention rule', () => {
assert.deepEqual(getTransferModeSummary('OFFLINE'), {
title: '发离线',
description: '文件先上传到站点存储,保留 7 天,到期自动销毁,可被多次接收。',
});
});
test('canArchiveTransferSelection is enabled for multi-file or folder downloads', () => {
assert.equal(canArchiveTransferSelection([
{

View File

@@ -1,3 +1,4 @@
import type { TransferMode } from '../lib/types';
import type { TransferFileDescriptor } from '../lib/transfer-protocol';
export type TransferTab = 'send' | 'receive';
@@ -36,6 +37,20 @@ export function canSendTransferFiles(isAuthenticated: boolean) {
return isAuthenticated;
}
export function getTransferModeSummary(mode: TransferMode) {
if (mode === 'OFFLINE') {
return {
title: '发离线',
description: '文件先上传到站点存储,保留 7 天,到期自动销毁,可被多次接收。',
};
}
return {
title: '发在线',
description: '文件通过浏览器 P2P 直连发送,只能被接收一次,适合双方都在线时快速传输。',
};
}
export function resolveInitialTransferTab(
isAuthenticated: boolean,
sessionId: string | null,