feat:美化UI,添加桌面共享/文件字传输UI

This commit is contained in:
MatrixSeven
2025-08-01 17:15:55 +08:00
parent 9e59806192
commit 652dbed722
20 changed files with 8255 additions and 755 deletions

144
README.md
View File

@@ -1,33 +1,133 @@
# 川 - P2P文件传输系统
# 传传传 - 跨平台文件传输工具
一个基于WebRTC技术的点对点文件传输系统支持无服务器中转的直接文件传输。
> 简单、快速、安全的点对点文件传输解决方案
## ✨ 功能特性
## ✨ 核心功能
### 🚀 核心功能
- **纯P2P传输**:文件直接在浏览器间传输,无需上传到服务器
- **取件码机制**6位随机取件码简单易用
- **实时传输**支持大文件高速传输64KB分块优化
- **多文件支持**:可同时选择和传输多个文件
- **拖拽上传**:支持文件拖拽选择
- 📁 **文件传输** - 支持多文件同时传输基于WebRTC的P2P技术
- 📝 **文字传输** - 快速分享文本内容,支持大文本传输
- 🖥️ **桌面共享** - 实时屏幕共享功能(开发中)
- 🔗 **URL路由** - 支持直链分享特定功能和模式
### 🛡️ 安全特性
- **端到端加密**WebRTC内置加密数据传输安全
- **临时连接**:传输完成后自动清理连接
- **无文件存储**:服务器不存储任何文件内容
- **房间隔离**:每个取件码对应独立的传输房间
## 🛡️ 安全特性
### 🌐 技术特性
- **WebRTC DataChannel**:高效的浏览器间直连
- **国内STUN优化**使用阿里云、腾讯云等国内STUN服务器
- **断线重连**WebSocket连接自动重连机制
- **传输进度**:实时显示文件传输进度
- **跨平台兼容**支持现代浏览器Chrome、Firefox、Safari、Edge
- **端到端加密** - WebRTC内置加密数据传输安全
- **无文件存储** - 服务器不存储任何文件内容
- **临时连接** - 传输完成后自动清理连接
- **房间隔离** - 每个取件码对应独立的传输房间
## 🏗️ 系统架构
## 🚀 技术栈
**前端架构**
- Next.js 15 + React 18 + TypeScript
- Tailwind CSS + 毛玻璃效果UI
- WebRTC DataChannel + WebSocket
**后端架构**
- Go + Gin框架 + WebSocket
- 内存存储 + 房间管理
- Docker容器化部署
## 📦 快速开始
### 方式一Docker一键部署推荐[未变写完成]
```bash
git clone https://github.com/MatrixSeven/file-transfer-go.git
cd file-transfer-go
docker-compose up -d
# 访问应用
open http://localhost:8080
```
### 方式二:本地开发
```bash
# 1. 启动后端服务
make dev
# 2. 启动前端服务
cd chuan-next
yarn
yarn dev
# 访问应用
open http://localhost:3000
```
## 🎯 URL路由支持
支持通过URL参数直接跳转到特定功能
```bash
# 文件传输
/?type=file&mode=send # 发送文件
/?type=file&mode=receive # 接收文件
# 文字传输
/?type=text&mode=send # 发送文字
/?type=text&mode=receive # 接收文字
# 桌面共享
/?type=desktop&mode=send # 共享桌面
/?type=desktop&mode=receive # 观看桌面
```
## 🌟 项目特色
-**零配置** - 无需注册登录,即开即用
- 🔒 **点对点** - 基于WebRTC的直接传输服务器仅做信令
- 📱 **响应式** - 完美适配手机、平板、电脑
- <20> **现代UI** - 精美的毛玻璃效果,流畅的动画
- 🚀 **高性能** - 64KB分块传输支持大文件高速传输
## 📊 系统架构
```
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
┌─────────────────┐ WebSocket ┌──────────────┐ WebSocket ┌─────────────────┐
│ 发送方 (A) │ ←──────────────→ │ 信令服务器 │ ←──────────────→ │ 接收方 (B) │
│ │ │ │ │ │
│ - 选择文件 │ │ - 房间管理 │ │ - 输入取件码 │
│ - 生成取件码 │ │ - 信令转发 │ │ - 获取文件列表 │
│ - 等待连接 │ │ - 状态同步 │ │ - 下载文件 │
└─────────────────┘ └──────────────┘ └─────────────────┘
│ │
│ WebRTC P2P │
│ ┌─────────────────┐ │
└────────────────────→│ 直接文件传输 │←──────────────────────────────┘
│ │
│ - 端到端加密 │
│ - 高速传输 │
│ - 断点续传 │
└─────────────────┘
```
## 📁 项目结构
```
.
├── cmd/ # Go应用入口
├── internal/ # Go后端核心代码
│ ├── handlers/ # HTTP和WebSocket处理器
│ ├── models/ # 数据模型
│ └── services/ # 业务服务层
├── chuan-next/ # Next.js前端应用
│ ├── src/app/ # 应用页面
│ ├── src/components/ # 组件库
│ └── src/hooks/ # React Hooks
├── web/ # 静态资源(测试页面)
├── docker-compose.yml # Docker部署配置
└── Makefile # 构建脚本
```
## 🤝 贡献指南
欢迎提交Issue和Pull Request来帮助改进项目
## 📄 许可证
MIT License
│ 发送方浏览器 │◄────────┤ 信令服务器 ├────────►│ 接收方浏览器 │
│ │ │ │ │ │
│ ┌───────────┐ │ │ ┌──────────┐ │ │ ┌───────────┐ │

View File

@@ -1,36 +1,65 @@
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:
## ✨ 核心功能
- 📁 **文件传输** - 支持多文件同时传输,断点续传
- 📝 **文字传输** - 快速分享文本内容
- 🖥️ **桌面共享** - 实时屏幕共享(开发中)
- 🔗 **URL路由** - 支持直链分享特定功能
## 🚀 技术栈
- **前端**: Next.js 15 + React 18 + TypeScript + Tailwind CSS
- **后端**: Go + WebSocket + Gin框架
- **部署**: Docker + Docker Compose
## 📦 快速开始
### 本地开发
```bash
# 克隆项目
git clone https://github.com/MatrixSeven/file-transfer-go.git
cd file-transfer-go
# 启动后端服务
make dev
# 启动前端服务
cd chuan-next
npm install
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.
### Docker部署
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```bash
# 一键启动所有服务
docker-compose up -d
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.
# 访问应用
open http://localhost:8080
```
## Learn More
## 🎯 URL参数
To learn more about Next.js, take a look at the following resources:
支持通过URL直接跳转到特定功能
- [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.
```
/?type=file&mode=send # 文件传输-发送
/?type=text&mode=receive # 文字传输-接收
/?type=desktop&mode=send # 桌面共享-共享
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## 🌟 特色
## Deploy on Vercel
-**零配置** - 无需注册登录,即开即用
- 🔒 **端到端** - 点对点传输,服务器不存储文件
- 📱 **响应式** - 完美适配手机、平板、电脑
- 🎨 **现代UI** - 精美的毛玻璃效果界面
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.
MIT License

