mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-04 03:25:03 +08:00
UI-React refactor
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
41
chuan-next/.gitignore
vendored
Normal 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
36
chuan-next/README.md
Normal 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.
|
||||
17
chuan-next/components.json
Normal file
17
chuan-next/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
16
chuan-next/eslint.config.mjs
Normal file
16
chuan-next/eslint.config.mjs
Normal 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;
|
||||
7
chuan-next/next.config.ts
Normal file
7
chuan-next/next.config.ts
Normal 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
40
chuan-next/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
chuan-next/postcss.config.mjs
Normal file
5
chuan-next/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
chuan-next/public/file.svg
Normal file
1
chuan-next/public/file.svg
Normal 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 |
1
chuan-next/public/globe.svg
Normal file
1
chuan-next/public/globe.svg
Normal 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 |
1
chuan-next/public/next.svg
Normal file
1
chuan-next/public/next.svg
Normal 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 |
1
chuan-next/public/vercel.svg
Normal file
1
chuan-next/public/vercel.svg
Normal 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 |
1
chuan-next/public/window.svg
Normal file
1
chuan-next/public/window.svg
Normal 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 |
574
chuan-next/src/app/HomePage-new.tsx
Normal file
574
chuan-next/src/app/HomePage-new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
chuan-next/src/app/HomePageWrapper.tsx
Normal file
14
chuan-next/src/app/HomePageWrapper.tsx
Normal 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;
|
||||
28
chuan-next/src/app/api/create-room/route.ts
Normal file
28
chuan-next/src/app/api/create-room/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
35
chuan-next/src/app/api/room-info/route.ts
Normal file
35
chuan-next/src/app/api/room-info/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
35
chuan-next/src/app/api/room-status/route.ts
Normal file
35
chuan-next/src/app/api/room-status/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
chuan-next/src/app/api/update-files/route.ts
Normal file
38
chuan-next/src/app/api/update-files/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
chuan-next/src/app/favicon.ico
Normal file
BIN
chuan-next/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
81
chuan-next/src/app/globals.css
Normal file
81
chuan-next/src/app/globals.css
Normal 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;
|
||||
}
|
||||
34
chuan-next/src/app/layout.tsx
Normal file
34
chuan-next/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
chuan-next/src/app/page-new.tsx
Normal file
3
chuan-next/src/app/page-new.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import HomePageWrapper from './HomePageWrapper';
|
||||
|
||||
export default HomePageWrapper;
|
||||
3
chuan-next/src/app/page.tsx
Normal file
3
chuan-next/src/app/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import HomePageWrapper from './HomePageWrapper';
|
||||
|
||||
export default HomePageWrapper;
|
||||
169
chuan-next/src/components/FileReceive.tsx
Normal file
169
chuan-next/src/components/FileReceive.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
chuan-next/src/components/FileUpload.tsx
Normal file
267
chuan-next/src/components/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
chuan-next/src/components/ui/button.tsx
Normal file
56
chuan-next/src/components/ui/button.tsx
Normal 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 }
|
||||
79
chuan-next/src/components/ui/card.tsx
Normal file
79
chuan-next/src/components/ui/card.tsx
Normal 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 }
|
||||
26
chuan-next/src/components/ui/input.tsx
Normal file
26
chuan-next/src/components/ui/input.tsx
Normal 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 }
|
||||
28
chuan-next/src/components/ui/progress.tsx
Normal file
28
chuan-next/src/components/ui/progress.tsx
Normal 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 }
|
||||
55
chuan-next/src/components/ui/tabs.tsx
Normal file
55
chuan-next/src/components/ui/tabs.tsx
Normal 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 }
|
||||
119
chuan-next/src/hooks/useWebSocket-new.ts
Normal file
119
chuan-next/src/hooks/useWebSocket-new.ts
Normal 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
|
||||
};
|
||||
}
|
||||
125
chuan-next/src/hooks/useWebSocket.ts
Normal file
125
chuan-next/src/hooks/useWebSocket.ts
Normal 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
|
||||
};
|
||||
}
|
||||
6
chuan-next/src/lib/utils.ts
Normal file
6
chuan-next/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
51
chuan-next/src/types/index.ts
Normal file
51
chuan-next/src/types/index.ts
Normal 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
27
chuan-next/tsconfig.json
Normal 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
3346
chuan-next/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
0
web/templates/index_clean.html
Normal file
0
web/templates/index_clean.html
Normal file
Reference in New Issue
Block a user