UI-React refactor

This commit is contained in:
MatrixSeven
2025-07-30 10:26:11 +08:00
parent adacb453ef
commit 9e59806192
42 changed files with 7151 additions and 397 deletions

6
.gitignore vendored
View File

@@ -102,4 +102,8 @@ production/
backup/
*.log
/bin/*
/bin/*
./chuan-next/node_modules/
# Next.js相关
.next/
./chuan/.next

41
chuan-next/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
chuan-next/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

40
chuan-next/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "chuan-next",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.533.0",
"next": "15.4.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.4.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,574 @@
"use client";
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import FileUpload from '@/components/FileUpload';
import { FileReceive } from '@/components/FileReceive';
import { useWebSocket } from '@/hooks/useWebSocket';
import { FileInfo, TransferProgress, WebSocketMessage, RoomStatus } from '@/types';
import { Upload, Download } from 'lucide-react';
interface FileTransferData {
fileId: string;
chunks: Array<{ offset: number; data: Uint8Array }>;
totalSize: number;
receivedSize: number;
fileName: string;
mimeType: string;
startTime: number;
}
export default function HomePage() {
const searchParams = useSearchParams();
const { websocket, isConnected, connect, disconnect, sendMessage } = useWebSocket();
// 发送方状态
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [pickupCode, setPickupCode] = useState<string>('');
const [pickupLink, setPickupLink] = useState<string>('');
const [currentRole, setCurrentRole] = useState<'sender' | 'receiver'>('sender');
// 接收方状态
const [receiverFiles, setReceiverFiles] = useState<FileInfo[]>([]);
const [transferProgresses, setTransferProgresses] = useState<TransferProgress[]>([]);
const [isConnecting, setIsConnecting] = useState(false);
// 房间状态
const [roomStatus, setRoomStatus] = useState<RoomStatus | null>(null);
// 文件传输状态
const [fileTransfers, setFileTransfers] = useState<Map<string, FileTransferData>>(new Map());
// 显示通知
const showNotification = useCallback((message: string, type: 'success' | 'error' | 'info' = 'success') => {
console.log(`[${type.toUpperCase()}] ${message}`);
}, []);
// 初始化文件传输
const initFileTransfer = useCallback((fileInfo: any) => {
console.log('初始化文件传输:', fileInfo);
const transferKey = fileInfo.file_id;
setFileTransfers(prev => {
const newMap = new Map(prev);
newMap.set(transferKey, {
fileId: fileInfo.file_id,
chunks: [],
totalSize: fileInfo.size,
receivedSize: 0,
fileName: fileInfo.name,
mimeType: fileInfo.mime_type,
startTime: Date.now()
});
console.log('添加文件传输记录:', transferKey);
return newMap;
});
setTransferProgresses(prev => {
const updated = prev.map(p => p.fileId === fileInfo.file_id
? { ...p, status: 'downloading' as const, totalSize: fileInfo.size }
: p
);
console.log('更新传输进度为下载中:', updated);
return updated;
});
}, []);
// 组装并下载文件
const assembleAndDownloadFile = useCallback((transferKey: string, transfer: FileTransferData) => {
// 按偏移量排序数据块
transfer.chunks.sort((a, b) => a.offset - b.offset);
// 合并所有数据块
const totalSize = transfer.chunks.reduce((sum, chunk) => sum + chunk.data.length, 0);
const mergedData = new Uint8Array(totalSize);
let currentOffset = 0;
transfer.chunks.forEach((chunk) => {
mergedData.set(chunk.data, currentOffset);
currentOffset += chunk.data.length;
});
// 创建Blob并触发下载
const blob = new Blob([mergedData], { type: transfer.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = transfer.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// 清理状态
setFileTransfers(prev => {
const newMap = new Map(prev);
newMap.delete(transferKey);
return newMap;
});
setTransferProgresses(prev =>
prev.filter(p => p.fileId !== transferKey)
);
const transferTime = (Date.now() - transfer.startTime) / 1000;
const speed = (transfer.totalSize / transferTime / 1024 / 1024).toFixed(2);
showNotification(`文件 "${transfer.fileName}" 下载完成!传输速度: ${speed} MB/s`);
}, [showNotification]);
// 接收文件数据块
const receiveFileChunk = useCallback((chunkData: any) => {
console.log('接收文件数据块:', chunkData);
const transferKey = chunkData.file_id;
setFileTransfers(prev => {
const newMap = new Map(prev);
const transfer = newMap.get(transferKey);
if (transfer) {
const chunkArray = new Uint8Array(chunkData.data);
transfer.chunks.push({
offset: chunkData.offset,
data: chunkArray
});
transfer.receivedSize += chunkArray.length;
const progress = (transfer.receivedSize / transfer.totalSize) * 100;
console.log(`文件 ${transferKey} 进度: ${progress.toFixed(2)}%`);
// 更新进度
setTransferProgresses(prev => {
const updated = prev.map(p => p.fileId === transferKey
? {
...p,
progress,
receivedSize: transfer.receivedSize,
totalSize: transfer.totalSize
}
: p
);
console.log('更新进度状态:', updated);
return updated;
});
// 检查是否完成
if (chunkData.is_last || transfer.receivedSize >= transfer.totalSize) {
console.log('文件接收完成,开始组装下载');
assembleAndDownloadFile(transferKey, transfer);
}
} else {
console.warn('未找到对应的文件传输:', transferKey);
}
return newMap;
});
}, [assembleAndDownloadFile]);
// 完成文件下载
const completeFileDownload = useCallback((fileId: string) => {
console.log('文件传输完成:', fileId);
}, []);
// 处理文件请求(发送方)
const handleFileRequest = useCallback(async (payload: any) => {
const fileId = payload.file_id;
const requestId = payload.request_id;
const fileIndex = parseInt(fileId.replace('file_', ''));
const file = selectedFiles[fileIndex];
if (!file) {
console.error('未找到请求的文件:', fileId);
return;
}
console.log('开始发送文件:', file.name);
showNotification(`开始发送文件: ${file.name}`);
// 发送文件信息
sendMessage({
type: 'file-info',
payload: {
file_id: requestId,
name: file.name,
size: file.size,
mime_type: file.type,
last_modified: file.lastModified
}
});
// 分块发送文件
const chunkSize = 65536;
let offset = 0;
const sendChunk = () => {
if (offset >= file.size) {
sendMessage({
type: 'file-complete',
payload: { file_id: requestId }
});
showNotification(`文件发送完成: ${file.name}`);
return;
}
const slice = file.slice(offset, offset + chunkSize);
const reader = new FileReader();
reader.onload = (e) => {
const chunk = e.target?.result as ArrayBuffer;
sendMessage({
type: 'file-chunk',
payload: {
file_id: requestId,
offset: offset,
data: Array.from(new Uint8Array(chunk)),
is_last: offset + chunk.byteLength >= file.size
}
});
offset += chunk.byteLength;
setTimeout(sendChunk, 10);
};
reader.readAsArrayBuffer(slice);
};
sendChunk();
}, [selectedFiles, sendMessage, showNotification]);
// WebSocket消息处理
useEffect(() => {
const handleWebSocketMessage = (event: CustomEvent<WebSocketMessage>) => {
const message = event.detail;
console.log('HomePage收到WebSocket消息:', message.type, message);
switch (message.type) {
case 'file-list':
console.log('处理file-list消息');
if (currentRole === 'receiver') {
setReceiverFiles((message.payload.files as FileInfo[]) || []);
setIsConnecting(false);
}
break;
case 'file-list-updated':
console.log('处理file-list-updated消息');
if (currentRole === 'receiver') {
setReceiverFiles((message.payload.files as FileInfo[]) || []);
showNotification('文件列表已更新,发现新文件!');
}
break;
case 'room-status':
console.log('处理room-status消息');
setRoomStatus(message.payload as unknown as RoomStatus);
break;
case 'file-info':
console.log('处理file-info消息');
if (currentRole === 'receiver') {
initFileTransfer(message.payload);
}
break;
case 'file-chunk':
console.log('处理file-chunk消息');
if (currentRole === 'receiver') {
receiveFileChunk(message.payload);
}
break;
case 'file-complete':
console.log('处理file-complete消息');
if (currentRole === 'receiver') {
completeFileDownload(message.payload.file_id as string);
}
break;
case 'file-request':
console.log('处理file-request消息');
if (currentRole === 'sender') {
handleFileRequest(message.payload);
}
break;
}
};
window.addEventListener('websocket-message', handleWebSocketMessage as EventListener);
return () => {
window.removeEventListener('websocket-message', handleWebSocketMessage as EventListener);
};
}, [currentRole, showNotification, initFileTransfer, receiveFileChunk, completeFileDownload, handleFileRequest]);
// 生成取件码
const handleGenerateCode = useCallback(async () => {
if (selectedFiles.length === 0) return;
const fileInfos = selectedFiles.map((file, index) => ({
id: 'file_' + index,
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
}));
try {
const response = await fetch('/api/create-room', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files: fileInfos })
});
const data = await response.json();
if (data.success) {
const code = data.code;
setPickupCode(code);
setCurrentRole('sender');
const baseUrl = window.location.origin;
const link = `${baseUrl}/?code=${code}`;
setPickupLink(link);
connect(code, 'sender');
showNotification('取件码生成成功!');
} else {
showNotification('生成取件码失败: ' + data.message, 'error');
}
} catch (error) {
console.error('生成取件码失败:', error);
showNotification('生成取件码失败,请重试', 'error');
}
}, [selectedFiles, connect, showNotification]);
// 加入房间
const handleJoinRoom = useCallback(async (code: string) => {
setIsConnecting(true);
try {
const response = await fetch(`/api/room-info?code=${code}`);
const data = await response.json();
if (data.success) {
setPickupCode(code);
setCurrentRole('receiver');
setReceiverFiles(data.files || []);
connect(code, 'receiver');
showNotification('连接成功!');
} else {
showNotification(data.message || '取件码无效或已过期', 'error');
setIsConnecting(false);
}
} catch (error) {
console.error('连接失败:', error);
showNotification('连接失败,请检查网络连接', 'error');
setIsConnecting(false);
}
}, [connect, showNotification]);
// 处理URL参数中的取件码
useEffect(() => {
const code = searchParams.get('code');
if (code && code.length === 6) {
setCurrentRole('receiver');
handleJoinRoom(code.toUpperCase());
}
}, [searchParams, handleJoinRoom]);
// 下载文件
const handleDownloadFile = useCallback((fileId: string) => {
console.log('开始下载文件:', fileId);
if (!websocket || websocket.readyState !== WebSocket.OPEN) {
showNotification('连接未建立,请重试', 'error');
return;
}
const requestId = 'req_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
console.log('生成请求ID:', requestId);
sendMessage({
type: 'file-request',
payload: {
file_id: fileId,
request_id: requestId
}
});
// 更新传输状态
const newProgress = {
fileId: requestId, // 传输的唯一标识
originalFileId: fileId, // 原始文件ID用于UI匹配
fileName: receiverFiles.find(f => f.id === fileId)?.name || fileId,
progress: 0,
receivedSize: 0,
totalSize: 0,
status: 'pending' as const
};
console.log('添加传输进度:', newProgress);
setTransferProgresses(prev => [
...prev.filter(p => p.originalFileId !== fileId), // 移除该文件的旧进度记录
newProgress
]);
}, [websocket, sendMessage, receiverFiles, showNotification]);
// 通过WebSocket更新文件列表
const updateFileList = useCallback((files: File[]) => {
if (!pickupCode || !websocket || websocket.readyState !== WebSocket.OPEN) {
console.log('无法更新文件列表: pickupCode=', pickupCode, 'websocket状态=', websocket?.readyState);
return;
}
const fileInfos = files.map((file, index) => ({
id: 'file_' + index,
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
}));
console.log('通过WebSocket发送文件列表更新:', fileInfos);
sendMessage({
type: 'update-file-list',
payload: {
files: fileInfos
}
});
showNotification('文件列表已更新');
}, [pickupCode, websocket, sendMessage, showNotification]);
// 处理文件删除后的同步
const handleRemoveFile = useCallback((updatedFiles: File[]) => {
if (pickupCode) {
updateFileList(updatedFiles);
}
}, [pickupCode, updateFileList]);
// 重置状态
const handleReset = useCallback(() => {
setSelectedFiles([]);
setPickupCode('');
setPickupLink('');
setReceiverFiles([]);
setTransferProgresses([]);
setRoomStatus(null);
setFileTransfers(new Map());
disconnect();
}, [disconnect]);
// 复制到剪贴板
const copyToClipboard = useCallback(async (text: string, message: string) => {
try {
await navigator.clipboard.writeText(text);
showNotification(message);
} catch (error) {
showNotification('复制失败,请手动复制', 'error');
}
}, [showNotification]);
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto py-8 px-4">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold mb-2"></h1>
<p className="text-muted-foreground">
P2P文件传输服务
</p>
</div>
<Tabs defaultValue="send" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="send" className="flex items-center space-x-2">
<Upload className="w-4 h-4" />
<span></span>
</TabsTrigger>
<TabsTrigger value="receive" className="flex items-center space-x-2">
<Download className="w-4 h-4" />
<span></span>
</TabsTrigger>
</TabsList>
<TabsContent value="send" className="mt-6">
<FileUpload
selectedFiles={selectedFiles}
onFilesChange={setSelectedFiles}
onGenerateCode={handleGenerateCode}
pickupCode={pickupCode}
pickupLink={pickupLink}
onCopyCode={() => copyToClipboard(pickupCode, '取件码已复制到剪贴板!')}
onCopyLink={() => copyToClipboard(pickupLink, '取件链接已复制到剪贴板!')}
onAddMoreFiles={() => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = async (e) => {
const files = Array.from((e.target as HTMLInputElement).files || []);
const newFiles = [...selectedFiles, ...files];
setSelectedFiles(newFiles);
// 如果已经生成了取件码,更新后端文件列表
if (pickupCode && files.length > 0) {
updateFileList(newFiles);
}
};
input.click();
}}
onRemoveFile={handleRemoveFile}
onReset={handleReset}
disabled={isConnecting}
/>
{roomStatus && currentRole === 'sender' && (
<Card className="mt-6">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
线
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-primary">
{roomStatus.sender_count + roomStatus.receiver_count}
</div>
<div className="text-sm text-muted-foreground">线</div>
</div>
<div>
<div className="text-2xl font-bold text-blue-600">
{roomStatus.sender_count}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">
{roomStatus.receiver_count}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="receive" className="mt-6">
<FileReceive
onJoinRoom={handleJoinRoom}
files={receiverFiles}
onDownloadFile={handleDownloadFile}
transferProgresses={transferProgresses}
isConnected={isConnected}
isConnecting={isConnecting}
/>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
"use client";
import { Suspense } from 'react';
import HomePage from './HomePage-new';
function HomePageWrapper() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">...</div>}>
<HomePage />
</Suspense>
);
}
export default HomePageWrapper;

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
const GO_BACKEND_URL = process.env.GO_BACKEND_URL || 'http://localhost:8080';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// 转发请求到Go后端
const response = await fetch(`${GO_BACKEND_URL}/api/create-room`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('Error creating room:', error);
return NextResponse.json(
{ success: false, message: '创建房间失败' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,35 @@
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');
if (!code) {
return NextResponse.json(
{ success: false, message: '缺少取件码' },
{ status: 400 }
);
}
// 转发请求到Go后端
const response = await fetch(`${GO_BACKEND_URL}/api/room-info?code=${code}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('Error getting room info:', error);
return NextResponse.json(
{ success: false, message: '获取房间信息失败' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,35 @@
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');
if (!code) {
return NextResponse.json(
{ success: false, message: '缺少取件码' },
{ status: 400 }
);
}
// 转发请求到Go后端
const response = await fetch(`${GO_BACKEND_URL}/api/room-status?code=${code}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('Error getting room status:', error);
return NextResponse.json(
{ success: false, message: '获取房间状态失败' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { code, files } = body;
if (!code || !files) {
return NextResponse.json(
{ success: false, message: '缺少必要参数' },
{ status: 400 }
);
}
// 转发请求到Go后端
const backendUrl = process.env.NODE_ENV === 'production'
? `https://${process.env.VERCEL_URL || 'localhost'}/api/update-files`
: 'http://localhost:8080/api/update-files';
const response = await fetch(backendUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, files }),
});
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Update files API error:', error);
return NextResponse.json(
{ success: false, message: '服务器错误' },
{ status: 500 }
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,81 @@
@import "tailwindcss";
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius: var(--radius);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
* {
border-color: hsl(var(--border));
}
body {
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,3 @@
import HomePageWrapper from './HomePageWrapper';
export default HomePageWrapper;

View File

@@ -0,0 +1,3 @@
import HomePageWrapper from './HomePageWrapper';
export default HomePageWrapper;

View File

@@ -0,0 +1,169 @@
"use client";
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Download, FileText, Image, Video, Music, Archive } from 'lucide-react';
import { FileInfo, TransferProgress } from '@/types';
interface FileReceiveProps {
onJoinRoom: (code: string) => void;
files: FileInfo[];
onDownloadFile: (fileId: string) => void;
transferProgresses: TransferProgress[];
isConnected: boolean;
isConnecting: boolean;
}
const getFileIcon = (mimeType: string) => {
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5" />;
if (mimeType.startsWith('video/')) return <Video className="w-5 h-5" />;
if (mimeType.startsWith('audio/')) return <Music className="w-5 h-5" />;
if (mimeType.includes('zip') || mimeType.includes('rar')) return <Archive className="w-5 h-5" />;
return <FileText className="w-5 h-5" />;
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
export function FileReceive({
onJoinRoom,
files,
onDownloadFile,
transferProgresses,
isConnected,
isConnecting
}: FileReceiveProps) {
const [pickupCode, setPickupCode] = useState('');
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (pickupCode.length === 6) {
onJoinRoom(pickupCode.toUpperCase());
}
}, [pickupCode, onJoinRoom]);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase();
if (value.length <= 6) {
setPickupCode(value);
}
}, []);
// 如果已经连接并且有文件列表,显示文件列表
if (files.length > 0) {
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle> ({files.length})</CardTitle>
<CardDescription>
{isConnected ? (
<span className="text-green-600"> </span>
) : (
<span className="text-yellow-600"> ...</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{files.map((file) => {
const progress = transferProgresses.find(p => p.originalFileId === file.id);
const isDownloading = progress && progress.status === 'downloading';
console.log(`文件 ${file.id} 进度状态:`, progress, '是否下载中:', isDownloading);
return (
<div key={file.id} className="space-y-2">
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<div className="text-muted-foreground">
{getFileIcon(file.type)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{file.name}</p>
<p className="text-sm text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
</div>
<Button
onClick={() => onDownloadFile(file.id)}
disabled={!isConnected || isDownloading}
size="sm"
>
<Download className="w-4 h-4 mr-2" />
{isDownloading ? '下载中...' : '下载'}
</Button>
</div>
{progress && progress.status === 'downloading' && (
<div className="px-3">
<div className="flex justify-between text-sm text-muted-foreground mb-1">
<span>...</span>
<span>{progress.progress.toFixed(1)}%</span>
</div>
<Progress value={progress.progress} className="h-2" />
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{formatFileSize(progress.receivedSize)} / {formatFileSize(progress.totalSize)}</span>
</div>
</div>
)}
{progress && (
<div className="px-3 text-xs text-muted-foreground">
={progress.status}, ={progress.progress}%, ID={progress.originalFileId}
</div>
)}
</div>
);
})}
</CardContent>
</Card>
</div>
);
}
// 显示取件码输入界面
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
6
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Input
value={pickupCode}
onChange={handleInputChange}
placeholder="输入6位取件码"
className="text-center text-2xl tracking-wider font-mono"
maxLength={6}
disabled={isConnecting}
/>
<p className="text-xs text-muted-foreground text-center">
{pickupCode.length}/6
</p>
</div>
<Button
type="submit"
className="w-full"
disabled={pickupCode.length !== 6 || isConnecting}
>
{isConnecting ? '连接中...' : '连接'}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,267 @@
"use client";
import { useState, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Upload, FileText, Image, Video, Music, Archive, X } from 'lucide-react';
interface FileUploadProps {
selectedFiles: File[];
onFilesChange: (files: File[]) => void;
onGenerateCode: () => void;
pickupCode?: string;
pickupLink?: string;
onCopyCode?: () => void;
onCopyLink?: () => void;
onAddMoreFiles?: () => void;
onRemoveFile?: (updatedFiles: File[]) => void;
onReset?: () => void;
disabled?: boolean;
}
const getFileIcon = (mimeType: string) => {
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5" />;
if (mimeType.startsWith('video/')) return <Video className="w-5 h-5" />;
if (mimeType.startsWith('audio/')) return <Music className="w-5 h-5" />;
if (mimeType.includes('zip') || mimeType.includes('rar')) return <Archive className="w-5 h-5" />;
return <FileText className="w-5 h-5" />;
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
export default function FileUpload({
selectedFiles,
onFilesChange,
onGenerateCode,
pickupCode,
pickupLink,
onCopyCode,
onCopyLink,
onAddMoreFiles,
onRemoveFile,
onReset,
disabled = false,
}: FileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
onFilesChange([...selectedFiles, ...files]);
}
}, [selectedFiles, onFilesChange]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
onFilesChange([...selectedFiles, ...files]);
}
}, [selectedFiles, onFilesChange]);
const removeFile = useCallback((index: number) => {
const newFiles = selectedFiles.filter((_, i) => i !== index);
onFilesChange(newFiles);
// 如果已经生成了取件码,同步删除操作到接收端
if (onRemoveFile) {
onRemoveFile(newFiles);
}
}, [selectedFiles, onFilesChange, onRemoveFile]);
const handleClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
if (selectedFiles.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Upload className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragOver
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-lg font-medium mb-2"></p>
<p className="text-sm text-muted-foreground">
</p>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={disabled}
/>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Upload className="w-5 h-5" />
<span> ({selectedFiles.length})</span>
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{selectedFiles.map((file, index) => (
<div
key={`${file.name}-${file.size}-${index}`}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div className="flex items-center space-x-3">
{getFileIcon(file.type)}
<div>
<p className="font-medium">{file.name}</p>
<p className="text-sm text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(index)}
disabled={disabled}
className="text-destructive hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
<div className="flex flex-wrap gap-2 pt-4">
{!pickupCode && (
<>
<Button
onClick={onGenerateCode}
disabled={disabled || selectedFiles.length === 0}
className="flex-1 min-w-[120px]"
>
</Button>
<Button
variant="outline"
onClick={onAddMoreFiles}
disabled={disabled}
>
</Button>
</>
)}
{pickupCode && (
<Button
variant="outline"
onClick={onAddMoreFiles}
disabled={disabled}
className="flex-1"
>
</Button>
)}
<Button
variant="outline"
onClick={onReset}
disabled={disabled}
>
</Button>
</div>
</CardContent>
</Card>
{pickupCode && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium"></label>
<div className="flex space-x-2 mt-1">
<div className="flex-1 p-3 bg-muted rounded-lg font-mono text-lg text-center">
{pickupCode}
</div>
<Button
variant="outline"
onClick={onCopyCode}
size="sm"
>
</Button>
</div>
</div>
{pickupLink && (
<div>
<label className="text-sm font-medium"></label>
<div className="flex space-x-2 mt-1">
<div className="flex-1 p-3 bg-muted rounded-lg text-sm break-all">
{pickupLink}
</div>
<Button
variant="outline"
onClick={onCopyLink}
size="sm"
>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import { cn } from "@/lib/utils"
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,119 @@
"use client";
import { useState, useEffect, useCallback, useRef } from 'react';
import { UseWebSocketReturn, WebSocketMessage } from '@/types';
export function useWebSocket(): UseWebSocketReturn {
const [websocket, setWebsocket] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentCodeRef = useRef<string>('');
const currentRoleRef = useRef<'sender' | 'receiver'>('sender');
const connect = useCallback((code: string, role: 'sender' | 'receiver') => {
if (websocket) {
websocket.close();
}
currentCodeRef.current = code;
currentRoleRef.current = role;
// 连接到Go后端的WebSocket
const wsUrl = process.env.NODE_ENV === 'production'
? `wss://${window.location.host}/ws/p2p?code=${code}&role=${role}`
: `ws://localhost:8080/ws/p2p?code=${code}&role=${role}`;
console.log('连接WebSocket:', wsUrl);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket连接已建立');
setIsConnected(true);
setWebsocket(ws);
// 发送初始连接信息
const message = {
type: 'connect',
payload: {
code: code,
role: role,
timestamp: Date.now()
}
};
ws.send(JSON.stringify(message));
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
console.log('收到WebSocket消息:', message);
// 分发事件
const customEvent = new CustomEvent('websocket-message', {
detail: message
});
window.dispatchEvent(customEvent);
} catch (error) {
console.error('解析WebSocket消息失败:', error);
}
};
ws.onclose = (event) => {
console.log('WebSocket连接关闭:', event.code, event.reason);
setIsConnected(false);
setWebsocket(null);
// 如果不是正常关闭且有房间码,尝试重连
if (event.code !== 1000 && currentCodeRef.current) {
console.log('尝试重新连接...');
reconnectTimeoutRef.current = setTimeout(() => {
connect(currentCodeRef.current, currentRoleRef.current);
}, 3000);
}
};
ws.onerror = (error) => {
console.error('WebSocket错误:', error);
};
}, [websocket]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
currentCodeRef.current = '';
if (websocket) {
websocket.close(1000, 'User disconnected');
}
}, [websocket]);
const sendMessage = useCallback((message: WebSocketMessage) => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify(message));
} else {
console.warn('WebSocket未连接无法发送消息');
}
}, [websocket]);
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (websocket) {
websocket.close();
}
};
}, [websocket]);
return {
websocket,
isConnected,
connect,
disconnect,
sendMessage
};
}

View File

@@ -0,0 +1,125 @@
"use client";
import { useState, useEffect, useCallback, useRef } from 'react';
import { UseWebSocketReturn, WebSocketMessage } from '@/types';
export function useWebSocket(): UseWebSocketReturn {
const [websocket, setWebsocket] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentCodeRef = useRef<string>('');
const currentRoleRef = useRef<'sender' | 'receiver'>('sender');
const connect = useCallback((code: string, role: 'sender' | 'receiver') => {
// 防止重复连接
if (websocket && websocket.readyState === WebSocket.OPEN && currentCodeRef.current === code) {
console.log('WebSocket已连接跳过重复连接');
return;
}
if (websocket) {
websocket.close();
}
currentCodeRef.current = code;
currentRoleRef.current = role;
// 连接到Go后端的WebSocket
const wsUrl = process.env.NODE_ENV === 'production'
? `wss://${window.location.host}/ws/p2p?code=${code}&role=${role}`
: `ws://localhost:8080/ws/p2p?code=${code}&role=${role}`;
console.log('连接WebSocket:', wsUrl);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket连接已建立');
setIsConnected(true);
setWebsocket(ws);
// 发送初始连接信息
const message = {
type: 'connect',
payload: {
code: code,
role: role,
timestamp: Date.now()
}
};
ws.send(JSON.stringify(message));
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
console.log('收到WebSocket消息:', message);
// 分发事件
const customEvent = new CustomEvent('websocket-message', {
detail: message
});
window.dispatchEvent(customEvent);
} catch (error) {
console.error('解析WebSocket消息失败:', error);
}
};
ws.onclose = (event) => {
console.log('WebSocket连接关闭:', event.code, event.reason);
setIsConnected(false);
setWebsocket(null);
// 如果不是正常关闭且有房间码,尝试重连
if (event.code !== 1000 && currentCodeRef.current) {
console.log('尝试重新连接...');
reconnectTimeoutRef.current = setTimeout(() => {
connect(currentCodeRef.current, currentRoleRef.current);
}, 3000);
}
};
ws.onerror = (error) => {
console.error('WebSocket错误:', error);
};
}, [websocket]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
currentCodeRef.current = '';
if (websocket) {
websocket.close(1000, 'User disconnected');
}
}, [websocket]);
const sendMessage = useCallback((message: WebSocketMessage) => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify(message));
} else {
console.warn('WebSocket未连接无法发送消息');
}
}, [websocket]);
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (websocket) {
websocket.close();
}
};
}, [websocket]);
return {
websocket,
isConnected,
connect,
disconnect,
sendMessage
};
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,51 @@
// 文件传输相关类型
export interface FileInfo {
id: string;
name: string;
size: number;
type: string;
lastModified?: number;
}
export interface TransferProgress {
fileId: string;
originalFileId?: string; // 原始文件ID用于UI匹配
fileName: string;
progress: number;
receivedSize: number;
totalSize: number;
status: 'pending' | 'downloading' | 'uploading' | 'completed' | 'error';
}
export interface RoomStatus {
code: string;
file_count: number;
sender_count: number;
receiver_count: number;
clients: {
id: string;
role: 'sender' | 'receiver';
joined_at: string;
user_agent: string;
}[];
created_at: string;
}
export interface FileChunk {
offset: number;
data: Uint8Array;
}
export interface WebSocketMessage {
type: string;
payload: Record<string, unknown>;
}
// WebSocket 钩子状态
export interface UseWebSocketReturn {
websocket: WebSocket | null;
isConnected: boolean;
connect: (code: string, role: 'sender' | 'receiver') => void;
disconnect: () => void;
sendMessage: (message: WebSocketMessage) => void;
}

27
chuan-next/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

3346
chuan-next/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -193,6 +193,9 @@ func (p *P2PService) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
// 处理特殊消息类型
switch msg.Type {
case "update-file-list":
// 处理文件列表更新
p.handleFileListUpdate(room, clientID, msg)
case "file-request":
// 处理文件请求
p.handleFileRequest(room, clientID, msg)
@@ -265,6 +268,88 @@ func (p *P2PService) getRoomStatus(room *FileTransferRoom) models.RoomStatus {
}
}
// handleFileListUpdate 处理文件列表更新
func (p *P2PService) handleFileListUpdate(room *FileTransferRoom, clientID string, msg models.VideoMessage) {
// 获取文件列表
payload, ok := msg.Payload.(map[string]interface{})
if !ok {
log.Printf("无效的文件列表更新消息格式")
return
}
filesData, ok := payload["files"].([]interface{})
if !ok {
log.Printf("缺少文件列表数据")
return
}
// 转换文件列表格式
var files []models.FileTransferInfo
for _, fileData := range filesData {
if fileMap, ok := fileData.(map[string]interface{}); ok {
file := models.FileTransferInfo{
ID: getString(fileMap, "id"),
Name: getString(fileMap, "name"),
Size: getInt64(fileMap, "size"),
Type: getString(fileMap, "type"),
LastModified: getInt64(fileMap, "lastModified"),
}
files = append(files, file)
}
}
log.Printf("收到文件列表更新请求,共 %d 个文件", len(files))
// 更新房间文件列表
room.mutex.Lock()
room.Files = files
room.mutex.Unlock()
log.Printf("房间 %s 文件列表已更新,共 %d 个文件", room.Code, len(files))
// 通知所有接收方客户端文件列表已更新
room.mutex.RLock()
for _, client := range room.Clients {
if client.Role == "receiver" {
message := models.VideoMessage{
Type: "file-list-updated",
Payload: map[string]interface{}{
"files": files,
},
}
if err := client.Connection.WriteJSON(message); err != nil {
log.Printf("发送文件列表更新消息失败: %v", err)
} else {
log.Printf("已向接收方 %s 发送文件列表更新消息", client.ID)
}
}
}
room.mutex.RUnlock()
}
// 辅助函数从map中获取字符串值
func getString(m map[string]interface{}, key string) string {
if val, ok := m[key].(string); ok {
return val
}
return ""
}
// 辅助函数从map中获取int64值
func getInt64(m map[string]interface{}, key string) int64 {
if val, ok := m[key].(float64); ok {
return int64(val)
}
if val, ok := m[key].(int64); ok {
return val
}
if val, ok := m[key].(int); ok {
return int64(val)
}
return 0
}
// handleFileRequest 处理文件请求
func (p *P2PService) handleFileRequest(room *FileTransferRoom, clientID string, msg models.VideoMessage) {
// 获取请求的文件ID

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
// P2P文件传输系统多人房间版本
// 全局变量
let websocket = null;
let clientConnections = new Map(); // 存储与其他客户端的P2P连接
@@ -11,22 +10,263 @@ let isP2PConnected = false; // P2P连接状态
let isConnecting = false; // 是否正在连接中
let pendingChunkMeta = null; // 待处理的数据块元数据
// 通知系统
function showNotification(message, type = 'info', duration = 5000) {
// 移除现有通知
const existing = document.querySelector('.notification');
if (existing) {
existing.remove();
}
const notification = document.createElement('div');
notification.className = `notification ${type}`;
const icons = {
success: `<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>`,
error: `<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>`,
warning: `<svg class="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>`,
info: `<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>`
};
notification.innerHTML = `
<div class="flex items-center">
${icons[type]}
<span class="ml-3 text-gray-900">${message}</span>
<button onclick="this.parentElement.parentElement.remove()" class="ml-auto text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
`;
document.body.appendChild(notification);
// 动画显示
setTimeout(() => notification.classList.add('show'), 100);
// 自动消失
if (duration > 0) {
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => notification.remove(), 300);
}, duration);
}
}
// 复制取件码增强
function copyPickupCode(event) {
// 阻止事件冒泡
if (event) {
event.stopPropagation();
event.preventDefault();
}
const code = document.getElementById('pickupCodeDisplay').textContent;
navigator.clipboard.writeText(code).then(() => {
showNotification('取件码已复制到剪贴板!', 'success', 3000);
// 添加视觉反馈
const codeDisplay = document.getElementById('pickupCodeDisplay');
const originalText = codeDisplay.textContent;
codeDisplay.textContent = '✅ 已复制';
codeDisplay.classList.add('success-bounce');
setTimeout(() => {
codeDisplay.textContent = originalText;
codeDisplay.classList.remove('success-bounce');
}, 1500);
}).catch(() => {
showNotification('复制失败,请手动复制取件码', 'error');
});
}
// 复制取件链接
function copyPickupLink(event) {
// 阻止事件冒泡
if (event) {
event.stopPropagation();
event.preventDefault();
}
const link = document.getElementById('pickupLinkDisplay').textContent;
navigator.clipboard.writeText(link).then(() => {
showNotification('取件链接已复制到剪贴板!', 'success', 3000);
// 添加视觉反馈
const linkDisplay = document.getElementById('pickupLinkDisplay');
const originalText = linkDisplay.textContent;
linkDisplay.textContent = '✅ 已复制';
linkDisplay.classList.add('success-bounce');
setTimeout(() => {
linkDisplay.textContent = originalText;
linkDisplay.classList.remove('success-bounce');
}, 1500);
}).catch(() => {
showNotification('复制失败,请手动复制链接', 'error');
});
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
initializeEventListeners();
initializeAnimations();
handleUrlParams(); // 处理URL参数
});
// 标签页切换函数
function switchTab(tab) {
// 移除所有标签页的活动状态
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active', 'border-blue-500', 'bg-blue-50', 'text-blue-600', 'border-green-500', 'bg-green-50', 'text-green-600');
btn.classList.add('border-transparent', 'text-gray-600');
});
// 隐藏所有标签页内容
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
content.classList.remove('active');
});
// 激活选中的标签页
if (tab === 'send') {
const sendTab = document.getElementById('sendTab');
const sendContent = document.getElementById('sendContent');
sendTab.classList.remove('border-transparent', 'text-gray-600');
sendTab.classList.add('active', 'border-blue-500', 'bg-blue-50', 'text-blue-600');
sendContent.classList.remove('hidden');
sendContent.classList.add('active');
} else if (tab === 'receive') {
const receiveTab = document.getElementById('receiveTab');
const receiveContent = document.getElementById('receiveContent');
receiveTab.classList.remove('border-transparent', 'text-gray-600');
receiveTab.classList.add('active', 'border-green-500', 'bg-green-50', 'text-green-600');
receiveContent.classList.remove('hidden');
receiveContent.classList.add('active');
}
}
// 处理URL参数
function handleUrlParams() {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (code && code.length === 6) {
// 切换到接收标签页
switchTab('receive');
// 自动填入取件码
const codeInput = document.getElementById('pickupCodeInput');
codeInput.value = code.toUpperCase();
// 触发输入事件以应用样式
codeInput.dispatchEvent(new Event('input'));
// 显示通知并自动连接
showNotification('检测到取件码,正在自动连接...', 'info', 3000);
setTimeout(() => {
joinRoom();
}, 1000);
}
}
// 初始化动画效果
function initializeAnimations() {
// 为主要元素添加进入动画
const leftPanel = document.querySelector('.lg\\:grid-cols-2 > div:first-child');
const rightPanel = document.querySelector('.lg\\:grid-cols-2 > div:last-child');
if (leftPanel) {
leftPanel.classList.add('slide-in-left');
}
if (rightPanel) {
rightPanel.classList.add('slide-in-right');
}
// 标题动画
const title = document.querySelector('h1');
if (title) {
title.classList.add('fade-in-down');
}
// 为按钮添加点击反馈效果
const buttons = document.querySelectorAll('button');
buttons.forEach(button => {
button.classList.add('click-feedback');
// 添加悬停音效反馈(视觉)
button.addEventListener('mouseenter', () => {
if (!button.disabled) {
button.style.transform = 'translateY(-1px) scale(1.02)';
}
});
button.addEventListener('mouseleave', () => {
button.style.transform = '';
});
});
}
// 初始化事件监听器
function initializeEventListeners() {
// 文件选择事件
document.getElementById('fileInput').addEventListener('change', handleFileSelect);
// 取件码输入事件
document.getElementById('pickupCodeInput').addEventListener('input', (e) => {
e.target.value = e.target.value.toUpperCase();
if (e.target.value.length === 6) {
// 取件码输入事件 - 增强用户体验
const codeInput = document.getElementById('pickupCodeInput');
codeInput.addEventListener('input', (e) => {
// 只允许字母和数字,自动转大写
let value = e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase();
e.target.value = value;
// 视觉反馈
if (value.length > 0) {
e.target.classList.remove('border-gray-200');
e.target.classList.add('border-blue-300');
} else {
e.target.classList.add('border-gray-200');
e.target.classList.remove('border-blue-300');
}
// 长度验证和自动连接
if (value.length === 6) {
e.target.classList.remove('border-blue-300');
e.target.classList.add('border-green-400');
showNotification('取件码格式正确,正在连接...', 'info', 3000);
// 自动连接
setTimeout(() => joinRoom(), 100);
setTimeout(() => joinRoom(), 500);
} else if (value.length > 6) {
e.target.value = value.substring(0, 6);
}
});
// 取件码输入框焦点事件
codeInput.addEventListener('focus', () => {
codeInput.classList.add('ring-4', 'ring-blue-100');
});
codeInput.addEventListener('blur', () => {
codeInput.classList.remove('ring-4', 'ring-blue-100');
});
// 回车键快速连接
codeInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && e.target.value.length === 6) {
joinRoom();
}
});
@@ -36,25 +276,42 @@ function initializeEventListeners() {
// 设置拖拽上传
function setupDragAndDrop() {
const dropArea = document.querySelector('.border-dashed');
const dropArea = document.getElementById('fileDropZone');
dropArea.addEventListener('dragover', (e) => {
e.preventDefault();
dropArea.classList.add('border-blue-400');
dropArea.classList.add('drag-over');
});
dropArea.addEventListener('dragleave', () => {
dropArea.classList.remove('border-blue-400');
dropArea.addEventListener('dragenter', (e) => {
e.preventDefault();
dropArea.classList.add('drag-over');
});
dropArea.addEventListener('dragleave', (e) => {
e.preventDefault();
// 只有当鼠标离开dropArea本身时才移除样式
if (!dropArea.contains(e.relatedTarget)) {
dropArea.classList.remove('drag-over');
}
});
dropArea.addEventListener('drop', (e) => {
e.preventDefault();
dropArea.classList.remove('border-blue-400');
dropArea.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
// 添加新文件到现有列表
selectedFiles = [...selectedFiles, ...files];
displaySelectedFiles();
// 显示成功动画
dropArea.classList.add('success-bounce');
setTimeout(() => {
dropArea.classList.remove('success-bounce');
}, 1000);
// 如果已经生成了取件码,自动更新房间文件列表
if (currentPickupCode && currentRole === 'sender') {
updateRoomFiles();
@@ -78,42 +335,102 @@ function handleFileSelect(event) {
}
}
// 显示选中的文件
// 显示选中的文件 - 修改布局逻辑
function displaySelectedFiles() {
const container = document.getElementById('selectedFiles');
console.log('displaySelectedFiles called, selectedFiles count:', selectedFiles.length);
const fileDropZone = document.getElementById('fileDropZone');
const fileListArea = document.getElementById('fileListArea');
const filesList = document.getElementById('filesList');
const fileCount = document.getElementById('fileCount');
console.log('Elements found:', {
fileDropZone: !!fileDropZone,
fileListArea: !!fileListArea,
filesList: !!filesList,
fileCount: !!fileCount
});
if (selectedFiles.length === 0) {
container.classList.add('hidden');
fileDropZone.style.display = 'block';
fileListArea.classList.add('hidden');
return;
}
container.classList.remove('hidden');
// 隐藏初始选择区域,显示文件列表区域
fileDropZone.style.display = 'none';
fileListArea.classList.remove('hidden');
fileListArea.classList.add('fade-in-up');
// 更新文件计数
if (fileCount) {
fileCount.textContent = `${selectedFiles.length} 个文件`;
}
filesList.innerHTML = '';
selectedFiles.forEach((file, index) => {
const fileItem = document.createElement('div');
fileItem.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg';
fileItem.className = 'file-item flex items-center justify-between bg-gray-50 p-2 rounded-lg border hover:shadow-sm';
// 安全地获取文件信息
const fileType = file.type || 'application/octet-stream';
const fileName = file.name || '未知文件';
const fileSize = file.size || 0;
fileItem.innerHTML = `
<div class="flex items-center">
<span class="text-2xl mr-3">${getFileIcon(file.type)}</span>
<div>
<div class="font-medium">${file.name}</div>
<div class="text-sm text-gray-500">${formatFileSize(file.size)}</div>
<div class="flex items-center flex-1 min-w-0">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center mr-2 flex-shrink-0">
<span class="text-sm">${getFileIcon(fileType)}</span>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 truncate text-sm">${fileName}</div>
<div class="text-xs text-gray-500">${formatFileSize(fileSize)}</div>
</div>
</div>
<button onclick="removeFile(${index})" class="text-red-500 hover:text-red-700 p-1">
<button onclick="removeFile(${index}, event)"
class="ml-2 w-6 h-6 flex items-center justify-center rounded-full bg-red-50 text-red-500 hover:bg-red-100 hover:text-red-600 transition-colors"
title="移除文件">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
`;
filesList.appendChild(fileItem);
});
}
// 处理拖拽区域点击
function handleDropZoneClick(event) {
event.stopPropagation();
document.getElementById('fileInput').click();
}
// 添加更多文件
function addMoreFiles() {
document.getElementById('fileInput').click();
}
// 移除文件
function removeFile(index) {
function removeFile(index, event) {
// 阻止事件冒泡
if (event) {
event.stopPropagation();
event.preventDefault();
}
selectedFiles.splice(index, 1);
displaySelectedFiles();
// 如果没有文件了,回到初始选择状态
if (selectedFiles.length === 0) {
const fileDropZone = document.getElementById('fileDropZone');
const fileListArea = document.getElementById('fileListArea');
fileDropZone.style.display = 'block';
fileListArea.classList.add('hidden');
} else {
displaySelectedFiles();
}
// 如果已经生成了取件码,需要更新房间文件列表
if (currentPickupCode && currentRole === 'sender') {
@@ -212,9 +529,18 @@ async function generatePickupCode() {
}
}
// 显示取件码
// 显示取件码和链接
function showPickupCode(code) {
document.getElementById('pickupCodeDisplay').textContent = code;
const pickupCodeDisplay = document.getElementById('pickupCodeDisplay');
const pickupLinkDisplay = document.getElementById('pickupLinkDisplay');
pickupCodeDisplay.textContent = code;
// 生成特定链接
const baseUrl = window.location.origin;
const pickupLink = `${baseUrl}/?code=${code}`;
pickupLinkDisplay.textContent = pickupLink;
document.getElementById('pickupCodeSection').classList.remove('hidden');
// 不隐藏生成取件码按钮,改为"添加更多文件"
const generateBtn = document.getElementById('generateCodeBtn');
@@ -222,15 +548,14 @@ function showPickupCode(code) {
generateBtn.onclick = addMoreFiles;
}
// 复制取件码
function copyPickupCode() {
navigator.clipboard.writeText(currentPickupCode).then(() => {
alert('取件码已复制到剪贴板');
});
}
// 重置发送方
function resetSender() {
function resetSender(event) {
// 阻止事件冒泡
if (event) {
event.stopPropagation();
event.preventDefault();
}
selectedFiles = [];
currentPickupCode = '';
currentRole = '';
@@ -239,36 +564,88 @@ function resetSender() {
websocket.close();
}
document.getElementById('selectedFiles').classList.add('hidden');
document.getElementById('pickupCodeSection').classList.add('hidden');
document.getElementById('generateCodeBtn').classList.remove('hidden');
document.getElementById('fileInput').value = '';
document.getElementById('roomStatusSection').classList.add('hidden');
// 重置界面
const fileDropZone = document.getElementById('fileDropZone');
const fileListArea = document.getElementById('fileListArea');
const pickupCodeSection = document.getElementById('pickupCodeSection');
const generateBtn = document.getElementById('generateCodeBtn');
const fileInput = document.getElementById('fileInput');
const roomStatusSection = document.getElementById('roomStatusSection');
// 显示初始选择区域
fileDropZone.style.display = 'block';
fileListArea.classList.add('hidden');
pickupCodeSection.classList.add('hidden');
roomStatusSection.classList.add('hidden');
// 重置按钮
generateBtn.textContent = '生成取件码';
generateBtn.onclick = generatePickupCode;
// 清空文件输入
fileInput.value = '';
showNotification('已重置,可以重新选择文件', 'info', 2000);
}
// 加入房间
async function joinRoom() {
const code = document.getElementById('pickupCodeInput').value.trim();
const codeInput = document.getElementById('pickupCodeInput');
const code = codeInput.value.trim();
const joinButton = document.querySelector('button[onclick="joinRoom()"]');
// 输入验证
if (code.length !== 6) {
alert('请输入6位取件码');
showNotification('请输入6位取件码', 'warning');
codeInput.classList.add('error-shake');
codeInput.focus();
setTimeout(() => codeInput.classList.remove('error-shake'), 500);
return;
}
// 防止重复点击
if (isConnecting) {
return;
}
isConnecting = true;
joinButton.disabled = true;
joinButton.classList.add('loading');
const originalText = joinButton.textContent;
joinButton.textContent = '连接中...';
try {
showNotification('正在验证取件码...', 'info', 3000);
const response = await fetch(`/api/room-info?code=${code}`);
const data = await response.json();
if (data.success) {
currentPickupCode = code;
currentRole = 'receiver';
showNotification('取件码验证成功!正在获取文件列表...', 'success', 3000);
displayReceiverFiles(data.files);
connectWebSocket();
// 隐藏输入界面
document.getElementById('codeInputSection').classList.add('hidden');
} else {
alert(data.message);
showNotification(data.message || '取件码无效或已过期', 'error');
codeInput.classList.add('error-shake');
setTimeout(() => codeInput.classList.remove('error-shake'), 500);
}
} catch (error) {
console.error('连接失败:', error);
alert('连接失败,请检查取件码是否正确');
showNotification('连接失败,请检查网络连接或稍后重试', 'error');
codeInput.classList.add('error-shake');
setTimeout(() => codeInput.classList.remove('error-shake'), 500);
} finally {
isConnecting = false;
joinButton.disabled = false;
joinButton.classList.remove('loading');
joinButton.textContent = originalText;
}
}
@@ -775,31 +1152,42 @@ function displayReceiverFiles(files) {
files.forEach((file, index) => {
const fileItem = document.createElement('div');
fileItem.className = 'flex items-center justify-between bg-gray-50 p-3 rounded-lg';
fileItem.className = 'file-item flex items-center justify-between bg-gray-50 p-2 rounded-lg border hover:shadow-sm transition-all';
fileItem.innerHTML = `
<div class="flex items-center">
<span class="text-2xl mr-3">${getFileIcon(file.type)}</span>
<div>
<div class="font-medium">${file.name}</div>
<div class="text-sm text-gray-500">${formatFileSize(file.size)}</div>
<div class="flex items-center flex-1 min-w-0">
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center mr-2 flex-shrink-0">
<span class="text-sm">${getFileIcon(file.type)}</span>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 truncate text-sm">${file.name}</div>
<div class="text-xs text-gray-500">${formatFileSize(file.size)}</div>
</div>
</div>
<button onclick="downloadFile('${file.id}')" disabled
class="bg-blue-500 text-white px-4 py-2 rounded font-semibold opacity-50 cursor-not-allowed">
📥 下载
id="download-btn-${file.id}"
class="ml-2 bg-green-500 hover:bg-green-600 text-white px-2 py-1 rounded-lg font-medium transition-colors opacity-50 cursor-not-allowed flex items-center text-xs">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
下载
</button>
`;
filesList.appendChild(fileItem);
});
// 只有在WebSocket未连接时才显示连接状态
if (!websocket || websocket.readyState !== WebSocket.OPEN) {
console.log('WebSocket未连接显示连接中状态');
updateP2PStatus(false);
} else {
console.log('WebSocket已连接启用下载功能');
updateP2PStatus(true);
}
// 显示文件列表后,检查连接状态
console.log('文件列表显示完成当前WebSocket状态:', websocket ? websocket.readyState : 'null');
// 延迟一点检查状态确保DOM更新完成
setTimeout(() => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
console.log('WebSocket已连接启用下载功能');
updateP2PStatus(true);
} else {
console.log('WebSocket未连接显示连接中状态');
updateP2PStatus(false);
}
}, 100);
}
// 下载文件(多人房间版本)
@@ -857,9 +1245,11 @@ function updateP2PStatus(connected) {
if (connected) {
console.log('设置为已连接状态');
receiverStatus.innerHTML = `
<div class="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800">
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
已连接,可以下载文件
<div class="flex items-center justify-center p-3 bg-green-50 border border-green-200 rounded-lg">
<div class="flex items-center">
<div class="w-2 h-2 bg-green-500 rounded-full mr-2"></div>
<span class="text-green-800 text-sm font-medium">已连接,可下载文件</span>
</div>
</div>`;
// 启用下载按钮
@@ -867,24 +1257,43 @@ function updateP2PStatus(connected) {
console.log('启用下载按钮:', btn);
btn.disabled = false;
btn.classList.remove('opacity-50', 'cursor-not-allowed');
btn.classList.add('hover:bg-blue-600');
if (btn.textContent === '⏳ 请求中...') {
btn.textContent = '📥 下载';
btn.classList.add('hover:bg-green-600');
// 更新按钮内容
const svg = btn.querySelector('svg');
if (svg) {
svg.innerHTML = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>`;
}
const textNode = btn.childNodes[btn.childNodes.length - 1];
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
textNode.textContent = '下载';
}
});
} else {
console.log('设置为连接中状态');
receiverStatus.innerHTML = `
<div class="inline-flex items-center px-3 py-1 rounded-full bg-yellow-100 text-yellow-800">
<span class="w-2 h-2 bg-yellow-500 rounded-full mr-2"></span>
正在建立连接...
<div class="flex items-center justify-center p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-center">
<div class="w-2 h-2 bg-yellow-500 rounded-full mr-2 animate-pulse"></div>
<span class="text-yellow-800 text-sm font-medium">正在建立连接...</span>
</div>
</div>`;
// 禁用下载按钮
downloadButtons.forEach(btn => {
btn.disabled = true;
btn.classList.add('opacity-50', 'cursor-not-allowed');
btn.classList.remove('hover:bg-blue-600');
btn.classList.remove('hover:bg-green-600');
// 更新按钮内容为等待状态
const svg = btn.querySelector('svg');
if (svg) {
svg.innerHTML = `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>`;
}
const textNode = btn.childNodes[btn.childNodes.length - 1];
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
textNode.textContent = '等待连接';
}
});
}
} else {
@@ -906,20 +1315,34 @@ function showTransferProgress(fileId, type, fileName = null) {
}
progressContainer.classList.remove('hidden');
progressContainer.classList.add('fade-in-up');
const displayName = fileName || fileId;
const progressItem = document.createElement('div');
progressItem.id = `progress-${fileId}`;
progressItem.className = 'bg-gray-100 p-3 rounded-lg';
progressItem.className = 'bg-white border border-gray-200 p-4 rounded-xl shadow-sm';
progressItem.innerHTML = `
<div class="flex justify-between items-center mb-2">
<span class="font-medium">文件: ${displayName}</span>
<span class="text-sm text-gray-500">${type === 'uploading' ? '上传中' : '下载中'}</span>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center mr-3">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
</div>
<div>
<div class="font-medium text-gray-900 truncate">${displayName}</div>
<div class="text-sm text-gray-500">${type === 'uploading' ? '正在发送' : '正在接收'}</div>
</div>
</div>
<div class="text-right">
<div class="text-sm font-medium text-purple-600" id="progress-percent-${fileId}">0%</div>
<div class="text-xs text-gray-500" id="progress-size-${fileId}">准备中...</div>
</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div class="progress-bar bg-gradient-to-r from-purple-500 to-purple-600 h-2 rounded-full transition-all duration-300 ease-out"
id="progress-bar-${fileId}" style="width: 0%"></div>
</div>
<div class="text-sm text-gray-500 mt-1">0%</div>
`;
progressList.appendChild(progressItem);
@@ -927,15 +1350,20 @@ function showTransferProgress(fileId, type, fileName = null) {
// 更新传输进度
function updateTransferProgress(fileId, progress, received, total) {
const progressItem = document.getElementById(`progress-${fileId}`);
if (!progressItem) return;
const progressBar = document.getElementById(`progress-bar-${fileId}`);
const progressPercent = document.getElementById(`progress-percent-${fileId}`);
const progressSize = document.getElementById(`progress-size-${fileId}`);
const progressBar = progressItem.querySelector('.bg-blue-500');
const progressText = progressItem.querySelector('.text-sm.text-gray-500:last-child');
if (progressBar && progressText) {
if (progressBar) {
progressBar.style.width = `${progress}%`;
progressText.textContent = `${progress.toFixed(1)}% (${formatFileSize(received)}/${formatFileSize(total)})`;
}
if (progressPercent) {
progressPercent.textContent = `${progress.toFixed(1)}%`;
}
if (progressSize) {
progressSize.textContent = `${formatFileSize(received)} / ${formatFileSize(total)}`;
}
}

View File

@@ -1,132 +1,350 @@
{{define "content"}}
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<div class="container mx-auto px-4 py-8">
<div class="max-w-4xl mx-auto">
<!-- 标题 -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-800 mb-2">P2P文件传输</h1>
<p class="text-gray-600">选择文件自动生成取件码,对方输入取件码即可在线下载</p>
</div>
<!-- 加载屏幕 -->
<div id="loadingScreen" class="fixed inset-0 bg-white z-50 flex items-center justify-center">
<div class="text-center">
<div class="w-16 h-16 border-4 border-blue-200 border-t-blue-500 rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-gray-600">正在加载...</p>
</div>
</div>
<!-- 主界面 -->
<div class="bg-white rounded-xl shadow-lg p-6 mb-6">
<!-- 发送文件区域 -->
<div id="senderSection" class="mb-8">
<h3 class="text-xl font-semibold mb-4">📤 发送文件</h3>
<<!-- 固定的Header -->
<div class="fixed top-0 left-0 right-0 bg-white/95 backdrop-blur-sm border-b border-gray-200 z-50">
<div class="container mx-auto px-4 py-4">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900 mb-1">🔗 P2P文件传输</h1>
<p class="text-gray-600 text-sm">安全快速的点对点文件传输</p>
</div>
</div>
</div>
<div class="min-h-screen bg-gray-50 pt-20" style="display: none;" id="mainContent">
<div class="container mx-auto px-4 py-6">
<div class="max-w-6xl mx-auto">
<!-- 主功能区域 - 标签页布局 -->
<!-- 主功能区域 - 标签页布局 -->
<div class="max-w-4xl mx-auto bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<!-- 标签页导航 -->
<div class="flex border-b border-gray-200">
<button id="sendTab" onclick="switchTab('send')"
class="tab-button active flex-1 px-6 py-4 text-center font-medium transition-all duration-200 border-b-2 border-blue-500 bg-blue-50 text-blue-600">
<div class="flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
发送文件
</div>
</button>
<button id="receiveTab" onclick="switchTab('receive')"
class="tab-button flex-1 px-6 py-4 text-center font-medium transition-all duration-200 border-b-2 border-transparent text-gray-600 hover:text-gray-800 hover:bg-gray-50">
<div class="flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 11l3 3m0 0l3-3m-3 3V8"></path>
</svg>
接收文件
</div>
</button>
</div>
<!-- 发送文件内容区域 -->
<div id="sendContent" class="tab-content active p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-800 mb-2 flex items-center">
<svg class="w-5 h-5 mr-2 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
选择文件并生成取件码
</h3>
<p class="text-gray-600 text-sm">选择要发送的文件,系统会生成取件码供接收方使用</p>
</div>
<!-- 文件选择区域 -->
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors cursor-pointer"
onclick="document.getElementById('fileInput').click()">
<div class="text-6xl mb-4">📁</div>
<p class="text-lg mb-2">点击选择文件或拖拽文件到此处</p>
<p class="text-gray-500">支持多文件选择</p>
<input type="file" id="fileInput" multiple class="hidden">
<div class="p-6">
<!-- 文件选择区域 - 简化设计 -->
<div id="fileDropZone" class="border-2 border-dashed border-gray-200 rounded-xl p-8 text-center hover:border-blue-300 hover:bg-blue-50 transition-all duration-200 cursor-pointer group"
onclick="handleDropZoneClick(event)">
<div class="text-4xl mb-3 group-hover:scale-110 transition-transform duration-200"><EFBFBD></div>
<h3 class="text-lg font-medium text-gray-700 mb-2">选择文件</h3>
<p class="text-sm text-gray-500">点击或拖拽文件到此处</p>
<p class="text-xs text-gray-400 mt-1">支持多文件选择</p>
<input type="file" id="fileInput" multiple class="hidden">
</div>
<!-- 文件列表区域 -->
<div id="fileListArea" class="hidden">
<div class="border-2 border-dashed border-gray-200 rounded-xl p-4 cursor-pointer hover:border-blue-300 hover:bg-blue-50 transition-all duration-200"
onclick="handleDropZoneClick(event)">
<div class="flex items-center justify-between mb-3">
<h4 class="font-medium text-gray-700">已选择文件</h4>
<div class="flex items-center gap-2">
<span id="fileCount" class="text-sm text-gray-500 bg-gray-100 px-2 py-1 rounded-full"></span>
<button onclick="addMoreFiles()" class="text-sm text-blue-600 hover:text-blue-800 px-2 py-1 rounded">
+ 添加更多
</button>
</div>
</div>
<div id="filesList" class="space-y-1 max-h-40 overflow-y-auto mb-4"></div>
<div class="text-center">
<button id="generateCodeBtn" onclick="generatePickupCode()"
class="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded-lg text-sm font-medium transition-colors duration-200 shadow-sm hover:shadow-md">
生成取件码
</button>
</div>
</div>
</div>
<!-- 取件码显示 - 更紧凑的设计 -->
<div id="pickupCodeSection" class="mt-4 hidden">
<div class="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
<div class="flex items-center justify-center mb-3">
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<h4 class="font-semibold text-green-800 text-sm">取件码和链接</h4>
</div>
<div class="bg-white border border-green-200 rounded-lg p-3 mb-3">
<div class="text-2xl font-mono font-bold text-green-600 tracking-wider select-all" id="pickupCodeDisplay" onclick="event.stopPropagation(); copyPickupCode();"></div>
</div>
<!-- 特定链接 -->
<div class="bg-white border border-green-200 rounded-lg p-2 mb-3">
<div class="text-xs text-gray-600 mb-1">直接取件链接:</div>
<div class="text-xs font-mono text-blue-600 break-all select-all cursor-pointer" id="pickupLinkDisplay" onclick="event.stopPropagation(); copyPickupLink();"></div>
</div>
<p class="text-green-700 text-xs mb-3">点击取件码或链接可直接复制</p>
<div class="grid grid-cols-3 gap-2">
<button onclick="event.stopPropagation(); copyPickupCode()" class="bg-green-500 hover:bg-green-600 text-white py-1.5 px-3 rounded-lg text-xs font-medium transition-colors">
📋 复制码
</button>
<button onclick="event.stopPropagation(); copyPickupLink()" class="bg-blue-500 hover:bg-blue-600 text-white py-1.5 px-3 rounded-lg text-xs font-medium transition-colors">
🔗 复制链接
</button>
<button onclick="event.stopPropagation(); resetSender()" class="bg-gray-500 hover:bg-gray-600 text-white py-1.5 px-3 rounded-lg text-xs font-medium transition-colors">
🔄 重置
</button>
</div>
</div>
<!-- 连接状态 - 简化显示 -->
<div id="senderStatus" class="mt-3">
<div class="flex items-center justify-center p-2 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-center">
<div class="w-2 h-2 bg-yellow-500 rounded-full mr-2 animate-pulse"></div>
<span class="text-yellow-800 text-xs font-medium">等待连接...</span>
</div>
</div>
</div>
<!-- 房间状态 - 折叠式显示 -->
<div id="roomStatusSection" class="mt-3 hidden">
<details class="bg-blue-50 border border-blue-200 rounded-lg">
<summary class="p-2 cursor-pointer text-blue-800 font-medium text-xs hover:bg-blue-100 rounded-lg">
房间状态 📊
</summary>
<div class="px-2 pb-2">
<div id="roomConnections" class="text-xs text-blue-700 space-y-1">
<div class="flex justify-between">
<span>在线用户:</span>
<span id="onlineCount" class="font-medium">0</span>
</div>
<div class="flex justify-between">
<span>发送方:</span>
<span id="senderCount" class="font-medium">0</span>
</div>
<div class="flex justify-between">
<span>接收方:</span>
<span id="receiverCount" class="font-medium">0</span>
</div>
</div>
<div id="clientsList" class="mt-1 space-y-1"></div>
</div>
</details>
</div>
</div>
</div>
</div>
<!-- 接收文件内容区域 -->
<div id="receiveContent" class="tab-content hidden p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-800 mb-2 flex items-center">
<svg class="w-5 h-5 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 11l3 3m0 0l3-3m-3 3V8"></path>
</svg>
输入取件码获取文件
</h3>
<p class="text-gray-600 text-sm">输入6位取件码连接发送方并下载文件</p>
</div>
<!-- 选中的文件列表 -->
<div id="selectedFiles" class="mt-6 hidden">
<h4 class="font-semibold mb-3">已选择的文件:</h4>
<div id="filesList" class="space-y-2 max-h-60 overflow-y-auto"></div>
<div class="mt-4 text-center">
<button id="generateCodeBtn" onclick="generatePickupCode()"
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold">
🎯 生成取件码
<!-- 右侧:接收文件 -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div class="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
<h2 class="text-xl font-semibold text-white flex items-center">
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 11l3 3m0 0l3-3m-3 3V8"></path>
</svg>
接收文件
</h2>
<p class="text-green-100 text-sm mt-1">输入取件码,获取文件</p>
</div>
<div class="p-6">
<!-- 取件码输入 - 简化设计 -->
<div id="codeInputSection" class="text-center">
<div class="max-w-sm mx-auto">
<label class="block text-sm font-medium text-gray-700 mb-3">请输入6位取件码</label>
<input type="text" id="pickupCodeInput" placeholder="取件码" maxlength="6"
class="w-full px-4 py-4 border-2 border-gray-200 rounded-xl text-center text-2xl font-mono font-bold uppercase focus:outline-none focus:border-green-500 focus:ring-4 focus:ring-green-100 transition-all duration-200">
<button onclick="joinRoom()" class="w-full mt-4 bg-green-500 hover:bg-green-600 text-white py-2 px-4 rounded-lg text-sm font-medium transition-colors duration-200 shadow-sm hover:shadow-md">
🔗 连接获取
</button>
</div>
</div>
</div>
<!-- 取件码显示 -->
<div id="pickupCodeSection" class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg hidden">
<div class="text-center">
<h4 class="font-semibold text-green-800 mb-2">取件码已生成</h4>
<div class="text-3xl font-mono font-bold text-green-600 mb-2" id="pickupCodeDisplay"></div>
<p class="text-green-700 mb-4">请将此取件码发送给对方</p>
<div class="flex justify-center space-x-3">
<button onclick="copyPickupCode()" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded">
📋 复制取件码
</button>
<button onclick="resetSender()" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded">
🔄 重新选择文件
</button>
<!-- 文件列表显示 - 优化布局 -->
<div id="receiverFilesSection" class="hidden">
<div class="mt-6">
<h4 class="font-medium text-gray-700 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
可下载文件
</h4>
<div id="receiverFilesList" class="space-y-1"></div>
</div>
</div>
<!-- 连接状态 -->
<div id="senderStatus" class="mt-4 text-center">
<div class="inline-flex items-center px-3 py-1 rounded-full bg-yellow-100 text-yellow-800">
<span class="w-2 h-2 bg-yellow-500 rounded-full mr-2"></span>
等待接收方连接...
<!-- 接收状态 - 简化显示 -->
<div id="receiverStatus" class="mt-6">
<div class="flex items-center justify-center p-3 bg-green-50 border border-green-200 rounded-lg">
<div class="flex items-center">
<div class="w-2 h-2 bg-green-500 rounded-full mr-2"></div>
<span class="text-green-800 text-sm font-medium">已连接,可下载文件</span>
</div>
</div>
</div>
</div>
<!-- 房间状态显示 -->
<div id="roomStatusSection" class="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg hidden">
<h5 class="font-semibold text-blue-800 mb-2">房间状态</h5>
<div id="roomConnections" class="text-sm text-blue-700">
<div>在线用户: <span id="onlineCount">0</span></div>
<div>发送方: <span id="senderCount">0</span></div>
<div>接收方: <span id="receiverCount">0</span></div>
<!-- 房间状态显示 (接收方) - 折叠式 -->
<div id="receiverRoomStatusSection" class="mt-4">
<details class="bg-blue-50 border border-blue-200 rounded-lg">
<summary class="p-3 cursor-pointer text-blue-800 font-medium text-sm hover:bg-blue-100 rounded-lg">
房间状态 📊
</summary>
<div class="px-3 pb-3">
<div id="receiverRoomConnections" class="text-xs text-blue-700 space-y-1">
<div class="flex justify-between">
<span>在线用户:</span>
<span id="receiverOnlineCount" class="font-medium">0</span>
</div>
<div class="flex justify-between">
<span>发送方:</span>
<span id="receiverSenderCount" class="font-medium">0</span>
</div>
<div class="flex justify-between">
<span>接收方:</span>
<span id="receiverReceiverCount" class="font-medium">0</span>
</div>
</div>
<div id="receiverClientsList" class="mt-2 space-y-1"></div>
</div>
</details>
</div>
<div id="clientsList" class="mt-2 space-y-1"></div>
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="border-t border-gray-200 my-8"></div>
<!-- 接收文件区域 -->
<div id="receiverSection">
<h3 class="text-xl font-semibold mb-4">📥 接收文件</h3>
<!-- 取件码输入 -->
<div id="codeInputSection">
<div class="flex justify-center mb-4">
<div class="flex flex-col items-center max-w-md w-full">
<input type="text" id="pickupCodeInput" placeholder="输入6位取件码" maxlength="6"
class="w-full px-4 py-3 border border-gray-300 rounded-lg text-center text-2xl font-mono font-bold uppercase mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500">
<button onclick="joinRoom()" class="bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-lg font-semibold">
🔗 连接并获取文件
</button>
</div>
</div>
</div>
<!-- 文件列表显示 -->
<div id="receiverFilesSection" class="hidden">
<h4 class="font-semibold mb-3">可下载的文件:</h4>
<div id="receiverFilesList" class="space-y-2"></div>
<!-- 接收状态 -->
<div id="receiverStatus" class="mt-4 text-center">
<div class="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800">
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
已连接,可以下载文件
</div>
</div>
<!-- 房间状态显示 (接收方) -->
<div id="receiverRoomStatusSection" class="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h5 class="font-semibold text-blue-800 mb-2">房间状态</h5>
<div id="receiverRoomConnections" class="text-sm text-blue-700">
<div>在线用户: <span id="receiverOnlineCount">0</span></div>
<div>发送方: <span id="receiverSenderCount">0</span></div>
<div>接收方: <span id="receiverReceiverCount">0</span></div>
</div>
<div id="receiverClientsList" class="mt-2 space-y-1"></div>
</div>
</div>
</div>
</div>
<!-- 传输进度 -->
<div id="transferProgress" class="bg-white rounded-xl shadow-lg p-6 hidden">
<h3 class="text-xl font-semibold mb-4">传输进度</h3>
<div id="progressList" class="space-y-3"></div>
<!-- 传输进度 - 浮动卡片设计 -->
<div id="transferProgress" class="hidden">
<div class="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div class="bg-gradient-to-r from-purple-500 to-purple-600 px-6 py-4">
<h3 class="text-xl font-semibold text-white flex items-center">
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
传输进度
</h3>
<p class="text-purple-100 text-sm mt-1">文件传输状态</p>
</div>
<div class="p-6">
<div id="progressList" class="space-y-4"></div>
</div>
</div>
</div>
<!-- 底部提示信息 -->
<div class="mt-8 text-center">
<div class="inline-flex items-center px-4 py-2 bg-white rounded-full shadow-sm border border-gray-200">
<svg class="w-4 h-4 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.707-3.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L9 10.586l7.293-7.293a1 1 0 011.414 0z"></path>
</svg>
<span class="text-sm text-gray-600">点对点传输,安全快速,不经过服务器</span>
</div>
</div>
</div>
</div>
</div>
<!-- 错误边界处理 -->
<script>
// 标签页切换函数
function switchTab(tab) {
// 移除所有标签页的活动状态
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active', 'border-blue-500', 'bg-blue-50', 'text-blue-600', 'border-green-500', 'bg-green-50', 'text-green-600');
btn.classList.add('border-transparent', 'text-gray-600');
});
// 隐藏所有标签页内容
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
content.classList.remove('active');
});
// 激活选中的标签页
if (tab === 'send') {
const sendTab = document.getElementById('sendTab');
const sendContent = document.getElementById('sendContent');
sendTab.classList.remove('border-transparent', 'text-gray-600');
sendTab.classList.add('active', 'border-blue-500', 'bg-blue-50', 'text-blue-600');
sendContent.classList.remove('hidden');
sendContent.classList.add('active');
} else if (tab === 'receive') {
const receiveTab = document.getElementById('receiveTab');
const receiveContent = document.getElementById('receiveContent');
receiveTab.classList.remove('border-transparent', 'text-gray-600');
receiveTab.classList.add('active', 'border-green-500', 'bg-green-50', 'text-green-600');
receiveContent.classList.remove('hidden');
receiveContent.classList.add('active');
}
}
// 隐藏加载屏幕并显示主内容
window.addEventListener('load', () => {
setTimeout(() => {
document.getElementById('loadingScreen').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
document.getElementById('mainContent').classList.add('fade-in-up');
}, 800);
});
// 全局错误处理
window.addEventListener('error', (event) => {
console.error('页面错误:', event.error);
showNotification('页面发生错误,请刷新重试', 'error');
});
// 未处理的Promise拒绝
window.addEventListener('unhandledrejection', (event) => {
console.error('未处理的Promise拒绝:', event.reason);
showNotification('操作失败,请重试', 'error');
event.preventDefault();
});
</script>
{{end}}
{{define "scripts"}}

View File