54
chuan-next/URL_ROUTING.md Normal file
View File

@@ -0,0 +1,54 @@
# URL 路由参数说明
现在您可以通过URL参数直接导航到特定的功能和模式。
## URL 参数格式
```
http://localhost:3000/?type={功能类型}&mode={操作模式}
```
## 支持的参数
### type功能类型
- `file` - 文件传输
- `text` - 文字传输
- `desktop` - 桌面共享
### mode操作模式
- `send` - 发送/共享模式
- `receive` - 接收/观看模式
## 使用示例
### 文件传输
- 发送文件:`/?type=file&mode=send`
- 接收文件:`/?type=file&mode=receive`
### 文字传输
- 发送文字:`/?type=text&mode=send`
- 接收文字:`/?type=text&mode=receive`
### 桌面共享
- 共享桌面:`/?type=desktop&mode=send`
- 观看桌面:`/?type=desktop&mode=receive`
## 功能特性
1. **自动切换**访问带参数的URL会自动切换到对应的功能和模式
2. **URL同步**用户手动切换功能和模式时URL会自动更新
3. **无页面刷新**:所有切换都通过客户端路由实现,无需刷新页面
4. **向后兼容**:不带参数访问时,默认显示文件传输-发送模式
## 实际应用场景
1. **分享链接**:可以直接分享特定功能的链接给其他用户
2. **书签管理**:用户可以为常用功能创建书签
3. **快速访问**:通过预设链接快速跳转到特定功能
4. **集成其他系统**便于其他系统通过URL直接跳转到特定功能
## 注意事项
- 桌面共享功能中,`send`模式对应"共享桌面"`receive`模式对应"观看桌面"
- 如果提供了无效的参数值,系统会回退到默认设置
- URL参数不区分大小写

