添加快传7天离线传
This commit is contained in:
30
front/src/lib/transfer.test.ts
Normal file
30
front/src/lib/transfer.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">当前页面通过后端交换 offer、answer 和 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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user