feat:传输文件优化

This commit is contained in:
MatrixSeven
2025-08-02 23:11:45 +08:00
parent 3a4a762cc9
commit 324408f6b2
22 changed files with 651 additions and 172 deletions

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
const GO_BACKEND_URL = process.env.GO_BACKEND_URL || 'http://localhost:8080';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
console.log('API Route: Getting room info, proxying to:', `${GO_BACKEND_URL}/api/room-info?code=${code}`);
const response = await fetch(`${GO_BACKEND_URL}/api/room-info?code=${code}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
console.log('Backend response:', response.status, data);
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('API Route Error:', error);
return NextResponse.json(
{ error: 'Failed to get room info', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useWebRTCTransfer } from '@/hooks/useWebRTCTransfer';
import { Button } from '@/components/ui/button';
import { toast } from '@/hooks/use-toast';
import { useToast } from '@/components/ui/toast-simple';
import { Upload, Download } from 'lucide-react';
import { WebRTCFileUpload } from '@/components/webrtc/WebRTCFileUpload';
import { WebRTCFileReceive } from '@/components/webrtc/WebRTCFileReceive';
@@ -21,11 +21,17 @@ interface FileInfo {
export const WebRTCFileTransfer: React.FC = () => {
const searchParams = useSearchParams();
const router = useRouter();
const { showToast } = useToast();
// 独立的文件状态
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [fileList, setFileList] = useState<FileInfo[]>([]);
const [downloadedFiles, setDownloadedFiles] = useState<Map<string, File>>(new Map());
const [currentTransferFile, setCurrentTransferFile] = useState<{
fileId: string;
fileName: string;
progress: number;
} | null>(null);
// 房间状态
const [pickupCode, setPickupCode] = useState('');
@@ -34,9 +40,8 @@ export const WebRTCFileTransfer: React.FC = () => {
const {
isConnected,
isTransferring,
isConnecting,
isWebSocketConnected,
transferProgress,
error,
connect,
disconnect,
@@ -45,7 +50,8 @@ export const WebRTCFileTransfer: React.FC = () => {
requestFile: requestFileFromHook,
onFileReceived,
onFileListReceived,
onFileRequested
onFileRequested,
onFileProgress
} = useWebRTCTransfer();
// 从URL参数中获取初始模式
@@ -112,11 +118,7 @@ export const WebRTCFileTransfer: React.FC = () => {
// 创建房间 (发送模式)
const generateCode = async () => {
if (selectedFiles.length === 0) {
toast({
title: "请先选择文件",
description: "需要选择文件才能创建传输房间",
variant: "destructive",
});
showToast("需要选择文件才能创建传输房间", "error");
return;
}
@@ -154,17 +156,10 @@ export const WebRTCFileTransfer: React.FC = () => {
// 连接WebRTC作为发送方
connect(code, 'sender');
toast({
title: "房间创建成功",
description: `取件码: ${code}`,
});
showToast(`房间创建成功,取件码: ${code}`, "success");
} catch (error) {
console.error('创建房间失败:', error);
toast({
title: "创建房间失败",
description: error instanceof Error ? error.message : '网络错误,请重试',
variant: "destructive",
});
showToast(error instanceof Error ? error.message : '网络错误,请重试', "error");
}
};
@@ -176,10 +171,27 @@ export const WebRTCFileTransfer: React.FC = () => {
setPickupCode(code.trim());
connect(code.trim(), 'receiver');
toast({
title: "正在连接...",
description: `尝试连接到房间: ${code}`,
});
showToast(`正在连接到房间: ${code}`, "info");
};
// 重置连接状态 (用于连接失败后重新输入)
const resetConnection = () => {
console.log('=== 重置连接状态 ===');
// 断开当前连接
disconnect();
// 清空状态
setPickupCode('');
setFileList([]);
setDownloadedFiles(new Map());
// 如果是接收模式更新URL移除code参数
if (mode === 'receive') {
const params = new URLSearchParams(searchParams.toString());
params.delete('code');
router.push(`?${params.toString()}`, { scroll: false });
}
};
// 处理文件列表更新
@@ -197,6 +209,17 @@ export const WebRTCFileTransfer: React.FC = () => {
return cleanup;
}, [onFileListReceived, mode]);
// 处理连接错误
useEffect(() => {
if (error && mode === 'receive') {
console.log('=== 连接错误处理 ===');
console.log('错误信息:', error);
// 显示错误提示
showToast(`连接失败: ${error}`, "error");
}
}, [error, mode, showToast]);
// 处理文件接收
useEffect(() => {
const cleanup = onFileReceived((fileData: { id: string; file: File }) => {
@@ -213,31 +236,51 @@ export const WebRTCFileTransfer: React.FC = () => {
: item
));
toast({
title: "文件下载完成",
description: `${fileData.file.name} 已准备好下载`,
});
showToast(`${fileData.file.name} 已准备好下载`, "success");
});
return cleanup;
}, [onFileReceived]);
}, [onFileReceived, showToast]);
// 实时更新传输进度
// 监听文件级别的进度更新
useEffect(() => {
console.log('=== 进度更新 ===');
console.log('传输中:', isTransferring, '进度:', transferProgress);
if (isTransferring && transferProgress > 0) {
console.log('更新文件传输进度:', transferProgress);
const cleanup = onFileProgress((progressInfo) => {
console.log('=== 文件进度更新 ===');
console.log('文件:', progressInfo.fileName, 'ID:', progressInfo.fileId, '进度:', progressInfo.progress);
// 更新当前传输文件信息
setCurrentTransferFile({
fileId: progressInfo.fileId,
fileName: progressInfo.fileName,
progress: progressInfo.progress
});
// 更新文件列表中对应文件的进度
setFileList(prev => prev.map(item => {
if (item.status === 'downloading') {
console.log(`更新文件 ${item.name} 进度从 ${item.progress}${transferProgress}`);
return { ...item, progress: transferProgress };
if (item.id === progressInfo.fileId || item.name === progressInfo.fileName) {
const newProgress = progressInfo.progress;
const newStatus = newProgress >= 100 ? 'completed' as const : 'downloading' as const;
console.log(`更新文件 ${item.name} 进度: ${item.progress} -> ${newProgress}`);
return { ...item, progress: newProgress, status: newStatus };
}
return item;
}));
}
}, [isTransferring, transferProgress]);
// 当传输完成时显示提示
if (progressInfo.progress >= 100 && mode === 'send') {
showToast(`文件发送完成: ${progressInfo.fileName}`, "success");
setCurrentTransferFile(null);
}
});
return cleanup;
}, [onFileProgress, mode, showToast]);
// 实时更新传输进度(旧逻辑 - 删除)
// useEffect(() => {
// ...已删除的旧代码...
// }, [...]);
// 处理文件请求(发送方监听)
useEffect(() => {
@@ -254,25 +297,31 @@ export const WebRTCFileTransfer: React.FC = () => {
if (!file) {
console.error('找不到匹配的文件:', fileName);
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
toast({
title: "文件不存在",
description: `无法找到文件: ${fileName}`,
variant: "destructive",
});
showToast(`无法找到文件: ${fileName}`, "error");
return;
}
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
// 更新发送方文件状态为downloading
setFileList(prev => prev.map(item =>
item.id === fileId || item.name === fileName
? { ...item, status: 'downloading' as const, progress: 0 }
: item
));
// 发送文件
sendFile(file, fileId);
// 显示开始传输的提示
showToast(`开始发送文件: ${fileName}`, "info");
} else {
console.warn('接收模式下收到文件请求,忽略');
}
});
return cleanup;
}, [onFileRequested, mode, selectedFiles, sendFile, toast]);
}, [onFileRequested, mode, selectedFiles, sendFile, showToast]);
// 连接状态变化时同步文件列表
useEffect(() => {
@@ -328,7 +377,7 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('=== 开始请求文件 ===');
console.log('文件信息:', { name: fileInfo.name, id: fileId, size: fileInfo.size });
console.log('当前文件状态:', fileInfo.status);
console.log('WebRTC连接状态:', { isConnected, isTransferring });
console.log('WebRTC连接状态:', { isConnected, isTransferring: !!currentTransferFile });
// 更新文件状态为下载中
setFileList(prev => {
@@ -345,29 +394,20 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('调用hook的requestFile...');
requestFileFromHook(fileId, fileInfo.name);
toast({
title: "请求文件",
description: `正在请求文件: ${fileInfo.name}`,
});
showToast(`正在请求文件: ${fileInfo.name}`, "info");
};
// 复制取件码
const copyCode = () => {
navigator.clipboard.writeText(pickupCode);
toast({
title: "取件码已复制",
description: "取件码已复制到剪贴板",
});
showToast("取件码已复制到剪贴板", "success");
};
// 复制链接
const copyLink = () => {
const link = `${window.location.origin}?type=webrtc&mode=receive&code=${pickupCode}`;
navigator.clipboard.writeText(link);
toast({
title: "取件链接已复制",
description: "取件链接已复制到剪贴板",
});
showToast("取件链接已复制到剪贴板", "success");
};
// 重置状态
@@ -409,10 +449,7 @@ export const WebRTCFileTransfer: React.FC = () => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "文件已保存",
description: `${file.name} 已保存到下载文件夹`,
});
showToast(`${file.name} 已保存到下载文件夹`, "success");
};
// 处理下载请求(接收模式)
@@ -432,13 +469,9 @@ export const WebRTCFileTransfer: React.FC = () => {
// 显示错误信息
useEffect(() => {
if (error) {
toast({
title: "连接错误",
description: error,
variant: "destructive",
});
showToast(error, "error");
}
}, [error]);
}, [error, showToast]);
return (
<div className="space-y-4 sm:space-y-6">
@@ -468,6 +501,7 @@ export const WebRTCFileTransfer: React.FC = () => {
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
<WebRTCFileUpload
selectedFiles={selectedFiles}
fileList={fileList}
onFilesChange={setSelectedFiles}
onGenerateCode={generateCode}
pickupCode={pickupCode}
@@ -478,7 +512,7 @@ export const WebRTCFileTransfer: React.FC = () => {
onRemoveFile={setSelectedFiles}
onClearFiles={clearFiles}
onReset={resetRoom}
disabled={isTransferring}
disabled={!!currentTransferFile}
isConnected={isConnected}
isWebSocketConnected={isWebSocketConnected}
/>
@@ -489,12 +523,12 @@ export const WebRTCFileTransfer: React.FC = () => {
onJoinRoom={joinRoom}
files={fileList}
onDownloadFile={handleDownloadRequest}
transferProgress={transferProgress}
isTransferring={isTransferring}
isConnected={isConnected}
isConnecting={!!pickupCode && !isConnected}
isConnecting={isConnecting}
isWebSocketConnected={isWebSocketConnected}
downloadedFiles={downloadedFiles}
error={error}
onReset={resetConnection}
/>
</div>
)}

View File

@@ -4,6 +4,7 @@ import React, { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Download, FileText, Image, Video, Music, Archive } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
interface FileInfo {
id: string;
@@ -34,31 +35,76 @@ interface WebRTCFileReceiveProps {
onJoinRoom: (code: string) => void;
files: FileInfo[];
onDownloadFile: (fileId: string) => void;
transferProgress: number;
isTransferring: boolean;
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected?: boolean;
downloadedFiles?: Map<string, File>;
error?: string | null;
onReset?: () => void;
}
export function WebRTCFileReceive({
onJoinRoom,
files,
onDownloadFile,
transferProgress,
isTransferring,
isConnected,
isConnecting,
isWebSocketConnected = false,
downloadedFiles
downloadedFiles,
error = null,
onReset
}: WebRTCFileReceiveProps) {
const [pickupCode, setPickupCode] = useState('');
const [isValidating, setIsValidating] = useState(false);
const { showToast } = useToast();
const handleSubmit = useCallback((e: React.FormEvent) => {
// 验证取件码是否存在
const validatePickupCode = async (code: string): Promise<boolean> => {
try {
setIsValidating(true);
console.log('开始验证取件码:', code);
const response = await fetch(`/api/room-info?code=${code}`);
const data = await response.json();
console.log('验证响应:', { status: response.status, data });
if (!response.ok || !data.success) {
const errorMessage = data.message || '取件码验证失败';
// 显示toast错误提示
showToast(errorMessage, 'error');
console.log('验证失败:', errorMessage);
return false;
}
console.log('取件码验证成功:', data.room);
return true;
} catch (error) {
console.error('验证取件码时发生错误:', error);
const errorMessage = '网络错误,请检查连接后重试';
// 显示toast错误提示
showToast(errorMessage, 'error');
return false;
} finally {
setIsValidating(false);
}
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (pickupCode.length === 6) {
onJoinRoom(pickupCode.toUpperCase());
const code = pickupCode.toUpperCase();
// 先验证取件码是否存在
const isValid = await validatePickupCode(code);
if (isValid) {
// 验证成功后再进行WebRTC连接
onJoinRoom(code);
}
}
}, [pickupCode, onJoinRoom]);
@@ -69,6 +115,19 @@ export function WebRTCFileReceive({
}
}, []);
// 当验证失败时重置输入状态
React.useEffect(() => {
if (error && !isConnecting && !isConnected && !isValidating) {
// 延迟重置,确保用户能看到错误信息
const timer = setTimeout(() => {
console.log('重置取件码输入');
setPickupCode('');
}, 3000); // 3秒后重置
return () => clearTimeout(timer);
}
}, [error, isConnecting, isConnected, isValidating]);
// 如果已经连接但没有文件,显示等待界面
if ((isConnected || isConnecting) && files.length === 0) {
return (
@@ -237,15 +296,13 @@ export function WebRTCFileReceive({
const isDownloading = file.status === 'downloading';
const isCompleted = file.status === 'completed';
const hasDownloadedFile = downloadedFiles?.has(file.id);
const currentProgress = isDownloading && isTransferring ? transferProgress : file.progress;
const currentProgress = file.progress;
console.log('文件状态:', {
fileName: file.name,
status: file.status,
progress: file.progress,
isDownloading,
isTransferring,
transferProgress,
currentProgress
});
@@ -262,24 +319,24 @@ export function WebRTCFileReceive({
{hasDownloadedFile && (
<p className="text-xs text-emerald-600 font-medium"> </p>
)}
{isDownloading && isTransferring && (
{isDownloading && (
<p className="text-xs text-blue-600 font-medium"> ...{currentProgress.toFixed(1)}%</p>
)}
</div>
</div>
<Button
onClick={() => onDownloadFile(file.id)}
disabled={!isConnected || (isDownloading && isTransferring)}
disabled={!isConnected || isDownloading}
className={`px-6 py-2 rounded-lg font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${
hasDownloadedFile
? 'bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 text-white'
: (isDownloading && isTransferring)
: isDownloading
? 'bg-slate-300 text-slate-500 cursor-not-allowed'
: 'bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white'
}`}
>
<Download className="w-4 h-4 mr-2" />
{(isDownloading && isTransferring) ? '传输中...' : hasDownloadedFile ? '保存文件' : '开始传输'}
{isDownloading ? '传输中...' : hasDownloadedFile ? '保存文件' : '开始传输'}
</Button>
</div>
@@ -387,7 +444,7 @@ export function WebRTCFileReceive({
placeholder="请输入取件码"
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-emerald-500 focus:ring-emerald-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
maxLength={6}
disabled={isConnecting}
disabled={isValidating || isConnecting}
/>
<div className="absolute inset-x-0 -bottom-4 sm:-bottom-6 flex justify-center space-x-1 sm:space-x-2">
{[...Array(6)].map((_, i) => (
@@ -411,9 +468,14 @@ export function WebRTCFileReceive({
<Button
type="submit"
className="w-full h-10 sm:h-12 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-base sm:text-lg font-medium rounded-xl shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
disabled={pickupCode.length !== 6 || isConnecting}
disabled={pickupCode.length !== 6 || isValidating || isConnecting}
>
{isConnecting ? (
{isValidating ? (
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>...</span>
</div>
) : isConnecting ? (
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>...</span>

View File

@@ -2,7 +2,7 @@
import React, { useState, useRef, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { toast } from '@/hooks/use-toast';
import { useToast } from '@/components/ui/toast-simple';
import { Upload, FileText, Image, Video, Music, Archive, X } from 'lucide-react';
interface FileInfo {
@@ -32,6 +32,7 @@ const formatFileSize = (bytes: number): string => {
interface WebRTCFileUploadProps {
selectedFiles: File[];
fileList?: FileInfo[]; // 添加文件列表信息(包含状态和进度)
onFilesChange: (files: File[]) => void;
onGenerateCode: () => void;
pickupCode?: string;
@@ -49,6 +50,7 @@ interface WebRTCFileUploadProps {
export function WebRTCFileUpload({
selectedFiles,
fileList = [],
onFilesChange,
onGenerateCode,
pickupCode,
@@ -254,31 +256,77 @@ export function WebRTCFileUpload({
</div>
<div className="space-y-3 mb-4 sm:mb-6">
{selectedFiles.map((file, index) => (
<div
key={`${file.name}-${file.size}-${index}`}
className="group flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl hover:shadow-md transition-all duration-200"
>
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-lg flex items-center justify-center flex-shrink-0">
{getFileIcon(file.type)}
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-slate-800 truncate text-sm sm:text-base">{file.name}</p>
<p className="text-xs sm:text-sm text-slate-500">{formatFileSize(file.size)}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(index)}
disabled={disabled}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all duration-200 flex-shrink-0 ml-2"
{selectedFiles.map((file, index) => {
// 查找对应的文件信息(包含状态和进度)
const fileInfo = fileList.find(f => f.name === file.name && f.size === file.size);
const isTransferringThisFile = fileInfo?.status === 'downloading';
const currentProgress = fileInfo?.progress || 0;
const fileStatus = fileInfo?.status || 'ready';
return (
<div
key={`${file.name}-${file.size}-${index}`}
className="group bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl hover:shadow-md transition-all duration-200"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
<div className="flex items-center justify-between p-3 sm:p-4">
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-lg flex items-center justify-center flex-shrink-0">
{getFileIcon(file.type)}
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-slate-800 truncate text-sm sm:text-base">{file.name}</p>
<div className="flex items-center space-x-2">
<p className="text-xs sm:text-sm text-slate-500">{formatFileSize(file.size)}</p>
{fileStatus === 'downloading' && (
<div className="flex items-center space-x-1">
<div className="w-1 h-1 bg-orange-500 rounded-full animate-pulse"></div>
<span className="text-xs text-orange-600 font-medium"></span>
</div>
)}
{fileStatus === 'completed' && (
<div className="flex items-center space-x-1">
<div className="w-1 h-1 bg-emerald-500 rounded-full"></div>
<span className="text-xs text-emerald-600 font-medium"></span>
</div>
)}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(index)}
disabled={disabled || fileStatus === 'downloading'}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all duration-200 flex-shrink-0 ml-2 disabled:opacity-50"
>
<X className="w-4 h-4" />
</Button>
</div>
{/* 传输进度条 */}
{(fileStatus === 'downloading' || fileStatus === 'completed') && currentProgress > 0 && (
<div className="px-3 sm:px-4 pb-3 sm:pb-4">
<div className="space-y-2">
<div className="flex justify-between text-xs text-slate-600">
<span>{fileStatus === 'downloading' ? '正在发送...' : '发送完成'}</span>
<span className="font-medium">{currentProgress.toFixed(1)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
fileStatus === 'completed'
? 'bg-gradient-to-r from-emerald-500 to-emerald-600'
: 'bg-gradient-to-r from-orange-500 to-orange-600'
}`}
style={{ width: `${currentProgress}%` }}
></div>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
{/* 操作按钮 */}