5849
chuan-next/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,16 @@
"use client";
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { useSearchParams, useRouter } 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 Hero from '@/components/Hero';
import FileTransfer from '@/components/FileTransfer';
import TextTransfer from '@/components/TextTransfer';
import DesktopShare from '@/components/DesktopShare';
import { useWebSocket } from '@/hooks/useWebSocket';
import { FileInfo, TransferProgress, WebSocketMessage, RoomStatus } from '@/types';
import { Upload, Download } from 'lucide-react';
import { Upload, MessageSquare, Monitor } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
interface FileTransferData {
fileId: string;
@@ -22,8 +24,39 @@ interface FileTransferData {
export default function HomePage() {
const searchParams = useSearchParams();
const router = useRouter();
const { websocket, isConnected, connect, disconnect, sendMessage } = useWebSocket();
const { showToast } = useToast();
// URL参数管理
const [activeTab, setActiveTab] = useState<'file' | 'text' | 'desktop'>('file');
// 从URL参数中获取初始状态
useEffect(() => {
const type = searchParams.get('type') as 'file' | 'text' | 'desktop';
const mode = searchParams.get('mode') as 'send' | 'receive';
if (type && ['file', 'text', 'desktop'].includes(type)) {
setActiveTab(type);
}
}, [searchParams]);
// 更新URL参数
const updateUrlParams = useCallback((tab: string, mode?: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set('type', tab);
if (mode) {
params.set('mode', mode);
}
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]);
// 处理tab切换
const handleTabChange = useCallback((value: string) => {
setActiveTab(value as 'file' | 'text' | 'desktop');
updateUrlParams(value);
}, [updateUrlParams]);
// 发送方状态
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [pickupCode, setPickupCode] = useState<string>('');
@@ -40,11 +73,13 @@ export default function HomePage() {
// 文件传输状态
const [fileTransfers, setFileTransfers] = useState<Map<string, FileTransferData>>(new Map());
const [completedDownloads, setCompletedDownloads] = useState<Set<string>>(new Set());
// 显示通知
const showNotification = useCallback((message: string, type: 'success' | 'error' | 'info' = 'success') => {
console.log(`[${type.toUpperCase()}] ${message}`);
}, []);
showToast(message, type);
}, [showToast]);
// 初始化文件传输
const initFileTransfer = useCallback((fileInfo: any) => {
@@ -128,6 +163,12 @@ export default function HomePage() {
const transfer = newMap.get(transferKey);
if (transfer) {
// 检查是否已经完成,如果已经完成就不再处理新的数据块
if (transfer.receivedSize >= transfer.totalSize) {
console.log('文件已完成,忽略额外的数据块');
return newMap;
}
const chunkArray = new Uint8Array(chunkData.data);
transfer.chunks.push({
offset: chunkData.offset,
@@ -135,6 +176,11 @@ export default function HomePage() {
});
transfer.receivedSize += chunkArray.length;
// 确保不超过总大小
if (transfer.receivedSize > transfer.totalSize) {
transfer.receivedSize = transfer.totalSize;
}
const progress = (transfer.receivedSize / transfer.totalSize) * 100;
console.log(`文件 ${transferKey} 进度: ${progress.toFixed(2)}%`);
@@ -155,8 +201,14 @@ export default function HomePage() {
// 检查是否完成
if (chunkData.is_last || transfer.receivedSize >= transfer.totalSize) {
console.log('文件接收完成,开始组装下载');
assembleAndDownloadFile(transferKey, transfer);
console.log('文件接收完成,准备下载');
// 标记为完成,等待 file-complete 消息统一处理下载
setTransferProgresses(prev =>
prev.map(p => p.fileId === transferKey
? { ...p, status: 'completed' as const, progress: 100, receivedSize: transfer.totalSize }
: p
)
);
}
} else {
console.warn('未找到对应的文件传输:', transferKey);
@@ -164,12 +216,36 @@ export default function HomePage() {
return newMap;
});
}, [assembleAndDownloadFile]);
}, []);
// 完成文件下载
const completeFileDownload = useCallback((fileId: string) => {
console.log('文件传输完成:', fileId);
}, []);
console.log('文件传输完成,开始下载:', fileId);
// 检查是否已经完成过下载
if (completedDownloads.has(fileId)) {
console.log('文件已经下载过,跳过重复下载:', fileId);
return;
}
// 标记为已完成
setCompletedDownloads(prev => new Set([...prev, fileId]));
// 查找对应的文件传输数据
const transfer = fileTransfers.get(fileId);
if (transfer) {
assembleAndDownloadFile(fileId, transfer);
// 清理传输进度,移除已完成的文件进度显示
setTimeout(() => {
setTransferProgresses(prev =>
prev.filter(p => p.fileId !== fileId)
);
}, 2000); // 2秒后清理让用户看到完成状态
} else {
console.warn('未找到文件传输数据:', fileId);
}
}, [fileTransfers, assembleAndDownloadFile, completedDownloads]);
// 处理文件请求(发送方)
const handleFileRequest = useCallback(async (payload: any) => {
@@ -345,6 +421,12 @@ export default function HomePage() {
// 加入房间
const handleJoinRoom = useCallback(async (code: string) => {
// 防止重复连接
if (isConnecting || (isConnected && pickupCode === code)) {
console.log('已在连接中或已连接,跳过重复请求');
return;
}
setIsConnecting(true);
try {
@@ -356,7 +438,7 @@ export default function HomePage() {
setCurrentRole('receiver');
setReceiverFiles(data.files || []);
connect(code, 'receiver');
showNotification('连接成功!');
showNotification('连接成功!', 'success');
} else {
showNotification(data.message || '取件码无效或已过期', 'error');
setIsConnecting(false);
@@ -366,16 +448,16 @@ export default function HomePage() {
showNotification('连接失败,请检查网络连接', 'error');
setIsConnecting(false);
}
}, [connect, showNotification]);
}, [connect, showNotification, isConnecting, isConnected, pickupCode]);
// 处理URL参数中的取件码
useEffect(() => {
const code = searchParams.get('code');
if (code && code.length === 6) {
if (code && code.length === 6 && !isConnected && pickupCode !== code.toUpperCase()) {
setCurrentRole('receiver');
handleJoinRoom(code.toUpperCase());
}
}, [searchParams, handleJoinRoom]);
}, [searchParams]); // 移除依赖只在URL变化时触发
// 下载文件
const handleDownloadFile = useCallback((fileId: string) => {
@@ -385,6 +467,14 @@ export default function HomePage() {
return;
}
// 检查是否已有同文件的进行中传输
const existingProgress = transferProgresses.find(p => p.originalFileId === fileId && p.status !== 'completed');
if (existingProgress) {
console.log('文件已在下载中,跳过重复请求:', fileId);
showNotification('文件正在下载中...', 'info');
return;
}
const requestId = 'req_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
console.log('生成请求ID:', requestId);
@@ -412,7 +502,7 @@ export default function HomePage() {
...prev.filter(p => p.originalFileId !== fileId), // 移除该文件的旧进度记录
newProgress
]);
}, [websocket, sendMessage, receiverFiles, showNotification]);
}, [websocket, sendMessage, receiverFiles, showNotification, transferProgresses]);
// 通过WebSocket更新文件列表
const updateFileList = useCallback((files: File[]) => {
@@ -459,114 +549,158 @@ export default function HomePage() {
disconnect();
}, [disconnect]);
// 复制到剪贴板
const copyToClipboard = useCallback(async (text: string, message: string) => {
// 复制到剪贴板
const copyToClipboard = useCallback(async (text: string, successMessage: string) => {
try {
await navigator.clipboard.writeText(text);
showNotification(message);
} catch (error) {
showNotification(successMessage, 'success');
} catch (err) {
console.error('复制失败:', err);
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>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
{/* Hero Section */}
<div className="relative min-h-screen">
{/* Background decorations */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-blue-400/20 to-indigo-600/20 rounded-full blur-3xl"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-br from-purple-400/20 to-pink-400/20 rounded-full blur-3xl"></div>
</div>
<div className="relative container mx-auto px-4 sm:px-6 py-8 max-w-6xl">
<Hero />
<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>
{/* Main Interface */}
<div className="max-w-4xl mx-auto">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-white/80 backdrop-blur-sm border-0 shadow-lg h-12 sm:h-14 p-1 mb-6">
<TabsTrigger
value="file"
className="flex items-center justify-center space-x-2 text-sm sm:text-base font-medium data-[state=active]:bg-gradient-to-r data-[state=active]:from-blue-500 data-[state=active]:to-indigo-500 data-[state=active]:text-white data-[state=active]:shadow-lg transition-all duration-300"
>
<Upload className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger
value="text"
className="flex items-center justify-center space-x-2 text-sm sm:text-base font-medium data-[state=active]:bg-gradient-to-r data-[state=active]:from-emerald-500 data-[state=active]:to-teal-500 data-[state=active]:text-white data-[state=active]:shadow-lg transition-all duration-300"
>
<MessageSquare className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger
value="desktop"
className="flex items-center justify-center space-x-2 text-sm sm:text-base font-medium data-[state=active]:bg-gradient-to-r data-[state=active]:from-purple-500 data-[state=active]:to-pink-500 data-[state=active]:text-white data-[state=active]:shadow-lg transition-all duration-300"
>
<Monitor className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></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}
<TabsContent value="file" className="mt-6 animate-fade-in-up">
<FileTransfer
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}
onJoinRoom={handleJoinRoom}
receiverFiles={receiverFiles}
onDownloadFile={handleDownloadFile}
transferProgresses={transferProgresses}
isConnected={isConnected}
isConnecting={isConnecting}
disabled={isConnecting}
/>
{roomStatus && currentRole === 'sender' && (
<div className="mt-6 glass-card rounded-2xl p-6 animate-fade-in-up">
<h3 className="text-xl font-semibold text-slate-800 mb-4 text-center"></h3>
<div className="grid grid-cols-3 gap-6">
<div className="text-center p-4 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl">
<div className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
{(roomStatus?.sender_count || 0) + (roomStatus?.receiver_count || 0)}
</div>
<div className="text-sm text-muted-foreground">线</div>
<div className="text-sm text-slate-600 mt-1">线</div>
</div>
<div>
<div className="text-2xl font-bold text-blue-600">
{roomStatus.sender_count}
<div className="text-center p-4 bg-gradient-to-br from-emerald-50 to-teal-50 rounded-xl">
<div className="text-3xl font-bold text-emerald-600">
{roomStatus?.sender_count || 0}
</div>
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-slate-600 mt-1"></div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">
{roomStatus.receiver_count}
<div className="text-center p-4 bg-gradient-to-br from-purple-50 to-pink-50 rounded-xl">
<div className="text-3xl font-bold text-purple-600">
{roomStatus?.receiver_count || 0}
</div>
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-slate-600 mt-1"></div>
</div>
</div>
</CardContent>
</Card>
)}
</TabsContent>
</div>
)}
</TabsContent>
<TabsContent value="receive" className="mt-6">
<FileReceive
onJoinRoom={handleJoinRoom}
files={receiverFiles}
onDownloadFile={handleDownloadFile}
transferProgresses={transferProgresses}
isConnected={isConnected}
isConnecting={isConnecting}
/>
</TabsContent>
</Tabs>
<TabsContent value="text" className="mt-6 animate-fade-in-up">
<TextTransfer
onSendText={async (text: string) => {
// TODO: 实现文字传输功能
showNotification('文字传输功能开发中', 'info');
return 'ABC123'; // 模拟返回取件码
}}
onReceiveText={async (code: string) => {
// TODO: 实现文字接收功能
showNotification('文字传输功能开发中', 'info');
return '示例文本内容'; // 模拟返回文本
}}
/>
</TabsContent>
<TabsContent value="desktop" className="mt-6 animate-fade-in-up">
<DesktopShare
onStartSharing={async () => {
// TODO: 实现桌面共享功能
showNotification('桌面共享功能开发中', 'info');
return 'DEF456'; // 模拟返回连接码
}}
onStopSharing={async () => {
showNotification('桌面共享已停止', 'info');
}}
onJoinSharing={async (code: string) => {
// TODO: 实现桌面查看功能
showNotification('桌面共享功能开发中', 'info');
}}
/>
</TabsContent>
</Tabs>
</div>
{/* Bottom spacing */}
<div className="h-8 sm:h-16"></div>
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@ function HomePageWrapper() {
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">...</div>}>
<HomePage />
</Suspense>
);
}

View File

@@ -2,47 +2,47 @@
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 225 15% 20%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 225 15% 20%;
--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;
--popover-foreground: 225 15% 20%;
--primary: 262 83% 58%;
--primary-foreground: 0 0% 100%;
--secondary: 220 14% 96%;
--secondary-foreground: 225 15% 20%;
--muted: 220 14% 96%;
--muted-foreground: 215 13% 55%;
--accent: 210 40% 94%;
--accent-foreground: 225 15% 20%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 262 83% 58%;
--radius: 0.75rem;
}
.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%;
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--popover: 224 71% 4%;
--popover-foreground: 213 31% 91%;
--primary: 263 70% 50%;
--primary-foreground: 213 31% 91%;
--secondary: 215 28% 17%;
--secondary-foreground: 213 31% 91%;
--muted: 215 28% 17%;
--muted-foreground: 217 11% 65%;
--accent: 215 28% 17%;
--accent-foreground: 213 31% 91%;
--destructive: 0 63% 31%;
--destructive-foreground: 213 31% 91%;
--border: 215 28% 17%;
--input: 215 28% 17%;
--ring: 263 70% 50%;
}
@theme inline {
@@ -72,10 +72,124 @@
* {
border-color: hsl(var(--border));
box-sizing: border-box;
}
html {
height: 100%;
overflow-x: hidden;
}
body {
background: hsl(var(--background));
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
color: hsl(var(--foreground));
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow-x: hidden;
min-height: 100vh;
margin: 0;
padding: 0;
}
/* 现代化组件样式 */
.hero-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.glass-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.upload-area {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
border: 2px dashed rgba(102, 126, 234, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.upload-area:hover {
border-color: rgba(102, 126, 234, 0.6);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
transform: translateY(-2px);
}
.upload-area.drag-active {
border-color: hsl(var(--primary));
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
transform: scale(1.02);
}
.button-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.button-primary:hover {
transform: translateY(-1px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
}
.code-display {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
border: 1px solid #cbd5e1;
}
/* 动画效果 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.6s ease-out;
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out;
}
.animate-slide-in-down {
animation: slideInDown 0.3s ease-out;
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ToastProvider } from "@/components/ui/toast-simple";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "文件快传",
description: "安全、快速、简单的文件传输服务",
};
export default function RootLayout({
@@ -23,11 +24,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="zh-CN">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<ToastProvider>
{children}
</ToastProvider>
</body>
</html>
);

View File

@@ -0,0 +1,269 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Share, Monitor, Copy, Play, Square } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
interface DesktopShareProps {
onStartSharing?: () => Promise<string>; // 返回连接码
onStopSharing?: () => Promise<void>;
onJoinSharing?: (code: string) => Promise<void>;
}
export default function DesktopShare({ onStartSharing, onStopSharing, onJoinSharing }: DesktopShareProps) {
const searchParams = useSearchParams();
const router = useRouter();
const [mode, setMode] = useState<'share' | 'view'>('share');
const [connectionCode, setConnectionCode] = useState('');
const [inputCode, setInputCode] = useState('');
const [isSharing, setIsSharing] = useState(false);
const [isViewing, setIsViewing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { showToast } = useToast();
// 从URL参数中获取初始模式
useEffect(() => {
const urlMode = searchParams.get('mode');
const type = searchParams.get('type');
if (type === 'desktop' && urlMode) {
// 将send映射为sharereceive映射为view
if (urlMode === 'send') {
setMode('share');
} else if (urlMode === 'receive') {
setMode('view');
}
}
}, [searchParams]);
// 更新URL参数
const updateMode = useCallback((newMode: 'share' | 'view') => {
setMode(newMode);
const params = new URLSearchParams(searchParams.toString());
params.set('type', 'desktop');
// 将share映射为sendview映射为receive以保持一致性
params.set('mode', newMode === 'share' ? 'send' : 'receive');
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]);
const handleStartSharing = useCallback(async () => {
if (!onStartSharing) return;
setIsLoading(true);
try {
const code = await onStartSharing();
setConnectionCode(code);
setIsSharing(true);
showToast('桌面共享已开始!', 'success');
} catch (error) {
console.error('开始共享失败:', error);
showToast('开始共享失败,请重试', 'error');
} finally {
setIsLoading(false);
}
}, [onStartSharing, showToast]);
const handleStopSharing = useCallback(async () => {
if (!onStopSharing) return;
setIsLoading(true);
try {
await onStopSharing();
setIsSharing(false);
setConnectionCode('');
showToast('桌面共享已停止', 'success');
} catch (error) {
console.error('停止共享失败:', error);
showToast('停止共享失败', 'error');
} finally {
setIsLoading(false);
}
}, [onStopSharing, showToast]);
const handleJoinSharing = useCallback(async () => {
if (!inputCode.trim() || !onJoinSharing) return;
setIsLoading(true);
try {
await onJoinSharing(inputCode);
setIsViewing(true);
showToast('已连接到桌面共享!', 'success');
} catch (error) {
console.error('连接失败:', error);
showToast('连接失败,请检查连接码', 'error');
} finally {
setIsLoading(false);
}
}, [inputCode, onJoinSharing, showToast]);
const copyToClipboard = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showToast('已复制到剪贴板!', 'success');
} catch (err) {
showToast('复制失败', 'error');
}
}, [showToast]);
return (
<div className="space-y-4 sm:space-y-6">
{/* 模式切换 */}
<div className="flex justify-center mb-6">
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
<Button
variant={mode === 'share' ? 'default' : 'ghost'}
onClick={() => updateMode('share')}
className="px-6 py-2 rounded-lg"
>
<Share className="w-4 h-4 mr-2" />
</Button>
<Button
variant={mode === 'view' ? 'default' : 'ghost'}
onClick={() => updateMode('view')}
className="px-6 py-2 rounded-lg"
>
<Monitor className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{mode === 'share' ? (
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
<div className="text-center mb-6">
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-purple-500 to-pink-500 rounded-2xl flex items-center justify-center animate-float">
<Share className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
</div>
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2"></h2>
<p className="text-sm sm:text-base text-slate-600">
{isSharing ? '桌面共享进行中' : '开始共享您的桌面屏幕'}
</p>
</div>
<div className="space-y-4">
{!isSharing ? (
<Button
onClick={handleStartSharing}
disabled={isLoading}
className="w-full h-12 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Play className="w-5 h-5 mr-2" />
</>
)}
</Button>
) : (
<div className="space-y-4">
<div className="p-4 bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl border border-purple-200">
<div className="text-center">
<p className="text-sm text-purple-700 mb-2"></p>
<div className="text-2xl font-bold font-mono text-purple-600 mb-3">{connectionCode}</div>
<Button
onClick={() => copyToClipboard(connectionCode)}
size="sm"
className="bg-purple-500 hover:bg-purple-600 text-white"
>
<Copy className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<Button
onClick={handleStopSharing}
disabled={isLoading}
className="w-full h-12 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Square className="w-5 h-5 mr-2" />
</>
)}
</Button>
</div>
)}
</div>
</div>
) : (
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
<div className="text-center mb-6">
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-2xl flex items-center justify-center animate-float">
<Monitor className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
</div>
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2"></h2>
<p className="text-sm sm:text-base text-slate-600">
{isViewing ? '正在观看桌面共享' : '输入连接码观看他人的桌面'}
</p>
</div>
<div className="space-y-4">
{!isViewing ? (
<>
<Input
value={inputCode}
onChange={(e) => setInputCode(e.target.value.toUpperCase().slice(0, 6))}
placeholder="请输入连接码"
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-indigo-500 focus:ring-indigo-500 bg-white/80 backdrop-blur-sm"
maxLength={6}
disabled={isLoading}
/>
<Button
onClick={handleJoinSharing}
disabled={inputCode.length !== 6 || isLoading}
className="w-full h-12 bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Monitor className="w-5 h-5 mr-2" />
</>
)}
</Button>
</>
) : (
<div className="space-y-4">
<div className="aspect-video bg-slate-900 rounded-xl flex items-center justify-center text-white">
<div className="text-center">
<Monitor className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm opacity-75"></p>
</div>
</div>
<Button
onClick={() => setIsViewing(false)}
className="w-full h-12 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
<Square className="w-5 h-5 mr-2" />
</Button>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -18,11 +18,11 @@ interface FileReceiveProps {
}
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" />;
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5 text-white" />;
if (mimeType.startsWith('video/')) return <Video className="w-5 h-5 text-white" />;
if (mimeType.startsWith('audio/')) return <Music className="w-5 h-5 text-white" />;
if (mimeType.includes('zip') || mimeType.includes('rar')) return <Archive className="w-5 h-5 text-white" />;
return <FileText className="w-5 h-5 text-white" />;
};
const formatFileSize = (bytes: number): string => {
@@ -60,110 +60,163 @@ export function FileReceive({
// 如果已经连接并且有文件列表,显示文件列表
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">
<div className="space-y-4 sm:space-y-6">
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 sm:mb-6 gap-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg sm:text-xl font-semibold text-slate-800"></h3>
<p className="text-slate-500 text-sm">
{isConnected ? (
<span className="text-emerald-600"> </span>
) : (
<span className="text-amber-600"> ...</span>
)}
</p>
</div>
</div>
<div className="bg-gradient-to-r from-emerald-100 to-teal-100 px-3 sm:px-4 py-2 rounded-full self-start sm:self-center">
<span className="text-emerald-700 font-medium text-sm">{files.length} </span>
</div>
</div>
<div className="space-y-3 sm:space-y-4">
{files.map((file) => {
const progress = transferProgresses.find(p => p.originalFileId === file.id);
const isDownloading = progress && progress.status === 'downloading';
console.log(`文件 ${file.id} 进度状态:`, progress, '是否下载中:', isDownloading);
const isCompleted = progress && progress.status === 'completed';
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">
<div key={file.id} className="bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl p-3 sm:p-4 hover:shadow-md transition-all duration-200">
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-3 gap-3">
<div className="flex items-center space-x-3 sm:space-x-4 flex-1 min-w-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-lg flex items-center justify-center flex-shrink-0">
{getFileIcon(file.type)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{file.name}</p>
<p className="text-sm text-muted-foreground">
{formatFileSize(file.size)}
</p>
<p className="font-medium text-slate-800 truncate text-sm sm:text-base">{file.name}</p>
<p className="text-sm text-slate-500">{formatFileSize(file.size)}</p>
{isCompleted && (
<p className="text-xs text-emerald-600 font-medium"> </p>
)}
</div>
</div>
<Button
onClick={() => onDownloadFile(file.id)}
disabled={!isConnected || isDownloading}
size="sm"
disabled={!isConnected || isDownloading || isCompleted}
className={`px-6 py-2 rounded-lg font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${
isCompleted
? 'bg-slate-300 text-slate-500 cursor-not-allowed'
: 'bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white'
}`}
>
<Download className="w-4 h-4 mr-2" />
{isDownloading ? '下载中...' : '下载'}
{isDownloading ? '下载中...' : isCompleted ? '已完成' : '下载'}
</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>
{progress && (progress.status === 'downloading' || progress.status === 'completed') && (
<div className="mt-3 space-y-2">
<div className="flex justify-between text-sm text-slate-600">
<span>{progress.status === 'completed' ? '下载完成' : '正在下载...'}</span>
<span className="font-medium">{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">
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
progress.status === 'completed'
? 'bg-gradient-to-r from-emerald-500 to-emerald-600'
: 'bg-gradient-to-r from-emerald-500 to-teal-500'
}`}
style={{ width: `${progress.progress}%` }}
></div>
</div>
<div className="flex justify-between text-xs text-slate-500">
<span>{formatFileSize(progress.receivedSize)} / {formatFileSize(progress.totalSize)}</span>
{progress.status === 'downloading' && (
<span> {Math.ceil((progress.totalSize - progress.receivedSize) / 1024 / 1024)} MB</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>
</div>
</div>
);
}
// 显示取件码输入界面
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
6
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<div className="glass-card rounded-2xl p-4 sm:p-6 md:p-8 animate-fade-in-up">
<div className="text-center mb-6 sm:mb-8">
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-2xl flex items-center justify-center animate-float">
<Download className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
</div>
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2"></h2>
<p className="text-sm sm:text-base text-slate-600">6</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
<div className="space-y-3">
<div className="relative">
<Input
value={pickupCode}
onChange={handleInputChange}
placeholder="输入6位取件码"
className="text-center text-2xl tracking-wider font-mono"
placeholder="输入取件码"
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-emerald-500 focus:ring-emerald-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
maxLength={6}
disabled={isConnecting}
/>
<p className="text-xs text-muted-foreground text-center">
{pickupCode.length}/6
</p>
<div className="absolute inset-x-0 -bottom-4 sm:-bottom-6 flex justify-center space-x-1 sm:space-x-2">
{[...Array(6)].map((_, i) => (
<div
key={i}
className={`w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full transition-all duration-200 ${
i < pickupCode.length
? 'bg-emerald-500'
: 'bg-slate-300'
}`}
/>
))}
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={pickupCode.length !== 6 || isConnecting}
>
{isConnecting ? '连接中...' : '连接'}
</Button>
</form>
</CardContent>
</Card>
<div className="h-3 sm:h-4"></div>
<p className="text-center text-xs sm:text-sm text-slate-500">
{pickupCode.length}/6
</p>
</div>
<Button
type="submit"
className="w-full h-10 sm:h-12 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-base sm:text-lg font-medium rounded-xl shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
disabled={pickupCode.length !== 6 || isConnecting}
>
{isConnecting ? (
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<Download className="w-5 h-5" />
<span></span>
</div>
)}
</Button>
</form>
{/* 使用提示 */}
<div className="mt-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200">
<p className="text-sm text-slate-600 text-center">
💡 <span className="font-medium"></span>24
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Upload, Download } from 'lucide-react';
import FileUpload from '@/components/FileUpload';
import { FileReceive } from '@/components/FileReceive';
import { FileInfo, TransferProgress } from '@/types';
interface FileTransferProps {
// 发送方相关
selectedFiles: File[];
onFilesChange: (files: File[]) => void;
onGenerateCode: () => void;
pickupCode: string;
pickupLink: string;
onCopyCode: () => void;
onCopyLink: () => void;
onAddMoreFiles: () => void;
onRemoveFile: (updatedFiles: File[]) => void;
onReset: () => void;
// 接收方相关
onJoinRoom: (code: string) => void;
receiverFiles: FileInfo[];
onDownloadFile: (fileId: string) => void;
transferProgresses: TransferProgress[];
// 通用状态
isConnected: boolean;
isConnecting: boolean;
disabled?: boolean;
}
export default function FileTransfer({
selectedFiles,
onFilesChange,
onGenerateCode,
pickupCode,
pickupLink,
onCopyCode,
onCopyLink,
onAddMoreFiles,
onRemoveFile,
onReset,
onJoinRoom,
receiverFiles,
onDownloadFile,
transferProgresses,
isConnected,
isConnecting,
disabled = false
}: FileTransferProps) {
const searchParams = useSearchParams();
const router = useRouter();
const [mode, setMode] = useState<'send' | 'receive'>('send');
// 从URL参数中获取初始模式
useEffect(() => {
const urlMode = searchParams.get('mode') as 'send' | 'receive';
const type = searchParams.get('type');
if (type === 'file' && urlMode && ['send', 'receive'].includes(urlMode)) {
setMode(urlMode);
}
}, [searchParams]);
// 更新URL参数
const updateMode = useCallback((newMode: 'send' | 'receive') => {
setMode(newMode);
const params = new URLSearchParams(searchParams.toString());
params.set('type', 'file');
params.set('mode', newMode);
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]);
return (
<div className="space-y-4 sm:space-y-6">
{/* 模式切换 */}
<div className="flex justify-center mb-6">
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
<Button
variant={mode === 'send' ? 'default' : 'ghost'}
onClick={() => updateMode('send')}
className="px-6 py-2 rounded-lg"
>
<Upload className="w-4 h-4 mr-2" />
</Button>
<Button
variant={mode === 'receive' ? 'default' : 'ghost'}
onClick={() => updateMode('receive')}
className="px-6 py-2 rounded-lg"
>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{mode === 'send' ? (
<div className="animate-fade-in-up">
<FileUpload
selectedFiles={selectedFiles}
onFilesChange={onFilesChange}
onGenerateCode={onGenerateCode}
pickupCode={pickupCode}
pickupLink={pickupLink}
onCopyCode={onCopyCode}
onCopyLink={onCopyLink}
onAddMoreFiles={onAddMoreFiles}
onRemoveFile={onRemoveFile}
onReset={onReset}
disabled={disabled}
/>
</div>
) : (
<div className="animate-fade-in-up">
<FileReceive
onJoinRoom={onJoinRoom}
files={receiverFiles}
onDownloadFile={onDownloadFile}
transferProgresses={transferProgresses}
isConnected={isConnected}
isConnecting={isConnecting}
/>
</div>
)}
</div>
);
}

View File

@@ -20,11 +20,11 @@ interface FileUploadProps {
}
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" />;
if (mimeType.startsWith('image/')) return <Image className="w-5 h-5 text-white" />;
if (mimeType.startsWith('video/')) return <Video className="w-5 h-5 text-white" />;
if (mimeType.startsWith('audio/')) return <Music className="w-5 h-5 text-white" />;
if (mimeType.includes('zip') || mimeType.includes('rar')) return <Archive className="w-5 h-5 text-white" />;
return <FileText className="w-5 h-5 text-white" />;
};
const formatFileSize = (bytes: number): string => {
@@ -95,72 +95,84 @@ export default function FileUpload({
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 className="glass-card rounded-2xl p-8 animate-fade-in-up">
<div className="text-center mb-6">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-2xl flex items-center justify-center animate-float">
<Upload className="w-8 h-8 text-white" />
</div>
</CardContent>
</Card>
<h2 className="text-2xl font-semibold text-slate-800 mb-2"></h2>
<p className="text-slate-600"></p>
</div>
<div
className={`upload-area rounded-xl p-6 sm:p-8 md:p-12 text-center cursor-pointer ${
isDragOver ? 'drag-active' : ''
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<div className={`transition-all duration-300 ${isDragOver ? 'scale-110' : ''}`}>
<div className="w-16 h-16 sm:w-20 sm:h-20 mx-auto mb-4 sm:mb-6 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
<Upload className={`w-8 h-8 sm:w-10 sm:h-10 transition-colors duration-300 ${
isDragOver ? 'text-blue-600' : 'text-slate-400'
}`} />
</div>
<div className="space-y-2">
<p className="text-lg sm:text-xl font-medium text-slate-700">
{isDragOver ? '释放文件' : '拖拽文件到这里'}
</p>
<p className="text-sm sm:text-base text-slate-500">
<span className="text-blue-600 font-medium underline"></span>
</p>
<p className="text-xs sm:text-sm text-slate-400 mt-4">
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={disabled}
/>
</div>
</div>
);
}
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">
<div className="space-y-4 sm:space-y-6">
{/* 文件列表 */}
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 sm:mb-6 gap-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg sm:text-xl font-semibold text-slate-800"></h3>
<p className="text-slate-500 text-sm">{selectedFiles.length} </p>
</div>
</div>
</div>
<div className="space-y-3 mb-4 sm:mb-6">
{selectedFiles.map((file, index) => (
<div
key={`${file.name}-${file.size}-${index}`}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
className="group flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl hover:shadow-md transition-all duration-200"
>
<div className="flex items-center space-x-3">
{getFileIcon(file.type)}
<div>
<p className="font-medium">{file.name}</p>
<p className="text-sm text-muted-foreground">
{formatFileSize(file.size)}
</p>
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-lg flex items-center justify-center flex-shrink-0">
{getFileIcon(file.type)}
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-slate-800 truncate text-sm sm:text-base">{file.name}</p>
<p className="text-xs sm:text-sm text-slate-500">{formatFileSize(file.size)}</p>
</div>
</div>
<Button
@@ -168,99 +180,127 @@ export default function FileUpload({
size="sm"
onClick={() => removeFile(index)}
disabled={disabled}
className="text-destructive hover:text-destructive"
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all duration-200 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</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 && (
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row gap-3">
{!pickupCode && (
<>
<Button
variant="outline"
onClick={onAddMoreFiles}
disabled={disabled}
className="flex-1"
onClick={onGenerateCode}
disabled={disabled || selectedFiles.length === 0}
className="button-primary text-white px-6 sm:px-8 py-3 rounded-xl font-medium flex-1 min-w-0 shadow-lg"
>
<Upload className="w-5 h-5 mr-2" />
</Button>
)}
<Button
onClick={onAddMoreFiles}
variant="outline"
disabled={disabled}
className="px-6 sm:px-8 py-3 rounded-xl font-medium"
>
</Button>
<Button
onClick={onReset}
variant="outline"
disabled={disabled}
className="text-red-600 hover:bg-red-50 px-6 sm:px-8 py-3 rounded-xl font-medium"
>
</Button>
</>
)}
{pickupCode && (
<Button
variant="outline"
onClick={onReset}
onClick={onAddMoreFiles}
disabled={disabled}
className="px-6 py-3 rounded-xl border-slate-300 text-slate-600 hover:bg-slate-50 flex-1"
>
</Button>
</div>
</CardContent>
</Card>
)}
<Button
variant="outline"
onClick={onReset}
disabled={disabled}
className="px-6 py-3 rounded-xl border-slate-300 text-slate-600 hover:bg-slate-50"
>
</Button>
</div>
</div>
{/* 取件码展示 */}
{pickupCode && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="glass-card rounded-2xl p-4 sm:p-6 md:p-8 animate-fade-in-up">
<div className="text-center mb-4 sm:mb-6">
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-2xl flex items-center justify-center animate-float">
<FileText className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
</div>
<h3 className="text-xl sm:text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent mb-2">
</h3>
<p className="text-sm sm:text-base text-slate-600"></p>
</div>
<div className="space-y-4 sm:space-y-6">
{/* 取件码 */}
<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}
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1 code-display rounded-xl p-4 sm:p-6 text-center">
<div className="text-2xl sm:text-3xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
{pickupCode}
</div>
</div>
<Button
variant="outline"
onClick={onCopyCode}
size="sm"
className="px-4 sm:px-6 py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-medium shadow-lg transition-all duration-200 hover:shadow-xl w-full sm:w-auto"
>
</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}
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1 code-display rounded-xl p-3 sm:p-4">
<div className="text-xs sm:text-sm text-slate-700 break-all font-mono">
{pickupLink}
</div>
</div>
<Button
variant="outline"
onClick={onCopyLink}
size="sm"
className="px-4 sm:px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-xl font-medium shadow-lg transition-all duration-200 hover:shadow-xl w-full sm:w-auto"
>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* 使用提示 */}
<div className="mt-4 sm:mt-6 p-3 sm:p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200">
<p className="text-xs sm:text-sm text-slate-600 text-center">
💡 <span className="font-medium">使</span>访
</p>
</div>
</div>
)}
</div>
);

View File

@@ -0,0 +1,18 @@
"use client";
import React from 'react';
export default function Hero() {
return (
<div className="text-center mb-8 sm:mb-12 animate-fade-in-up">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent mb-4">
</h1>
<p className="text-base sm:text-lg text-slate-600 max-w-2xl mx-auto leading-relaxed px-4">
<br />
<span className="text-sm sm:text-base text-slate-500"> - </span>
</p>
</div>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { MessageSquare, Copy, Send, Download } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
interface TextTransferProps {
onSendText?: (text: string) => Promise<string>; // 返回取件码
onReceiveText?: (code: string) => Promise<string>; // 返回文本内容
}
export default function TextTransfer({ onSendText, onReceiveText }: TextTransferProps) {
const searchParams = useSearchParams();
const router = useRouter();
const [mode, setMode] = useState<'send' | 'receive'>('send');
const [textContent, setTextContent] = useState('');
const [pickupCode, setPickupCode] = useState('');
const [receivedText, setReceivedText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { showToast } = useToast();
// 从URL参数中获取初始模式
useEffect(() => {
const urlMode = searchParams.get('mode') as 'send' | 'receive';
const type = searchParams.get('type');
if (type === 'text' && urlMode && ['send', 'receive'].includes(urlMode)) {
setMode(urlMode);
}
}, [searchParams]);
// 更新URL参数
const updateMode = useCallback((newMode: 'send' | 'receive') => {
setMode(newMode);
const params = new URLSearchParams(searchParams.toString());
params.set('type', 'text');
params.set('mode', newMode);
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]);
const handleSendText = useCallback(async () => {
if (!textContent.trim() || !onSendText) return;
setIsLoading(true);
try {
const code = await onSendText(textContent);
setPickupCode(code);
showToast('文本已生成取件码!', 'success');
} catch (error) {
console.error('发送文本失败:', error);
showToast('发送失败,请重试', 'error');
} finally {
setIsLoading(false);
}
}, [textContent, onSendText, showToast]);
const handleReceiveText = useCallback(async () => {
if (!pickupCode.trim() || !onReceiveText) return;
setIsLoading(true);
try {
const text = await onReceiveText(pickupCode);
setReceivedText(text);
showToast('文本接收成功!', 'success');
} catch (error) {
console.error('接收文本失败:', error);
showToast('接收失败,请检查取件码', 'error');
} finally {
setIsLoading(false);
}
}, [pickupCode, onReceiveText, showToast]);
const copyToClipboard = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showToast('已复制到剪贴板!', 'success');
} catch (err) {
showToast('复制失败', 'error');
}
}, [showToast]);
return (
<div className="space-y-4 sm:space-y-6">
{/* 模式切换 */}
<div className="flex justify-center mb-6">
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
<Button
variant={mode === 'send' ? 'default' : 'ghost'}
onClick={() => updateMode('send')}
className="px-6 py-2 rounded-lg"
>
<Send className="w-4 h-4 mr-2" />
</Button>
<Button
variant={mode === 'receive' ? 'default' : 'ghost'}
onClick={() => updateMode('receive')}
className="px-6 py-2 rounded-lg"
>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{mode === 'send' ? (
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
<div className="text-center mb-6">
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-2xl flex items-center justify-center animate-float">
<MessageSquare className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
</div>
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2"></h2>
<p className="text-sm sm:text-base text-slate-600"></p>
</div>
<div className="space-y-4">
<textarea
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
placeholder="在这里输入要传输的文本内容..."
className="w-full min-h-[200px] p-4 border-2 border-slate-200 rounded-xl focus:border-blue-500 focus:ring-blue-500 bg-white/80 backdrop-blur-sm resize-none"
disabled={isLoading}
/>
<div className="flex justify-between text-sm text-slate-500">
<span>{textContent.length} </span>
<span> 10,000 </span>
</div>
<Button
onClick={handleSendText}
disabled={!textContent.trim() || textContent.length > 10000 || isLoading}
className="w-full h-12 bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Send className="w-5 h-5 mr-2" />
</>
)}
</Button>
{pickupCode && (
<div className="mt-6 p-4 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-xl border border-emerald-200">
<div className="text-center">
<p className="text-sm text-emerald-700 mb-2"></p>
<div className="text-2xl font-bold font-mono text-emerald-600 mb-3">{pickupCode}</div>
<Button
onClick={() => copyToClipboard(pickupCode)}
size="sm"
className="bg-emerald-500 hover:bg-emerald-600 text-white"
>
<Copy className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
)}
</div>
</div>
) : (
<div className="glass-card rounded-2xl p-4 sm:p-6 animate-fade-in-up">
<div className="text-center mb-6">
<div className="w-12 h-12 sm:w-16 sm:h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-2xl flex items-center justify-center animate-float">
<Download className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
</div>
<h2 className="text-xl sm:text-2xl font-semibold text-slate-800 mb-2"></h2>
<p className="text-sm sm:text-base text-slate-600">6</p>
</div>
<div className="space-y-4">
<Input
value={pickupCode}
onChange={(e) => setPickupCode(e.target.value.toUpperCase().slice(0, 6))}
placeholder="请输入取件码"
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-emerald-500 focus:ring-emerald-500 bg-white/80 backdrop-blur-sm"
maxLength={6}
disabled={isLoading}
/>
<Button
onClick={handleReceiveText}
disabled={pickupCode.length !== 6 || isLoading}
className="w-full h-12 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Download className="w-5 h-5 mr-2" />
</>
)}
</Button>
{receivedText && (
<div className="mt-6 space-y-4">
<textarea
value={receivedText}
readOnly
className="w-full min-h-[200px] p-4 border-2 border-emerald-200 rounded-xl bg-emerald-50/50 backdrop-blur-sm resize-none"
/>
<Button
onClick={() => copyToClipboard(receivedText)}
className="w-full h-12 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
<Copy className="w-5 h-5 mr-2" />
</Button>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,93 @@
"use client";
import React, { createContext, useContext, useState, useCallback } from 'react';
interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
interface ToastContextType {
showToast: (message: string, type?: 'success' | 'error' | 'info') => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: 'success' | 'error' | 'info' = 'success') => {
const id = Date.now().toString();
const newToast = { id, message, type };
setToasts(prev => [...prev, newToast]);
// 自动移除toast
setTimeout(() => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, 3000);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast 容器 */}
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
className={`
max-w-sm p-4 rounded-xl shadow-lg backdrop-blur-sm transform transition-all duration-300 ease-in-out
${toast.type === 'success' ? 'bg-emerald-50/90 border border-emerald-200 text-emerald-800' : ''}
${toast.type === 'error' ? 'bg-red-50/90 border border-red-200 text-red-800' : ''}
${toast.type === 'info' ? 'bg-blue-50/90 border border-blue-200 text-blue-800' : ''}
animate-slide-in-down
`}
onClick={() => removeToast(toast.id)}
>
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
{toast.type === 'success' && (
<div className="w-6 h-6 bg-emerald-500 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
{toast.type === 'error' && (
<div className="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
)}
{toast.type === 'info' && (
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
<p className="text-sm font-medium">{toast.message}</p>
</div>
</div>
))}
</div>
</ToastContext.Provider>
);
};

View File

@@ -0,0 +1,131 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
success:
"border-emerald-200 bg-emerald-50 text-emerald-900",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/hooks/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,193 @@
"use client"
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

File diff suppressed because it is too large Load Diff