View File

@@ -141,6 +141,7 @@ export function useWebRTCTransfer() {
// 回调注册
onFileRequested: fileTransfer.onFileRequested,
onFileReceived: fileTransfer.onFileReceived,
onFileProgress: fileTransfer.onFileProgress,
onFileListReceived,
};
}

View File

@@ -7,6 +7,12 @@ interface FileTransferState {
error: string | null;
}
interface FileProgressInfo {
fileId: string;
fileName: string;
progress: number;
}
interface FileChunk {
fileId: string;
chunkIndex: number;
@@ -21,7 +27,7 @@ interface FileMetadata {
type: string;
}
const CHUNK_SIZE = 16 * 1024; // 16KB chunks
const CHUNK_SIZE = 256 * 1024; // 256KB chunks - 平衡传输效率和内存使用
export function useFileTransfer() {
const [state, setState] = useState<FileTransferState>({
@@ -39,9 +45,17 @@ export function useFileTransfer() {
totalChunks: number;
}>>(new Map());
// 存储当前正在接收的块信息
const expectedChunk = useRef<{
fileId: string;
chunkIndex: number;
totalChunks: number;
} | null>(null);
// 文件请求回调
const fileRequestCallbacks = useRef<Array<(fileId: string, fileName: string) => void>>([]);
const fileReceivedCallbacks = useRef<Array<(fileData: { id: string; file: File }) => void>>([]);
const fileProgressCallbacks = useRef<Array<(progressInfo: FileProgressInfo) => void>>([]);
const updateState = useCallback((updates: Partial<FileTransferState>) => {
setState(prev => ({ ...prev, ...updates }));
@@ -107,27 +121,54 @@ export function useFileTransfer() {
if (event.target?.result && dataChannel.readyState === 'open') {
const arrayBuffer = event.target.result as ArrayBuffer;
// 发送分块数据
const chunkMessage = JSON.stringify({
type: 'file-chunk',
payload: {
fileId,
chunkIndex: sentChunks,
totalChunks
}
});
dataChannel.send(chunkMessage);
dataChannel.send(arrayBuffer);
// 检查缓冲区状态,避免过度缓冲
if (dataChannel.bufferedAmount > 1024 * 1024) { // 1MB 缓冲区限制
console.log('数据通道缓冲区满,等待清空...');
// 等待缓冲区清空后再发送
const waitForBuffer = () => {
if (dataChannel.bufferedAmount < 256 * 1024) { // 等到缓冲区低于 256KB
sendChunkData();
} else {
setTimeout(waitForBuffer, 10);
}
};
waitForBuffer();
} else {
sendChunkData();
}
sentChunks++;
const progress = (sentChunks / totalChunks) * 100;
updateState({ transferProgress: progress });
function sendChunkData() {
// 发送分块数据
const chunkMessage = JSON.stringify({
type: 'file-chunk',
payload: {
fileId,
chunkIndex: sentChunks,
totalChunks
}
});
dataChannel.send(chunkMessage);
dataChannel.send(arrayBuffer);
console.log(`发送进度: ${progress.toFixed(1)}%, 块: ${sentChunks}/${totalChunks}`);
sentChunks++;
const progress = (sentChunks / totalChunks) * 100;
updateState({ transferProgress: progress });
// 短暂延迟,避免阻塞
setTimeout(sendNextChunk, 10);
// 通知文件级别的进度
fileProgressCallbacks.current.forEach(callback => {
callback({
fileId,
fileName: file.name,
progress
});
});
console.log(`发送进度: ${progress.toFixed(1)}%, 块: ${sentChunks}/${totalChunks}, 文件: ${file.name}, 缓冲区: ${dataChannel.bufferedAmount} bytes`);
// 立即发送下一个块,不等待
sendNextChunk();
}
}
};
@@ -179,7 +220,15 @@ export function useFileTransfer() {
case 'file-chunk':
const chunkInfo = message.payload;
console.log(`接收文件块: ${chunkInfo.chunkIndex + 1}/${chunkInfo.totalChunks}`);
const { fileId: chunkFileId, chunkIndex, totalChunks } = chunkInfo;
console.log(`接收文件块信息: ${chunkIndex + 1}/${totalChunks}, 文件ID: ${chunkFileId}`);
// 设置期望的下一个块
expectedChunk.current = {
fileId: chunkFileId,
chunkIndex,
totalChunks
};
break;
case 'file-end':
@@ -229,18 +278,37 @@ export function useFileTransfer() {
const arrayBuffer = event.data;
console.log('收到文件块数据:', arrayBuffer.byteLength, 'bytes');
// 找到最近开始接收的文件(简化逻辑)
for (const [fileId, fileInfo] of receivingFiles.current.entries()) {
if (fileInfo.receivedChunks < fileInfo.totalChunks) {
fileInfo.chunks.push(arrayBuffer);
fileInfo.receivedChunks++;
// 检查是否有期望的块信息
if (expectedChunk.current) {
const { fileId, chunkIndex } = expectedChunk.current;
const fileInfo = receivingFiles.current.get(fileId);
if (fileInfo) {
// 确保chunks数组足够大
if (!fileInfo.chunks[chunkIndex]) {
fileInfo.chunks[chunkIndex] = arrayBuffer;
fileInfo.receivedChunks++;
const progress = (fileInfo.receivedChunks / fileInfo.totalChunks) * 100;
updateState({ transferProgress: progress });
const progress = (fileInfo.receivedChunks / fileInfo.totalChunks) * 100;
updateState({ transferProgress: progress });
console.log(`文件接收进度: ${progress.toFixed(1)}%, 块: ${fileInfo.receivedChunks}/${fileInfo.totalChunks}`);
break;
// 通知文件级别的进度
fileProgressCallbacks.current.forEach(callback => {
callback({
fileId,
fileName: fileInfo.metadata.name,
progress
});
});
console.log(`文件接收进度: ${progress.toFixed(1)}%, 块: ${fileInfo.receivedChunks}/${fileInfo.totalChunks}, 文件: ${fileInfo.metadata.name}`);
}
// 清除期望的块信息
expectedChunk.current = null;
}
} else {
console.warn('收到块数据但没有对应的块信息');
}
}
}, [updateState]);
@@ -288,6 +356,19 @@ export function useFileTransfer() {
};
}, []);
// 注册文件进度回调
const onFileProgress = useCallback((callback: (progressInfo: FileProgressInfo) => void) => {
fileProgressCallbacks.current.push(callback);
// 返回清理函数
return () => {
const index = fileProgressCallbacks.current.indexOf(callback);
if (index > -1) {
fileProgressCallbacks.current.splice(index, 1);
}
};
}, []);
return {
...state,
sendFile,
@@ -295,5 +376,6 @@ export function useFileTransfer() {
handleMessage,
onFileRequested,
onFileReceived,
onFileProgress,
};
}

View File

@@ -1,3 +1,4 @@
import { url } from 'inspector';
import { useState, useRef, useCallback } from 'react';
interface WebRTCConnectionState {
@@ -22,6 +23,7 @@ export function useWebRTCConnection() {
const wsRef = useRef<WebSocket | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const dcRef = useRef<RTCDataChannel | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const STUN_SERVERS = [
{ urls: 'stun:stun.l.google.com:19302' },
@@ -29,29 +31,78 @@ export function useWebRTCConnection() {
{ urls: 'stun:stun2.l.google.com:19302' },
];
// 连接超时时间30秒
const CONNECTION_TIMEOUT = 30000;
const updateState = useCallback((updates: Partial<WebRTCConnectionState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 清理超时定时器
const clearConnectionTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
// 处理连接超时
const handleConnectionTimeout = useCallback(() => {
console.warn('WebRTC连接超时');
updateState({
error: '连接超时,请检查取件码是否正确或稍后重试',
isConnecting: false
});
// 清理连接
if (wsRef.current) {
wsRef.current.close();
}
if (pcRef.current) {
pcRef.current.close();
}
}, [updateState]);
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
console.log('=== 开始WebRTC连接 ===');
console.log('房间代码:', roomCode, '角色:', role);
// 清理之前的超时定时器
clearConnectionTimeout();
updateState({ isConnecting: true, error: null });
// 设置连接超时
timeoutRef.current = setTimeout(() => {
handleConnectionTimeout();
}, CONNECTION_TIMEOUT);
try {
// 创建PeerConnection
const pc = new RTCPeerConnection({ iceServers: STUN_SERVERS });
pcRef.current = pc;
// 连接WebSocket信令服务器
const ws = new WebSocket(`ws://localhost:8080/ws/webrtc?room=${roomCode}&role=${role}`);
const ws = new WebSocket(`ws://localhost:8080/ws/webrtc?code=${roomCode}&role=${role}`);
wsRef.current = ws;
// WebSocket事件处理
ws.onopen = () => {
ws.onopen = async () => {
console.log('WebSocket连接已建立');
updateState({ isWebSocketConnected: true });
// 如果是发送方在WebSocket连接建立后创建offer
if (role === 'sender') {
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'offer', payload: offer }));
console.log('已发送offer');
} catch (error) {
console.error('创建offer失败:', error);
updateState({ error: '创建连接失败', isConnecting: false });
}
}
};
ws.onmessage = async (event) => {
@@ -61,19 +112,26 @@ export function useWebRTCConnection() {
switch (message.type) {
case 'offer':
await pc.setRemoteDescription(new RTCSessionDescription(message.offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
ws.send(JSON.stringify({ type: 'answer', answer }));
if (message.payload) {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
console.log('已发送answer');
}
}
break;
case 'answer':
await pc.setRemoteDescription(new RTCSessionDescription(message.answer));
if (message.payload) {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
}
break;
case 'ice-candidate':
if (message.candidate) {
await pc.addIceCandidate(new RTCIceCandidate(message.candidate));
if (message.payload) {
await pc.addIceCandidate(new RTCIceCandidate(message.payload));
}
break;
@@ -104,7 +162,7 @@ export function useWebRTCConnection() {
console.log('发送ICE候选:', event.candidate);
ws.send(JSON.stringify({
type: 'ice-candidate',
candidate: event.candidate
payload: event.candidate
}));
}
};
@@ -113,12 +171,19 @@ export function useWebRTCConnection() {
pc.onconnectionstatechange = () => {
console.log('连接状态:', pc.connectionState);
const isConnected = pc.connectionState === 'connected';
if (isConnected) {
// 连接成功,清理超时定时器
clearConnectionTimeout();
}
updateState({
isConnected,
isConnecting: !isConnected && pc.connectionState !== 'failed'
});
if (pc.connectionState === 'failed') {
clearConnectionTimeout();
updateState({ error: '连接失败', isConnecting: false });
}
};
@@ -126,19 +191,27 @@ export function useWebRTCConnection() {
// 如果是发送方,创建数据通道
if (role === 'sender') {
const dataChannel = pc.createDataChannel('fileTransfer', {
ordered: true
ordered: true,
maxPacketLifeTime: undefined,
maxRetransmits: undefined
});
dcRef.current = dataChannel;
// 设置缓冲区管理
dataChannel.bufferedAmountLowThreshold = 256 * 1024; // 256KB
dataChannel.onopen = () => {
console.log('数据通道已打开 (发送方)');
console.log('数据通道配置:', {
id: dataChannel.id,
label: dataChannel.label,
maxPacketLifeTime: dataChannel.maxPacketLifeTime,
maxRetransmits: dataChannel.maxRetransmits,
ordered: dataChannel.ordered,
bufferedAmountLowThreshold: dataChannel.bufferedAmountLowThreshold
});
updateState({ localDataChannel: dataChannel });
};
// 创建offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'offer', offer }));
} else {
// 接收方等待数据通道
pc.ondatachannel = (event) => {
@@ -155,16 +228,20 @@ export function useWebRTCConnection() {
} catch (error) {
console.error('连接失败:', error);
clearConnectionTimeout();
updateState({
error: error instanceof Error ? error.message : '连接失败',
isConnecting: false
});
}
}, [updateState]);
}, [updateState, clearConnectionTimeout, handleConnectionTimeout]);
const disconnect = useCallback(() => {
console.log('断开WebRTC连接');
// 清理超时定时器
clearConnectionTimeout();
if (dcRef.current) {
dcRef.current.close();
dcRef.current = null;
@@ -188,7 +265,7 @@ export function useWebRTCConnection() {
localDataChannel: null,
remoteDataChannel: null,
});
}, []);
}, [clearConnectionTimeout]);
const getDataChannel = useCallback(() => {
return dcRef.current;

View File

@@ -58,6 +58,10 @@ func main() {
r.Post("/api/create-text-room", h.CreateTextRoomHandler)
r.Get("/api/get-text-content", h.GetTextContentHandler)
// 文件传输API路由
r.Post("/api/create-room", h.CreateRoomHandler)
r.Get("/api/room-info", h.RoomInfoHandler)
// 启动服务器
srv := &http.Server{
Addr: ":8080",

View File

@@ -2,10 +2,13 @@ package handlers
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"path/filepath"
"time"
"chuan/internal/models"
"chuan/internal/services"
)
@@ -139,3 +142,140 @@ func (h *Handler) GetTextContentHandler(w http.ResponseWriter, r *http.Request)
func (h *Handler) HandleWebRTCWebSocket(w http.ResponseWriter, r *http.Request) {
h.webrtcService.HandleWebSocket(w, r)
}
// CreateRoomHandler 创建文件传输房间API
func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
// 设置响应为JSON格式
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "方法不允许",
})
return
}
var req struct {
Files []struct {
Name string `json:"name"`
Size int64 `json:"size"`
Type string `json:"type"`
LastModified int64 `json:"lastModified"`
} `json:"files"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "解析请求失败",
"error": err.Error(),
})
return
}
if len(req.Files) == 0 {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "至少需要选择一个文件",
})
return
}
// 验证文件信息
for _, file := range req.Files {
if file.Name == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "文件名不能为空",
})
return
}
if file.Size <= 0 {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "文件大小必须大于0",
})
return
}
}
// 转换文件信息
var fileInfos []models.FileTransferInfo
for i, file := range req.Files {
fileInfos = append(fileInfos, models.FileTransferInfo{
ID: fmt.Sprintf("file_%d_%d", time.Now().Unix(), i),
Name: file.Name,
Size: file.Size,
Type: file.Type,
LastModified: file.LastModified,
})
}
// 创建文件传输房间
code := h.p2pService.CreateRoom(fileInfos)
response := map[string]interface{}{
"success": true,
"code": code,
"message": "文件传输房间创建成功",
"files": fileInfos,
}
json.NewEncoder(w).Encode(response)
}
// RoomInfoHandler 获取房间信息API
func (h *Handler) RoomInfoHandler(w http.ResponseWriter, r *http.Request) {
// 设置响应为JSON格式
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "方法不允许",
})
return
}
code := r.URL.Query().Get("code")
if code == "" || len(code) != 6 {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "请提供正确的6位房间码",
})
return
}
// 获取房间信息
room, exists := h.p2pService.GetRoomByCode(code)
if !exists {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "房间不存在或已过期",
})
return
}
// 构建响应
response := map[string]interface{}{
"success": true,
"message": "房间信息获取成功",
"room": map[string]interface{}{
"code": room.Code,
"files": room.Files,
"file_count": len(room.Files),
"is_text_room": room.IsTextRoom,
"created_at": room.CreatedAt,
},
}
json.NewEncoder(w).Encode(response)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
(()=>{"use strict";var e={},t={};function r(o){var n=t[o];if(void 0!==n)return n.exports;var a=t[o]={exports:{}},i=!0;try{e[o](a,a.exports,r),i=!1}finally{i&&delete t[o]}return a.exports}r.m=e,(()=>{var e=[];r.O=(t,o,n,a)=>{if(o){a=a||0;for(var i=e.length;i>0&&e[i-1][2]>a;i--)e[i]=e[i-1];e[i]=[o,n,a];return}for(var u=1/0,i=0;i<e.length;i++){for(var[o,n,a]=e[i],l=!0,c=0;c<o.length;c++)(!1&a||u>=a)&&Object.keys(r.O).every(e=>r.O[e](o[c]))?o.splice(c--,1):(l=!1,a<u&&(u=a));if(l){e.splice(i--,1);var d=n();void 0!==d&&(t=d)}}return t}})(),r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},(()=>{var e,t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__;r.t=function(o,n){if(1&n&&(o=this(o)),8&n||"object"==typeof o&&o&&(4&n&&o.__esModule||16&n&&"function"==typeof o.then))return o;var a=Object.create(null);r.r(a);var i={};e=e||[null,t({}),t([]),t(t)];for(var u=2&n&&o;"object"==typeof u&&!~e.indexOf(u);u=t(u))Object.getOwnPropertyNames(u).forEach(e=>i[e]=()=>o[e]);return i.default=()=>o,r.d(a,i),a}})(),r.d=(e,t)=>{for(var o in t)r.o(t,o)&&!r.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:t[o]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>{},r.miniCssF=e=>{},r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={},t="_N_E:";r.l=(o,n,a,i)=>{if(e[o])return void e[o].push(n);if(void 0!==a)for(var u,l,c=document.getElementsByTagName("script"),d=0;d<c.length;d++){var f=c[d];if(f.getAttribute("src")==o||f.getAttribute("data-webpack")==t+a){u=f;break}}u||(l=!0,(u=document.createElement("script")).charset="utf-8",u.timeout=120,r.nc&&u.setAttribute("nonce",r.nc),u.setAttribute("data-webpack",t+a),u.src=r.tu(o)),e[o]=[n];var s=(t,r)=>{u.onerror=u.onload=null,clearTimeout(p);var n=e[o];if(delete e[o],u.parentNode&&u.parentNode.removeChild(u),n&&n.forEach(e=>e(r)),t)return t(r)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:u}),12e4);u.onerror=s.bind(null,u.onerror),u.onload=s.bind(null,u.onload),l&&document.head.appendChild(u)}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:e=>e},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("nextjs#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="/_next/",(()=>{var e={68:0,896:0};r.f.j=(t,o)=>{var n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else if(/^(68|896)$/.test(t))e[t]=0;else{var a=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=a);var i=r.p+r.u(t),u=Error();r.l(i,o=>{if(r.o(e,t)&&(0!==(n=e[t])&&(e[t]=void 0),n)){var a=o&&("load"===o.type?"missing":o.type),i=o&&o.target&&o.target.src;u.message="Loading chunk "+t+" failed.\n("+a+": "+i+")",u.name="ChunkLoadError",u.type=a,u.request=i,n[1](u)}},"chunk-"+t,t)}},r.O.j=t=>0===e[t];var t=(t,o)=>{var n,a,[i,u,l]=o,c=0;if(i.some(t=>0!==e[t])){for(n in u)r.o(u,n)&&(r.m[n]=u[n]);if(l)var d=l(r)}for(t&&t(o);c<i.length;c++)a=i[c],r.o(e,a)&&e[a]&&e[a][0](),e[a]=0;return r.O(d)},o=self.webpackChunk_N_E=self.webpackChunk_N_E||[];o.forEach(t.bind(null,0)),o.push=t.bind(null,o.push.bind(o))})(),r.nc=void 0})();
(()=>{"use strict";var e={},t={};function r(o){var n=t[o];if(void 0!==n)return n.exports;var a=t[o]={exports:{}},i=!0;try{e[o](a,a.exports,r),i=!1}finally{i&&delete t[o]}return a.exports}r.m=e,(()=>{var e=[];r.O=(t,o,n,a)=>{if(o){a=a||0;for(var i=e.length;i>0&&e[i-1][2]>a;i--)e[i]=e[i-1];e[i]=[o,n,a];return}for(var u=1/0,i=0;i<e.length;i++){for(var[o,n,a]=e[i],l=!0,c=0;c<o.length;c++)(!1&a||u>=a)&&Object.keys(r.O).every(e=>r.O[e](o[c]))?o.splice(c--,1):(l=!1,a<u&&(u=a));if(l){e.splice(i--,1);var d=n();void 0!==d&&(t=d)}}return t}})(),r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},(()=>{var e,t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__;r.t=function(o,n){if(1&n&&(o=this(o)),8&n||"object"==typeof o&&o&&(4&n&&o.__esModule||16&n&&"function"==typeof o.then))return o;var a=Object.create(null);r.r(a);var i={};e=e||[null,t({}),t([]),t(t)];for(var u=2&n&&o;"object"==typeof u&&!~e.indexOf(u);u=t(u))Object.getOwnPropertyNames(u).forEach(e=>i[e]=()=>o[e]);return i.default=()=>o,r.d(a,i),a}})(),r.d=(e,t)=>{for(var o in t)r.o(t,o)&&!r.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:t[o]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>{},r.miniCssF=e=>{},r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={},t="_N_E:";r.l=(o,n,a,i)=>{if(e[o])return void e[o].push(n);if(void 0!==a)for(var u,l,c=document.getElementsByTagName("script"),d=0;d<c.length;d++){var f=c[d];if(f.getAttribute("src")==o||f.getAttribute("data-webpack")==t+a){u=f;break}}u||(l=!0,(u=document.createElement("script")).charset="utf-8",u.timeout=120,r.nc&&u.setAttribute("nonce",r.nc),u.setAttribute("data-webpack",t+a),u.src=r.tu(o)),e[o]=[n];var s=(t,r)=>{u.onerror=u.onload=null,clearTimeout(p);var n=e[o];if(delete e[o],u.parentNode&&u.parentNode.removeChild(u),n&&n.forEach(e=>e(r)),t)return t(r)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:u}),12e4);u.onerror=s.bind(null,u.onerror),u.onload=s.bind(null,u.onload),l&&document.head.appendChild(u)}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:e=>e},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("nextjs#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="/_next/",(()=>{var e={68:0,896:0};r.f.j=(t,o)=>{var n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else if(/^(68|896)$/.test(t))e[t]=0;else{var a=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=a);var i=r.p+r.u(t),u=Error();r.l(i,o=>{if(r.o(e,t)&&(0!==(n=e[t])&&(e[t]=void 0),n)){var a=o&&("load"===o.type?"missing":o.type),i=o&&o.target&&o.target.src;u.message="Loading chunk "+t+" failed.\n("+a+": "+i+")",u.name="ChunkLoadError",u.type=a,u.request=i,n[1](u)}},"chunk-"+t,t)}},r.O.j=t=>0===e[t];var t=(t,o)=>{var n,a,[i,u,l]=o,c=0;if(i.some(t=>0!==e[t])){for(n in u)r.o(u,n)&&(r.m[n]=u[n]);if(l)var d=l(r)}for(t&&t(o);c<i.length;c++)a=i[c],r.o(e,a)&&e[a]&&e[a][0](),e[a]=0;return r.O(d)},o=self.webpackChunk_N_E=self.webpackChunk_N_E||[];o.forEach(t.bind(null,0)),o.push=t.bind(null,o.push.bind(o))})()})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
2:I[6801,["177","static/chunks/app/layout-d0f2a5cbcfca20f5.js"],"ToastProvider"]
3:I[7555,[],""]
4:I[1295,[],""]
5:I[5055,["984","static/chunks/984-39bc34483f07a61c.js","974","static/chunks/app/page-f438e315cafde810.js"],"default"]
5:I[1287,["423","static/chunks/423-3db9dd818e8fd852.js","974","static/chunks/app/page-6f704b6eb3095813.js"],"default"]
6:I[9665,[],"OutletBoundary"]
8:I[4911,[],"AsyncMetadataOutlet"]
a:I[9665,[],"ViewportBoundary"]
@@ -11,8 +11,8 @@ d:"$Sreact.suspense"
f:I[8393,[],""]
:HL["/_next/static/media/569ce4b8f30dc480-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
:HL["/_next/static/media/93f479601ee12b01-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
:HL["/_next/static/css/7908b4c934e87974.css","style"]
0:{"P":null,"b":"dIaE9ChLg6gKLX3iG0ErS","p":"","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/7908b4c934e87974.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"zh-CN","children":["$","body",null,{"className":"__variable_5cfdac __variable_9a8899 antialiased","children":["$","$L2",null,{"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L5",null,{}],null,["$","$L6",null,{"children":["$L7",["$","$L8",null,{"promise":"$@9"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$La",null,{"children":"$Lb"}],["$","meta",null,{"name":"next-size-adjust","content":""}]],["$","$Lc",null,{"children":["$","div",null,{"hidden":true,"children":["$","$d",null,{"fallback":null,"children":"$Le"}]}]}]]}],false]],"m":"$undefined","G":["$f",[]],"s":false,"S":true}
:HL["/_next/static/css/f6f47fde0030ec04.css","style"]
0:{"P":null,"b":"8bbWAyBmnmNj0Jk5ryXl4","p":"","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/f6f47fde0030ec04.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"zh-CN","children":["$","body",null,{"className":"__variable_5cfdac __variable_9a8899 antialiased","children":["$","$L2",null,{"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L5",null,{}],null,["$","$L6",null,{"children":["$L7",["$","$L8",null,{"promise":"$@9"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$La",null,{"children":"$Lb"}],["$","meta",null,{"name":"next-size-adjust","content":""}]],["$","$Lc",null,{"children":["$","div",null,{"hidden":true,"children":["$","$d",null,{"fallback":null,"children":"$Le"}]}]}]]}],false]],"m":"$undefined","G":["$f",[]],"s":false,"S":true}
b:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
7:null
10:I[8175,[],"IconMark"]