feat: 完成navbar隐藏优化和侧边栏冻结功能
- 优化navbar滚动隐藏逻辑,更敏感响应 - 添加返回顶部按钮,固定在右下角 - 实现profile页面侧边栏真正冻结效果 - 修复首页滑动指示器位置 - 优化整体布局确保首屏内容完整显示
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
943
API文档.md
Normal file
943
API文档.md
Normal file
@@ -0,0 +1,943 @@
|
|||||||
|
# CarrotSkin 后端 API 文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档总结了 CarrotSkin 后端 API,主要关注前端需要的接口,不包括 Yggdrasil 相关接口(除了更换 Yggdrasil 密码)。
|
||||||
|
|
||||||
|
## 基础信息
|
||||||
|
|
||||||
|
- **基础URL**: `/api/v1`
|
||||||
|
- **认证方式**: JWT Bearer Token
|
||||||
|
- **数据格式**: JSON
|
||||||
|
- **字符编码**: UTF-8
|
||||||
|
|
||||||
|
## 通用响应格式
|
||||||
|
|
||||||
|
所有API响应都遵循以下格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
// 具体数据内容
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
分页响应格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"list": [],
|
||||||
|
"total": 100,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total_pages": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 认证相关 API
|
||||||
|
|
||||||
|
### 1. 用户注册
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/auth/register`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "newuser", // 用户名,3-50字符
|
||||||
|
"email": "user@example.com", // 邮箱地址
|
||||||
|
"password": "password123", // 密码,6-128字符
|
||||||
|
"verification_code": "123456", // 邮箱验证码,6位数字
|
||||||
|
"avatar": "https://example.com/avatar.png" // 可选,头像URL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "注册成功",
|
||||||
|
"data": {
|
||||||
|
"token": "jwt_token_here",
|
||||||
|
"user_info": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "newuser",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"avatar": "https://example.com/avatar.png",
|
||||||
|
"points": 0,
|
||||||
|
"role": "user",
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 用户登录
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/auth/login`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "testuser", // 用户名或邮箱
|
||||||
|
"password": "password123" // 密码
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "登录成功",
|
||||||
|
"data": {
|
||||||
|
"token": "jwt_token_here",
|
||||||
|
"user_info": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"avatar": "https://example.com/avatar.png",
|
||||||
|
"points": 100,
|
||||||
|
"role": "user",
|
||||||
|
"status": 1,
|
||||||
|
"last_login_at": "2025-10-01T12:00:00Z",
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 发送验证码
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/auth/send-code`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com", // 邮箱地址
|
||||||
|
"type": "register" // 类型: register/reset_password/change_email
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "验证码已发送,请查收邮件",
|
||||||
|
"data": {
|
||||||
|
"message": "验证码已发送,请查收邮件"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 重置密码
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/auth/reset-password`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com", // 邮箱地址
|
||||||
|
"verification_code": "123456", // 邮箱验证码
|
||||||
|
"new_password": "newpassword123" // 新密码
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "密码重置成功",
|
||||||
|
"data": {
|
||||||
|
"message": "密码重置成功"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 用户相关 API
|
||||||
|
|
||||||
|
### 1. 获取用户信息
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/user/profile`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**: 无
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"avatar": "https://example.com/avatar.png",
|
||||||
|
"points": 100,
|
||||||
|
"role": "user",
|
||||||
|
"status": 1,
|
||||||
|
"last_login_at": "2025-10-01T12:00:00Z",
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 更新用户信息
|
||||||
|
|
||||||
|
- **URL**: `PUT /api/v1/user/profile`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"avatar": "https://example.com/new-avatar.png", // 可选,新头像URL
|
||||||
|
"old_password": "oldpassword123", // 可选,修改密码时需要
|
||||||
|
"new_password": "newpassword123" // 可选,新密码
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"avatar": "https://example.com/new-avatar.png",
|
||||||
|
"points": 100,
|
||||||
|
"role": "user",
|
||||||
|
"status": 1,
|
||||||
|
"last_login_at": "2025-10-01T12:00:00Z",
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 生成头像上传URL
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/user/avatar/upload-url`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_name": "avatar.png" // 文件名
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"post_url": "https://rustfs.example.com/avatars",
|
||||||
|
"form_data": {
|
||||||
|
"key": "user_1/xxx.png",
|
||||||
|
"policy": "base64_policy",
|
||||||
|
"x-amz-signature": "signature"
|
||||||
|
},
|
||||||
|
"avatar_url": "https://rustfs.example.com/avatars/user_1/xxx.png",
|
||||||
|
"expires_in": 900
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 更新头像URL
|
||||||
|
|
||||||
|
- **URL**: `PUT /api/v1/user/avatar`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
- Query参数: `avatar_url` - 头像URL
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"avatar": "https://example.com/new-avatar.png",
|
||||||
|
"points": 100,
|
||||||
|
"role": "user",
|
||||||
|
"status": 1,
|
||||||
|
"last_login_at": "2025-10-01T12:00:00Z",
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 更换邮箱
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/user/change-email`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"new_email": "newemail@example.com", // 新邮箱地址
|
||||||
|
"verification_code": "123456" // 邮箱验证码
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "newemail@example.com",
|
||||||
|
"avatar": "https://example.com/avatar.png",
|
||||||
|
"points": 100,
|
||||||
|
"role": "user",
|
||||||
|
"status": 1,
|
||||||
|
"last_login_at": "2025-10-01T12:00:00Z",
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 重置Yggdrasil密码
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/user/yggdrasil-password/reset`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**: 无
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"password": "new_yggdrasil_password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 材质相关 API
|
||||||
|
|
||||||
|
### 1. 搜索材质
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/texture`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**:
|
||||||
|
- Query参数:
|
||||||
|
- `keyword`: 搜索关键词
|
||||||
|
- `type`: 材质类型 (SKIN/CAPE)
|
||||||
|
- `public_only`: 是否只搜索公开材质 (true/false)
|
||||||
|
- `page`: 页码,默认1
|
||||||
|
- `page_size`: 每页数量,默认20
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"uploader_id": 1,
|
||||||
|
"name": "My Skin",
|
||||||
|
"description": "A cool skin",
|
||||||
|
"type": "SKIN",
|
||||||
|
"url": "https://rustfs.example.com/textures/xxx.png",
|
||||||
|
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"size": 2048,
|
||||||
|
"is_public": true,
|
||||||
|
"download_count": 100,
|
||||||
|
"favorite_count": 50,
|
||||||
|
"is_slim": false,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 100,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total_pages": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 获取材质详情
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/texture/{id}`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**:
|
||||||
|
- 路径参数: `id` - 材质ID
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"uploader_id": 1,
|
||||||
|
"name": "My Skin",
|
||||||
|
"description": "A cool skin",
|
||||||
|
"type": "SKIN",
|
||||||
|
"url": "https://rustfs.example.com/textures/xxx.png",
|
||||||
|
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"size": 2048,
|
||||||
|
"is_public": true,
|
||||||
|
"download_count": 100,
|
||||||
|
"favorite_count": 50,
|
||||||
|
"is_slim": false,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 直接上传材质文件(推荐)
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/texture/upload`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **Content-Type**: `multipart/form-data`
|
||||||
|
- **请求参数**:
|
||||||
|
- `file`: 材质文件(PNG格式,1KB-10MB)
|
||||||
|
- `name`: 材质名称(必填,1-100字符)
|
||||||
|
- `description`: 材质描述(可选,最多500字符)
|
||||||
|
- `type`: 材质类型(可选,默认SKIN,可选值:SKIN/CAPE)
|
||||||
|
- `is_public`: 是否公开(可选,默认false,true/false)
|
||||||
|
- `is_slim`: 是否为细臂模型(可选,默认false,true/false)
|
||||||
|
- **说明**:
|
||||||
|
- 后端会自动计算文件的SHA256哈希值
|
||||||
|
- 如果已存在相同哈希的材质,会复用已存在的文件URL,不重复上传
|
||||||
|
- 允许多次上传相同哈希的材质(包括同一用户),每次都会创建新的数据库记录
|
||||||
|
- 文件存储路径格式:`{type}/{hash[:2]}/{hash[2:4]}/{hash}.png`
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"uploader_id": 1,
|
||||||
|
"name": "My Cool Skin",
|
||||||
|
"description": "A very cool skin",
|
||||||
|
"type": "SKIN",
|
||||||
|
"url": "https://rustfs.example.com/textures/skin/e3/b0/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.png",
|
||||||
|
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"size": 2048,
|
||||||
|
"is_public": true,
|
||||||
|
"download_count": 0,
|
||||||
|
"favorite_count": 0,
|
||||||
|
"is_slim": false,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 生成材质上传URL(兼容接口)
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/texture/upload-url`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_name": "skin.png", // 文件名
|
||||||
|
"texture_type": "SKIN" // 材质类型: SKIN/CAPE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"post_url": "https://rustfs.example.com/textures",
|
||||||
|
"form_data": {
|
||||||
|
"key": "user_1/skin/xxx.png",
|
||||||
|
"policy": "base64_policy",
|
||||||
|
"x-amz-signature": "signature"
|
||||||
|
},
|
||||||
|
"texture_url": "https://rustfs.example.com/textures/user_1/skin/xxx.png",
|
||||||
|
"expires_in": 900
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 创建材质记录(配合预签名URL使用)
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/texture`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "My Cool Skin", // 材质名称,1-100字符
|
||||||
|
"description": "A very cool skin", // 描述,最多500字符
|
||||||
|
"type": "SKIN", // 材质类型: SKIN/CAPE
|
||||||
|
"url": "https://rustfs.example.com/textures/user_1/skin/xxx.png", // 材质URL
|
||||||
|
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", // SHA256哈希
|
||||||
|
"size": 2048, // 文件大小(字节)
|
||||||
|
"is_public": true, // 是否公开
|
||||||
|
"is_slim": false // 是否为细臂模型(Alex)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"uploader_id": 1,
|
||||||
|
"name": "My Cool Skin",
|
||||||
|
"description": "A very cool skin",
|
||||||
|
"type": "SKIN",
|
||||||
|
"url": "https://rustfs.example.com/textures/user_1/skin/xxx.png",
|
||||||
|
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"size": 2048,
|
||||||
|
"is_public": true,
|
||||||
|
"download_count": 0,
|
||||||
|
"favorite_count": 0,
|
||||||
|
"is_slim": false,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 更新材质
|
||||||
|
|
||||||
|
- **URL**: `PUT /api/v1/texture/{id}`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
- 路径参数: `id` - 材质ID
|
||||||
|
- 请求体:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Updated Skin Name", // 可选,新名称
|
||||||
|
"description": "Updated description", // 可选,新描述
|
||||||
|
"is_public": false // 可选,是否公开
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"uploader_id": 1,
|
||||||
|
"name": "Updated Skin Name",
|
||||||
|
"description": "Updated description",
|
||||||
|
"type": "SKIN",
|
||||||
|
"url": "https://rustfs.example.com/textures/user_1/skin/xxx.png",
|
||||||
|
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"size": 2048,
|
||||||
|
"is_public": false,
|
||||||
|
"download_count": 100,
|
||||||
|
"favorite_count": 50,
|
||||||
|
"is_slim": false,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 删除材质
|
||||||
|
|
||||||
|
- **URL**: `DELETE /api/v1/texture/{id}`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
- 路径参数: `id` - 材质ID
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 切换收藏状态
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/texture/{id}/favorite`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
- 路径参数: `id` - 材质ID
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"is_favorited": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. 获取用户上传的材质列表
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/texture/my`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
- Query参数:
|
||||||
|
- `page`: 页码,默认1
|
||||||
|
- `page_size`: 每页数量,默认20
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"uploader_id": 1,
|
||||||
|
"name": "My Skin",
|
||||||
|
"description": "A cool skin",
|
||||||
|
"type": "SKIN",
|
||||||
|
"url": "https://rustfs.example.com/textures/xxx.png",
|
||||||
|
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"size": 2048,
|
||||||
|
"is_public": true,
|
||||||
|
"download_count": 100,
|
||||||
|
"favorite_count": 50,
|
||||||
|
"is_slim": false,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 50,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total_pages": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. 获取用户收藏的材质列表
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/texture/favorites`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
- Query参数:
|
||||||
|
- `page`: 页码,默认1
|
||||||
|
- `page_size`: 每页数量,默认20
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"uploader_id": 2,
|
||||||
|
"name": "Cool Skin",
|
||||||
|
"description": "A very cool skin",
|
||||||
|
"type": "SKIN",
|
||||||
|
"url": "https://rustfs.example.com/textures/xxx.png",
|
||||||
|
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"size": 2048,
|
||||||
|
"is_public": true,
|
||||||
|
"download_count": 100,
|
||||||
|
"favorite_count": 50,
|
||||||
|
"is_slim": false,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 30,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total_pages": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 档案相关 API
|
||||||
|
|
||||||
|
### 1. 创建档案
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/profile`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "PlayerName" // 角色名,1-16字符
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"user_id": 1,
|
||||||
|
"name": "PlayerName",
|
||||||
|
"skin_id": null,
|
||||||
|
"cape_id": null,
|
||||||
|
"is_active": false,
|
||||||
|
"last_used_at": null,
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 获取档案列表
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/profile`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**: 无
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"user_id": 1,
|
||||||
|
"name": "PlayerName",
|
||||||
|
"skin_id": 1,
|
||||||
|
"cape_id": 2,
|
||||||
|
"is_active": true,
|
||||||
|
"last_used_at": "2025-10-01T12:00:00Z",
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 获取档案详情
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/profile/{uuid}`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**:
|
||||||
|
- 路径参数: `uuid` - 档案UUID
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"user_id": 1,
|
||||||
|
"name": "PlayerName",
|
||||||
|
"skin_id": 1,
|
||||||
|
"cape_id": 2,
|
||||||
|
"is_active": true,
|
||||||
|
"last_used_at": "2025-10-01T12:00:00Z",
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 更新档案
|
||||||
|
|
||||||
|
- **URL**: `PUT /api/v1/profile/{uuid}`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
- 路径参数: `uuid` - 档案UUID
|
||||||
|
- 请求体:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "NewPlayerName", // 可选,新角色名
|
||||||
|
"skin_id": 1, // 可选,皮肤ID
|
||||||
|
"cape_id": 2 // 可选,披风ID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"user_id": 1,
|
||||||
|
"name": "NewPlayerName",
|
||||||
|
"skin_id": 1,
|
||||||
|
"cape_id": 2,
|
||||||
|
"is_active": true,
|
||||||
|
"last_used_at": "2025-10-01T12:00:00Z",
|
||||||
|
"created_at": "2025-10-01T10:00:00Z",
|
||||||
|
"updated_at": "2025-10-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 删除档案
|
||||||
|
|
||||||
|
- **URL**: `DELETE /api/v1/profile/{uuid}`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
- 路径参数: `uuid` - 档案UUID
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"message": "删除成功"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 设置活跃档案
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/profile/{uuid}/activate`
|
||||||
|
- **认证**: 需要JWT认证
|
||||||
|
- **请求参数**:
|
||||||
|
- 路径参数: `uuid` - 档案UUID
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"message": "设置成功"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证码相关 API
|
||||||
|
|
||||||
|
### 1. 生成验证码
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/captcha/generate`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**: 无
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"masterImage": "base64_encoded_master_image",
|
||||||
|
"tileImage": "base64_encoded_tile_image",
|
||||||
|
"captchaId": "captcha_id_here",
|
||||||
|
"y": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 验证验证码
|
||||||
|
|
||||||
|
- **URL**: `POST /api/v1/captcha/verify`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"captchaId": "captcha_id_here",
|
||||||
|
"dx": 150 // 滑动距离
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "验证成功"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 系统相关 API
|
||||||
|
|
||||||
|
### 1. 获取系统配置
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/system/config`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**: 无
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"site_name": "CarrotSkin",
|
||||||
|
"site_description": "A Minecraft Skin Station",
|
||||||
|
"registration_enabled": true,
|
||||||
|
"max_textures_per_user": 100,
|
||||||
|
"max_profiles_per_user": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CustomSkin API
|
||||||
|
|
||||||
|
### 1. 获取玩家信息
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/csl/{username}`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**:
|
||||||
|
- 路径参数: `username` - 玩家用户名
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "PlayerName",
|
||||||
|
"textures": {
|
||||||
|
"default": "skin_hash_here",
|
||||||
|
"slim": "skin_hash_here",
|
||||||
|
"cape": "cape_hash_here",
|
||||||
|
"elytra": "cape_hash_here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
或简化格式:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "PlayerName",
|
||||||
|
"skin": "skin_hash_here"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 获取资源文件
|
||||||
|
|
||||||
|
- **URL**: `GET /api/v1/csl/textures/{hash}`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**:
|
||||||
|
- 路径参数: `hash` - 资源哈希值
|
||||||
|
- **响应数据**: 二进制文件内容
|
||||||
|
|
||||||
|
## 健康检查
|
||||||
|
|
||||||
|
### 1. 健康检查
|
||||||
|
|
||||||
|
- **URL**: `GET /health`
|
||||||
|
- **认证**: 无需认证
|
||||||
|
- **请求参数**: 无
|
||||||
|
- **响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误码说明
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 200 | 操作成功 |
|
||||||
|
| 400 | 请求参数错误 |
|
||||||
|
| 401 | 未认证或认证失败 |
|
||||||
|
| 403 | 无权限操作 |
|
||||||
|
| 404 | 资源不存在 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
## 认证说明
|
||||||
|
|
||||||
|
需要JWT认证的API需要在请求头中添加:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer {jwt_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
JWT Token在用户登录或注册成功后返回,有效期内可用于访问需要认证的API。
|
||||||
131
README.md
131
README.md
@@ -1,36 +1,125 @@
|
|||||||
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).
|
# 🥕 CarrotSkin
|
||||||
|
|
||||||
## Getting Started
|
新一代现代化Minecraft Yggdrasil皮肤站,为创作者打造的专业皮肤管理平台。
|
||||||
|
|
||||||
First, run the development server:
|
## ✨ 项目特色
|
||||||
|
|
||||||
|
### 🎨 现代化设计
|
||||||
|
- **清新橘色主题**:温暖的橙色渐变配色方案,营造舒适的视觉体验
|
||||||
|
- **玻璃态效果**:使用backdrop-filter实现现代化的毛玻璃效果
|
||||||
|
- **流畅动画**:基于Framer Motion的丝滑动画和交互效果
|
||||||
|
- **响应式布局**:完美适配桌面端、平板和移动设备
|
||||||
|
|
||||||
|
### 🔧 技术栈
|
||||||
|
- **Next.js 16**:最新的React框架,支持App Router
|
||||||
|
- **TypeScript**:类型安全的开发体验
|
||||||
|
- **Tailwind CSS v4**:现代化的CSS框架,支持自定义主题
|
||||||
|
- **Framer Motion**:专业级动画库
|
||||||
|
- **Heroicons**:精美的图标系统
|
||||||
|
|
||||||
|
### 🚀 核心功能
|
||||||
|
|
||||||
|
#### Yggdrasil API支持
|
||||||
|
- 完整的Minecraft Yggdrasil认证系统
|
||||||
|
- 安全可靠的API接口
|
||||||
|
- 支持第三方启动器集成
|
||||||
|
|
||||||
|
#### 皮肤管理
|
||||||
|
- 无限皮肤存储空间
|
||||||
|
- 3D皮肤预览功能
|
||||||
|
- 多角色管理系统
|
||||||
|
- 皮肤版本控制
|
||||||
|
|
||||||
|
#### 社区功能
|
||||||
|
- 皮肤分享和发现
|
||||||
|
- 用户关注和互动
|
||||||
|
- 皮肤收藏和点赞
|
||||||
|
- 评论和评价系统
|
||||||
|
|
||||||
|
#### 现代化体验
|
||||||
|
- 拖拽上传皮肤
|
||||||
|
- 实时预览效果
|
||||||
|
- 批量管理操作
|
||||||
|
- 智能搜索筛选
|
||||||
|
|
||||||
|
## 🎯 页面结构
|
||||||
|
|
||||||
|
### 主页 (/)
|
||||||
|
- 现代化英雄区域展示
|
||||||
|
- Yggdrasil特色功能介绍
|
||||||
|
- 统计数据展示
|
||||||
|
- 行动召唤区域
|
||||||
|
|
||||||
|
### 皮肤库 (/skins)
|
||||||
|
- 网格化皮肤展示
|
||||||
|
- 高级搜索和筛选
|
||||||
|
- 分类标签系统
|
||||||
|
- 排序和分页功能
|
||||||
|
|
||||||
|
### 个人中心 (/profile)
|
||||||
|
- 用户信息管理
|
||||||
|
- 角色和皮肤管理
|
||||||
|
- 账户设置
|
||||||
|
- API密钥管理
|
||||||
|
|
||||||
|
## 🛠️ 开发环境
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm install
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
### 启动开发服务器
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
### 构建生产版本
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
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
|
### 即将推出
|
||||||
|
- 3D皮肤编辑器
|
||||||
|
- 皮肤模板系统
|
||||||
|
- 高级统计分析
|
||||||
|
- 移动端APP
|
||||||
|
|
||||||
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.
|
### 长期目标
|
||||||
|
- AI皮肤生成
|
||||||
|
- 皮肤交易市场
|
||||||
|
- 创作者激励计划
|
||||||
|
- 国际化支持
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
欢迎提交Issue和Pull Request来帮助我们改进项目!
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License - 详见 [LICENSE](LICENSE) 文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**CarrotSkin** - 让Minecraft皮肤管理变得简单而优雅 🥕
|
||||||
|
|||||||
1586
package-lock.json
generated
1586
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -9,9 +9,20 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
"@headlessui/react": "^2.2.9",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@prisma/client": "^7.1.0",
|
||||||
|
"@types/three": "^0.181.0",
|
||||||
|
"framer-motion": "^12.23.25",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.7",
|
"next": "16.0.7",
|
||||||
|
"next-auth": "^4.24.13",
|
||||||
|
"prisma": "^7.1.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0",
|
||||||
|
"skinview3d": "^3.4.1",
|
||||||
|
"three": "^0.181.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
const config = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
'@tailwindcss/postcss': {},
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
14
prisma/schema.prisma
Normal file
14
prisma/schema.prisma
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||||
|
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
11
src/app/auth/layout.tsx
Normal file
11
src/app/auth/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
732
src/app/auth/page.tsx
Normal file
732
src/app/auth/page.tsx
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { EyeIcon, EyeSlashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { errorManager } from '@/components/ErrorNotification';
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
const [isLoginMode, setIsLoginMode] = useState(true);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
verificationCode: '',
|
||||||
|
rememberMe: false,
|
||||||
|
agreeToTerms: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [authError, setAuthError] = useState('');
|
||||||
|
const [isSendingCode, setIsSendingCode] = useState(false);
|
||||||
|
const [codeTimer, setCodeTimer] = useState(0);
|
||||||
|
|
||||||
|
const { login, register } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
if (codeTimer > 0) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setCodeTimer(prev => prev - 1);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [codeTimer]);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? checked : value
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({ ...prev, [name]: '' }));
|
||||||
|
}
|
||||||
|
if (authError) {
|
||||||
|
setAuthError('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateLoginForm = () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!formData.username.trim()) {
|
||||||
|
newErrors.username = '请输入用户名或邮箱';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
newErrors.password = '请输入密码';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateRegisterForm = () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!formData.username.trim()) {
|
||||||
|
newErrors.username = '用户名不能为空';
|
||||||
|
} else if (formData.username.length < 3) {
|
||||||
|
newErrors.username = '用户名至少需要3个字符';
|
||||||
|
} else if (formData.username.length > 50) {
|
||||||
|
newErrors.username = '用户名不能超过50个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.email.trim()) {
|
||||||
|
newErrors.email = '邮箱不能为空';
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
|
newErrors.email = '请输入有效的邮箱地址';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
newErrors.password = '密码不能为空';
|
||||||
|
} else if (formData.password.length < 6) {
|
||||||
|
newErrors.password = '密码至少需要6个字符';
|
||||||
|
} else if (formData.password.length > 128) {
|
||||||
|
newErrors.password = '密码不能超过128个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
newErrors.confirmPassword = '两次输入的密码不一致';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.verificationCode) {
|
||||||
|
newErrors.verificationCode = '请输入验证码';
|
||||||
|
} else if (!/^\d{6}$/.test(formData.verificationCode)) {
|
||||||
|
newErrors.verificationCode = '验证码应为6位数字';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.agreeToTerms) {
|
||||||
|
newErrors.agreeToTerms = '请同意服务条款';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPasswordStrength = () => {
|
||||||
|
const password = formData.password;
|
||||||
|
if (password.length === 0) return { strength: 0, label: '', color: 'bg-gray-200' };
|
||||||
|
if (password.length < 6) return { strength: 1, label: '弱', color: 'bg-red-500' };
|
||||||
|
if (password.length < 10) return { strength: 2, label: '中等', color: 'bg-yellow-500' };
|
||||||
|
if (password.length >= 15) return { strength: 4, label: '很强', color: 'bg-green-500' };
|
||||||
|
return { strength: 3, label: '强', color: 'bg-blue-500' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrength = getPasswordStrength();
|
||||||
|
|
||||||
|
const handleSendCode = async () => {
|
||||||
|
if (!formData.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
|
setErrors(prev => ({ ...prev, email: '请输入有效的邮箱地址' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSendingCode(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/send-code', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.email,
|
||||||
|
type: 'register'
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.code === 200) {
|
||||||
|
setCodeTimer(60);
|
||||||
|
errorManager.showSuccess('验证码已发送到您的邮箱');
|
||||||
|
} else {
|
||||||
|
setErrors(prev => ({ ...prev, email: data.message || '发送验证码失败' }));
|
||||||
|
errorManager.showError(data.message || '发送验证码失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setErrors(prev => ({ ...prev, email: '发送验证码失败,请稍后重试' }));
|
||||||
|
errorManager.showError('发送验证码失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setIsSendingCode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isLoginMode) {
|
||||||
|
if (!validateLoginForm()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setAuthError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(formData.username, formData.password);
|
||||||
|
|
||||||
|
if (formData.rememberMe) {
|
||||||
|
localStorage.setItem('rememberMe', 'true');
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('rememberMe');
|
||||||
|
}
|
||||||
|
|
||||||
|
errorManager.showSuccess('登录成功!');
|
||||||
|
router.push('/');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '登录失败,请检查用户名和密码';
|
||||||
|
setAuthError(errorMessage);
|
||||||
|
errorManager.showError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!validateRegisterForm()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setAuthError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(formData.username, formData.email, formData.password, formData.verificationCode);
|
||||||
|
errorManager.showSuccess('注册成功!欢迎加入CarrotSkin!');
|
||||||
|
router.push('/');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '注册失败,请稍后重试';
|
||||||
|
setAuthError(errorMessage);
|
||||||
|
errorManager.showError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchMode = () => {
|
||||||
|
setIsLoginMode(!isLoginMode);
|
||||||
|
setAuthError('');
|
||||||
|
setErrors({});
|
||||||
|
setFormData({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
verificationCode: '',
|
||||||
|
rememberMe: false,
|
||||||
|
agreeToTerms: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
{/* Left Side - Orange Section */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-orange-500 via-orange-400 to-amber-500 flex-col justify-center items-center text-white p-12">
|
||||||
|
<motion.div
|
||||||
|
className="max-w-md"
|
||||||
|
initial={{ opacity: 0, x: -50 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="w-24 h-24 bg-white/20 rounded-2xl flex items-center justify-center mb-8 backdrop-blur-sm border border-white/30"
|
||||||
|
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300 }}
|
||||||
|
>
|
||||||
|
<span className="text-4xl font-black">CS</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
className="text-5xl font-black mb-6 leading-tight"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
{isLoginMode ? '欢迎回来' : '加入我们'}
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
className="text-2xl mb-12 text-white/90 leading-relaxed"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
{isLoginMode ? '继续你的创作之旅' : '开始你的创作之旅'}
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="space-y-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.4, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-8 h-8 bg-white/25 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-5 h-5" 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>
|
||||||
|
<span className="text-xl font-medium">创建独特的皮肤设计</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-8 h-8 bg-white/25 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-5 h-5" 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>
|
||||||
|
<span className="text-xl font-medium">与社区分享作品</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-8 h-8 bg-white/25 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-5 h-5" 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>
|
||||||
|
<span className="text-xl font-medium">发现精彩创意</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="mt-16"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.6, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<p className="text-lg text-white/80">
|
||||||
|
已有超过 <span className="font-bold text-white text-2xl">10,000</span> 位创作者加入我们
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - White Section */}
|
||||||
|
<div className="w-full lg:w-1/2 bg-white dark:bg-gray-900 flex flex-col justify-center items-center p-4 lg:p-8">
|
||||||
|
<motion.div
|
||||||
|
className="w-full max-w-md"
|
||||||
|
initial={{ opacity: 0, x: 50 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
{/* Back to Home Button */}
|
||||||
|
<motion.div
|
||||||
|
className="mb-6"
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center text-gray-500 dark:text-gray-400 hover:text-orange-500 dark:hover:text-orange-400 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
返回主页
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
className="mb-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-4xl font-black text-gray-900 dark:text-white mb-2">
|
||||||
|
{isLoginMode ? '登录账户' : '创建账户'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||||
|
{isLoginMode ? '登录您的CarrotSkin账户' : '加入我们,开始创作'}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{authError && (
|
||||||
|
<motion.div
|
||||||
|
className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl"
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-sm">{authError}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Username */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{isLoginMode ? '用户名或邮箱' : '用户名'}
|
||||||
|
{!isLoginMode && ' (3-50个字符)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent focus:bg-white dark:focus:bg-gray-700 ${
|
||||||
|
errors.username ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
placeholder={isLoginMode ? "请输入用户名或邮箱" : "请输入用户名"}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{!isLoginMode && formData.username && !errors.username && (
|
||||||
|
<CheckCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-green-500" />
|
||||||
|
)}
|
||||||
|
{errors.username && (
|
||||||
|
<XCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.username && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errors.username}</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Email - Only for registration */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isLoginMode && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
邮箱地址
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent focus:bg-white dark:focus:bg-gray-700 ${
|
||||||
|
errors.email ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
placeholder="请输入邮箱地址"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{formData.email && !errors.email && (
|
||||||
|
<CheckCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-green-500" />
|
||||||
|
)}
|
||||||
|
{errors.email && (
|
||||||
|
<XCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
密码
|
||||||
|
{!isLoginMode && ' (6-128个字符)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent pr-12 focus:bg-white dark:focus:bg-gray-700 ${
|
||||||
|
errors.password ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeSlashIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!isLoginMode && formData.password && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">密码强度</span>
|
||||||
|
<span className={`font-medium ${passwordStrength.color.replace('bg-', 'text-')}`}>
|
||||||
|
{passwordStrength.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${passwordStrength.color}`}
|
||||||
|
style={{ width: `${passwordStrength.strength * 25}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Confirm Password - Only for registration */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isLoginMode && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
确认密码
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
name="confirmPassword"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent pr-12 focus:bg-white dark:focus:bg-gray-700 ${
|
||||||
|
errors.confirmPassword ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <EyeSlashIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{formData.confirmPassword && formData.password === formData.confirmPassword && !errors.confirmPassword && (
|
||||||
|
<CheckCircleIcon className="mt-1 w-5 h-5 text-green-500" />
|
||||||
|
)}
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errors.confirmPassword}</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Verification Code - Only for registration */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isLoginMode && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
邮箱验证码
|
||||||
|
</label>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="verificationCode"
|
||||||
|
value={formData.verificationCode}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent focus:bg-white dark:focus:bg-gray-700 ${
|
||||||
|
errors.verificationCode ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
placeholder="请输入6位验证码"
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
{errors.verificationCode && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errors.verificationCode}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSendCode}
|
||||||
|
disabled={isSendingCode || codeTimer > 0 || !formData.email || !!errors.email}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 disabled:from-gray-400 disabled:to-gray-500 text-white font-medium rounded-xl transition-all duration-200 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{codeTimer > 0 ? `${codeTimer}秒后重试` : (isSendingCode ? '发送中...' : '发送验证码')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Remember Me - Only for login */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isLoginMode && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="rememberMe"
|
||||||
|
checked={formData.rememberMe}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-4 h-4 text-orange-500 border-2 border-gray-300 rounded focus:ring-orange-500 focus:ring-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
记住我
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Link href="/forgot-password" className="text-sm text-orange-500 hover:text-orange-600 transition-colors">
|
||||||
|
忘记密码?
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Terms Agreement - Only for registration */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isLoginMode && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<label className="flex items-start space-x-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="agreeToTerms"
|
||||||
|
checked={formData.agreeToTerms}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="mt-1 w-4 h-4 text-orange-500 border-2 border-gray-300 rounded focus:ring-orange-500 focus:ring-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
我已阅读并同意
|
||||||
|
<Link href="/terms" className="text-orange-500 hover:text-orange-600 underline ml-1">
|
||||||
|
服务条款
|
||||||
|
</Link>
|
||||||
|
和
|
||||||
|
<Link href="/privacy" className="text-orange-500 hover:text-orange-600 underline ml-1">
|
||||||
|
隐私政策
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{errors.agreeToTerms && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errors.agreeToTerms}</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
>
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 disabled:from-gray-400 disabled:to-gray-500 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>{isLoginMode ? '登录中...' : '注册中...'}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
isLoginMode ? '登录' : '创建账户'
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Social Login */}
|
||||||
|
<motion.div
|
||||||
|
className="mt-8"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white dark:bg-gray-900 text-gray-500 dark:text-gray-400">
|
||||||
|
或者
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-center px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-all duration-200 font-medium text-gray-700 dark:text-gray-300 text-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-center px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-all duration-200 font-medium text-gray-700 dark:text-gray-300 text-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M11.5 2.5H9.5V8H11.5V2.5Z" />
|
||||||
|
<path d="M11.5 12H9.5V17.5H11.5V12Z" />
|
||||||
|
<path d="M6 7H2V17H6V7Z" />
|
||||||
|
<path d="M18 7H14V17H18V7Z" />
|
||||||
|
</svg>
|
||||||
|
Microsoft
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Mode Switch */}
|
||||||
|
<motion.div
|
||||||
|
className="text-center mt-6"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.7 }}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{isLoginMode ? '还没有账户?' : '已有账户?'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={switchMode}
|
||||||
|
className="text-orange-500 hover:text-orange-600 font-bold ml-1 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{isLoginMode ? '立即注册' : '立即登录'}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
--navbar-height: 64px; /* 与pt-16对应 */
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -20,7 +18,90 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
background: var(--background);
|
||||||
|
font-family: 'Inter', Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom utility classes */
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom component classes */
|
||||||
|
.btn-carrot {
|
||||||
|
background-color: #f97316;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-carrot:hover {
|
||||||
|
background-color: #ea580c;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-carrot-outline {
|
||||||
|
border: 2px solid #f97316;
|
||||||
|
color: #f97316;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-carrot-outline:hover {
|
||||||
|
background-color: #f97316;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-minecraft {
|
||||||
|
background-color: white;
|
||||||
|
border: 2px solid #fed7aa;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-minecraft:hover {
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.card-minecraft {
|
||||||
|
background-color: #1f2937;
|
||||||
|
border-color: #c2410c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(to right, #fb923c, #f97316);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-carrot {
|
||||||
|
background: linear-gradient(to bottom right, #fb923c, #f97316, #ea580c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 现代布局解决方案 */
|
||||||
|
@layer utilities {
|
||||||
|
/* 全屏减去navbar高度 */
|
||||||
|
.h-screen-nav {
|
||||||
|
height: calc(100vh - var(--navbar-height));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 侧栏最大高度,确保底部按钮可见 */
|
||||||
|
.sidebar-max-height {
|
||||||
|
max-height: calc(100vh - var(--navbar-height) - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 首页hero section专用高度 */
|
||||||
|
.min-h-screen-nav {
|
||||||
|
min-height: calc(100vh - var(--navbar-height));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Navbar from "@/components/Navbar";
|
||||||
|
import { AuthProvider } from "@/contexts/AuthContext";
|
||||||
|
import { MainContent } from "@/components/MainContent";
|
||||||
|
import { ErrorNotificationContainer } from "@/components/ErrorNotification";
|
||||||
|
import ScrollToTop from "@/components/ScrollToTop";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||||
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "CarrotSkin - 现代化Minecraft Yggdrasil皮肤站",
|
||||||
description: "Generated by create next app",
|
description: "新一代Minecraft Yggdrasil皮肤站,为创作者打造的现代化皮肤管理平台",
|
||||||
|
keywords: "Minecraft, 皮肤站, Yggdrasil, CarrotSkin, 我的世界, 皮肤管理",
|
||||||
|
authors: [{ name: "CarrotSkin Team" }],
|
||||||
|
openGraph: {
|
||||||
|
title: "CarrotSkin - 现代化Minecraft Yggdrasil皮肤站",
|
||||||
|
description: "新一代Minecraft Yggdrasil皮肤站,为创作者打造的现代化皮肤管理平台",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,11 +31,14 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<body
|
<body className={inter.className}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<AuthProvider>
|
||||||
>
|
<Navbar />
|
||||||
{children}
|
<MainContent>{children}</MainContent>
|
||||||
|
<ErrorNotificationContainer />
|
||||||
|
<ScrollToTop />
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
108
src/app/not-found.tsx
Normal file
108
src/app/not-found.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { HomeIcon, ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 via-white to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||||
|
<motion.div
|
||||||
|
className="text-center px-4"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
{/* 404 数字 */}
|
||||||
|
<motion.div
|
||||||
|
className="mb-8"
|
||||||
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }}
|
||||||
|
>
|
||||||
|
<h1 className="text-9xl font-black bg-gradient-to-r from-orange-400 via-orange-500 to-amber-500 bg-clip-text text-transparent mb-4">
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
<div className="w-24 h-1 bg-gradient-to-r from-orange-400 to-amber-500 mx-auto rounded-full" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
<motion.div
|
||||||
|
className="mb-8"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.4, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
页面不见了
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-md mx-auto leading-relaxed">
|
||||||
|
抱歉,我们找不到您要访问的页面。它可能已被移动、删除,或者您输入的链接不正确。
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Minecraft 风格的装饰 */}
|
||||||
|
<motion.div
|
||||||
|
className="mb-8 flex justify-center"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.6, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg flex items-center justify-center transform rotate-12 shadow-lg">
|
||||||
|
<span className="text-2xl font-bold text-white">?</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center animate-pulse">
|
||||||
|
<span className="text-white text-xs font-bold">!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.8, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<HomeIcon className="w-5 h-5 mr-2" />
|
||||||
|
返回主页
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||||
|
返回上页
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 额外的帮助信息 */}
|
||||||
|
<motion.div
|
||||||
|
className="mt-8 text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 1, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<p>如果问题持续存在,请
|
||||||
|
<Link href="/contact" className="text-orange-500 hover:text-orange-600 underline">
|
||||||
|
联系我们
|
||||||
|
</Link>
|
||||||
|
的支持团队
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 背景装饰 */}
|
||||||
|
<div className="fixed inset-0 -z-10 overflow-hidden">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-orange-200/20 dark:bg-orange-900/20 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-amber-200/20 dark:bg-amber-900/20 rounded-full blur-3xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
343
src/app/page.tsx
343
src/app/page.tsx
@@ -1,65 +1,304 @@
|
|||||||
import Image from "next/image";
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
CloudArrowUpIcon,
|
||||||
|
ShareIcon,
|
||||||
|
CubeIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
RocketLaunchIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { scrollYProgress } = useScroll();
|
||||||
|
const opacity = useTransform(scrollYProgress, [0, 0.3], [1, 0]);
|
||||||
|
const scale = useTransform(scrollYProgress, [0, 0.3], [1, 0.8]);
|
||||||
|
|
||||||
|
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
setMousePosition({ x: e.clientX, y: e.clientY });
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: ShieldCheckIcon,
|
||||||
|
title: "Yggdrasil认证",
|
||||||
|
description: "完整的Minecraft Yggdrasil API支持,安全可靠的用户认证系统",
|
||||||
|
color: "from-amber-400 to-orange-500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CloudArrowUpIcon,
|
||||||
|
title: "云端存储",
|
||||||
|
description: "无限皮肤存储空间,自动备份,随时随地访问你的皮肤库",
|
||||||
|
color: "from-orange-400 to-red-500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ShareIcon,
|
||||||
|
title: "社区分享",
|
||||||
|
description: "与全球玩家分享创作,发现灵感,建立你的粉丝群体",
|
||||||
|
color: "from-red-400 to-pink-500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CubeIcon,
|
||||||
|
title: "3D预览",
|
||||||
|
description: "实时3D皮肤预览,360度旋转查看,支持多种渲染模式",
|
||||||
|
color: "from-pink-400 to-purple-500"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ number: "50K+", label: "注册用户" },
|
||||||
|
{ number: "200K+", label: "皮肤上传" },
|
||||||
|
{ number: "1M+", label: "月活用户" },
|
||||||
|
{ number: "99.9%", label: "服务可用性" }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
{/* Animated Background */}
|
||||||
<Image
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
className="dark:invert"
|
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-orange-400/20 to-amber-400/20 rounded-full blur-3xl animate-pulse"></div>
|
||||||
src="/next.svg"
|
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-tr from-pink-400/20 to-orange-400/20 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||||
alt="Next.js logo"
|
<div
|
||||||
width={100}
|
className="absolute w-32 h-32 bg-gradient-to-r from-orange-400/30 to-amber-400/30 rounded-full blur-2xl transition-all duration-300 ease-out"
|
||||||
height={20}
|
style={{
|
||||||
priority
|
left: mousePosition.x - 64,
|
||||||
|
top: mousePosition.y - 64,
|
||||||
|
transform: isHovered ? 'scale(1.5)' : 'scale(1)'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
</div>
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
{/* Hero Section */}
|
||||||
|
<motion.section
|
||||||
|
style={{ opacity, scale }}
|
||||||
|
className="relative min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="relative z-10 max-w-7xl mx-auto text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
{/* Logo Animation */}
|
||||||
|
<motion.div
|
||||||
|
className="mb-8 flex justify-center"
|
||||||
|
initial={{ scale: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ duration: 1, type: "spring", stiffness: 100 }}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 via-amber-500 to-orange-600 rounded-3xl flex items-center justify-center shadow-2xl">
|
||||||
|
<span className="text-4xl font-bold text-white">CS</span>
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
className="absolute -inset-2 bg-gradient-to-br from-orange-400/30 to-amber-500/30 rounded-3xl blur-lg"
|
||||||
|
animate={{ scale: [1, 1.1, 1] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h1 className="text-6xl md:text-8xl font-black text-gray-900 dark:text-white mb-6 tracking-tight">
|
||||||
|
<span className="bg-gradient-to-r from-orange-500 via-amber-500 to-orange-600 bg-clip-text text-transparent">
|
||||||
|
CarrotSkin
|
||||||
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
<motion.p
|
||||||
<a
|
className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 mb-8 max-w-3xl mx-auto font-light leading-relaxed"
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
initial={{ opacity: 0, y: 20 }}
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3, duration: 0.6 }}
|
||||||
>
|
>
|
||||||
Templates
|
新一代 <span className="font-semibold text-orange-500">Minecraft Yggdrasil</span> 皮肤站
|
||||||
</a>{" "}
|
<br className="hidden sm:block" />
|
||||||
or the{" "}
|
为创作者打造的现代化皮肤管理平台
|
||||||
<a
|
</motion.p>
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
<motion.div
|
||||||
|
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5, duration: 0.6 }}
|
||||||
>
|
>
|
||||||
Learning
|
<Link
|
||||||
</a>{" "}
|
href="/register"
|
||||||
center.
|
className="group relative overflow-hidden bg-gradient-to-r from-orange-500 to-amber-500 text-white font-semibold py-4 px-8 rounded-2xl transition-all duration-300 hover:shadow-2xl hover:shadow-orange-500/25"
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<span className="relative z-10 flex items-center space-x-2">
|
||||||
|
<span>立即开始</span>
|
||||||
|
<ArrowRightIcon className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</span>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-gradient-to-r from-amber-500 to-orange-500"
|
||||||
|
initial={{ x: '-100%' }}
|
||||||
|
whileHover={{ x: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/skins"
|
||||||
|
className="group border-2 border-orange-200 dark:border-orange-700 text-orange-600 dark:text-orange-400 font-semibold py-4 px-8 rounded-2xl transition-all duration-300 hover:bg-orange-500 hover:text-white hover:border-orange-500"
|
||||||
|
>
|
||||||
|
<span className="flex items-center space-x-2">
|
||||||
|
<span>探索皮肤库</span>
|
||||||
|
<SparklesIcon className="w-5 h-5 group-hover:rotate-12 transition-transform" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Indicator */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-12 left-1/2 transform -translate-x-1/2"
|
||||||
|
animate={{ y: [0, 10, 0] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
>
|
||||||
|
<div className="w-6 h-10 border-2 border-orange-400 rounded-full flex justify-center">
|
||||||
|
<motion.div
|
||||||
|
className="w-1 h-3 bg-orange-400 rounded-full mt-2"
|
||||||
|
animate={{ y: [0, 16, 0] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
{/* Stats Section */}
|
||||||
|
<section className="py-20 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.6 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="text-center group"
|
||||||
|
>
|
||||||
|
<div className="text-4xl md:text-5xl font-black bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent mb-2">
|
||||||
|
{stat.number}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400 font-medium">
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-16 bg-gradient-to-r from-orange-400 to-amber-400 mx-auto mt-3 rounded-full group-hover:w-24 transition-all duration-300"></div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<section className="py-20 px-4 sm:px-6 lg:px-8 relative">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="text-center mb-16"
|
||||||
|
>
|
||||||
|
<h2 className="text-4xl md:text-5xl font-black text-gray-900 dark:text-white mb-6">
|
||||||
|
为创作者而生的
|
||||||
|
<span className="bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent"> 强大功能</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto font-light">
|
||||||
|
从上传到分享,从管理到展示,每一个细节都为提升你的创作体验而设计
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, x: index % 2 === 0 ? -50 : 50 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.6 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
className="group relative"
|
||||||
|
>
|
||||||
|
<div className="bg-white/70 dark:bg-gray-800/70 backdrop-blur-lg rounded-3xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 border border-white/20 dark:border-gray-700/50">
|
||||||
|
<div className={`w-16 h-16 bg-gradient-to-br ${feature.color} rounded-2xl flex items-center justify-center mb-6 shadow-lg group-hover:scale-110 transition-transform duration-300`}>
|
||||||
|
<feature.icon className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||||
|
{feature.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
</motion.div>
|
||||||
<a
|
))}
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<section className="py-20 px-4 sm:px-6 lg:px-8 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-orange-500 via-amber-500 to-orange-600">
|
||||||
|
<div className="absolute inset-0 bg-black/10"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="relative z-10 max-w-4xl mx-auto text-center"
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||||
|
className="w-32 h-32 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-8"
|
||||||
|
>
|
||||||
|
<RocketLaunchIcon className="w-16 h-16 text-white/80" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h2 className="text-4xl md:text-5xl font-black text-white mb-6">
|
||||||
|
准备开启创作之旅?
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-white/90 mb-8 max-w-2xl mx-auto font-light">
|
||||||
|
加入CarrotSkin,体验新一代Minecraft皮肤管理平台,让你的创意无限绽放
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="group bg-white text-orange-600 hover:bg-gray-100 font-bold py-4 px-8 rounded-2xl transition-all duration-300 inline-flex items-center space-x-2 shadow-2xl"
|
||||||
|
>
|
||||||
|
<span>免费注册</span>
|
||||||
|
<ArrowRightIcon className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/api"
|
||||||
|
className="border-2 border-white/30 text-white hover:bg-white/10 font-bold py-4 px-8 rounded-2xl transition-all duration-300 inline-flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<span>查看API文档</span>
|
||||||
|
<UserGroupIcon className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1359
src/app/profile/page.tsx
Normal file
1359
src/app/profile/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
462
src/app/skins/page.tsx
Normal file
462
src/app/skins/page.tsx
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { MagnifyingGlassIcon, EyeIcon, HeartIcon, ArrowDownTrayIcon, SparklesIcon, FunnelIcon, ArrowsUpDownIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
|
||||||
|
import SkinViewer from '@/components/SkinViewer';
|
||||||
|
import { searchTextures, toggleFavorite, type Texture } from '@/lib/api';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
export default function SkinsPage() {
|
||||||
|
const [textures, setTextures] = useState<Texture[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [textureType, setTextureType] = useState<'SKIN' | 'CAPE' | 'ALL'>('ALL');
|
||||||
|
const [sortBy, setSortBy] = useState('最新');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [favoritedIds, setFavoritedIds] = useState<Set<number>>(new Set());
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const sortOptions = ['最新', '最热', '最多下载'];
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
// 加载材质数据
|
||||||
|
const loadTextures = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
console.log('开始加载材质数据,参数:', { searchTerm, textureType, sortBy, page, pageSize });
|
||||||
|
|
||||||
|
const response = await searchTextures({
|
||||||
|
keyword: searchTerm || undefined,
|
||||||
|
type: textureType !== 'ALL' ? textureType : undefined,
|
||||||
|
public_only: true,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('API响应数据:', response);
|
||||||
|
console.log('API响应code:', response.code);
|
||||||
|
console.log('API响应data:', response.data);
|
||||||
|
|
||||||
|
if (response.code === 200 && response.data) {
|
||||||
|
// 安全地处理数据,避免未定义错误
|
||||||
|
const textureList = response.data.list || [];
|
||||||
|
const totalCount = response.data.total || 0;
|
||||||
|
const totalPagesCount = response.data.total_pages || 1;
|
||||||
|
|
||||||
|
console.log('解析后的数据:', { textureList, totalCount, totalPagesCount });
|
||||||
|
console.log('材质列表长度:', textureList.length);
|
||||||
|
|
||||||
|
if (textureList.length > 0) {
|
||||||
|
console.log('第一个材质数据:', textureList[0]);
|
||||||
|
console.log('第一个材质URL:', textureList[0].url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedList = [...textureList];
|
||||||
|
|
||||||
|
// 客户端排序
|
||||||
|
if (sortedList.length > 0) {
|
||||||
|
switch (sortBy) {
|
||||||
|
case '最热':
|
||||||
|
sortedList = sortedList.sort((a, b) => (b.favorite_count || 0) - (a.favorite_count || 0));
|
||||||
|
break;
|
||||||
|
case '最多下载':
|
||||||
|
sortedList = sortedList.sort((a, b) => (b.download_count || 0) - (a.download_count || 0));
|
||||||
|
break;
|
||||||
|
default: // 最新
|
||||||
|
sortedList = sortedList.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.created_at || 0).getTime();
|
||||||
|
const dateB = new Date(b.created_at || 0).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTextures(sortedList);
|
||||||
|
setTotal(totalCount);
|
||||||
|
setTotalPages(totalPagesCount);
|
||||||
|
|
||||||
|
console.log('设置状态后的数据:', { sortedListLength: sortedList.length, totalCount, totalPagesCount });
|
||||||
|
} else {
|
||||||
|
// API返回错误状态
|
||||||
|
console.warn('API返回错误:', response.message);
|
||||||
|
console.warn('API完整响应:', response);
|
||||||
|
setTextures([]);
|
||||||
|
setTotal(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载材质失败:', error);
|
||||||
|
// 发生网络或其他错误时,显示空状态
|
||||||
|
setTextures([]);
|
||||||
|
setTotal(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
console.log('加载完成,isLoading设置为false');
|
||||||
|
}
|
||||||
|
}, [searchTerm, textureType, sortBy, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTextures();
|
||||||
|
}, [loadTextures]);
|
||||||
|
|
||||||
|
// 处理收藏
|
||||||
|
const handleFavorite = async (id: number) => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
alert('请先登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await toggleFavorite(id);
|
||||||
|
if (response.code === 200) {
|
||||||
|
setFavoritedIds(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (response.data.is_favorited) {
|
||||||
|
newSet.add(id);
|
||||||
|
} else {
|
||||||
|
newSet.delete(id);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
// 更新本地数据
|
||||||
|
setTextures(prev => prev.map(texture =>
|
||||||
|
texture.id === id
|
||||||
|
? {
|
||||||
|
...texture,
|
||||||
|
favorite_count: response.data.is_favorited
|
||||||
|
? texture.favorite_count + 1
|
||||||
|
: Math.max(0, texture.favorite_count - 1)
|
||||||
|
}
|
||||||
|
: texture
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('收藏操作失败:', error);
|
||||||
|
alert('操作失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||||
|
{/* Animated Background - 保持背景但简化 */}
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-orange-400/10 to-amber-400/10 rounded-full blur-3xl animate-pulse"></div>
|
||||||
|
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-tr from-pink-400/10 to-orange-400/10 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-0 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* 简化的头部区域 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
皮肤库
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
发现和分享精彩的Minecraft皮肤与披风
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 重新设计的搜索区域 - 更紧凑专业 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1, duration: 0.5 }}
|
||||||
|
className="bg-white/60 dark:bg-gray-800/60 backdrop-blur-md rounded-2xl shadow-lg p-6 mb-6 border border-white/10 dark:border-gray-700/30"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 items-end">
|
||||||
|
{/* 搜索框 - 更紧凑 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索皮肤、披风或作者..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
loadTextures();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 类型筛选 - 更紧凑 */}
|
||||||
|
<div className="lg:w-48">
|
||||||
|
<div className="relative">
|
||||||
|
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={textureType}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTextureType(e.target.value as 'SKIN' | 'CAPE' | 'ALL');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="w-full pl-10 pr-8 py-2.5 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 appearance-none"
|
||||||
|
>
|
||||||
|
<option value="ALL">全部</option>
|
||||||
|
<option value="SKIN">皮肤</option>
|
||||||
|
<option value="CAPE">披风</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 排序 - 更紧凑 */}
|
||||||
|
<div className="lg:w-48">
|
||||||
|
<div className="relative">
|
||||||
|
<ArrowsUpDownIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSortBy(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="w-full pl-10 pr-8 py-2.5 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 appearance-none"
|
||||||
|
>
|
||||||
|
{sortOptions.map(option => (
|
||||||
|
<option key={option} value={option}>{option}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索按钮 - 更简洁 */}
|
||||||
|
<motion.button
|
||||||
|
onClick={loadTextures}
|
||||||
|
className="px-6 py-2.5 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-medium rounded-xl transition-all duration-200 shadow-md hover:shadow-lg"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 结果统计 - 更简洁 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="mb-6 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
共找到 <span className="font-semibold text-orange-500">{total}</span> 个材质
|
||||||
|
</p>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<motion.button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
|
||||||
|
whileHover={{ scale: page === 1 ? 1 : 1.05 }}
|
||||||
|
whileTap={{ scale: page === 1 ? 1 : 0.95 }}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</motion.button>
|
||||||
|
<span className="px-4 py-2 text-gray-600 dark:text-gray-400">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<motion.button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
|
||||||
|
whileHover={{ scale: page === totalPages ? 1 : 1.05 }}
|
||||||
|
whileTap={{ scale: page === totalPages ? 1 : 0.95 }}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Loading State - 保持但简化 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isLoading && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
className="animate-pulse"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="bg-gray-200 dark:bg-gray-700 rounded-xl aspect-square mb-3"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
|
||||||
|
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Textures Grid - 保持卡片设计但简化 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isLoading && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||||
|
>
|
||||||
|
{textures.map((texture, index) => {
|
||||||
|
const isFavorited = favoritedIds.has(texture.id);
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={texture.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
className="group relative"
|
||||||
|
>
|
||||||
|
<div className="bg-white/60 dark:bg-gray-800/60 backdrop-blur-md rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-white/10 dark:border-gray-700/30 overflow-hidden">
|
||||||
|
{/* 3D Skin Preview */}
|
||||||
|
<div className="aspect-square bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 relative overflow-hidden group flex items-center justify-center">
|
||||||
|
{texture.type === 'SKIN' ? (
|
||||||
|
<SkinViewer
|
||||||
|
skinUrl={texture.url}
|
||||||
|
isSlim={texture.is_slim}
|
||||||
|
width={400}
|
||||||
|
height={400}
|
||||||
|
className="w-full h-full transition-transform duration-300 group-hover:scale-105"
|
||||||
|
autoRotate={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<motion.div
|
||||||
|
className="w-24 h-24 mx-auto mb-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg flex items-center justify-center"
|
||||||
|
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300 }}
|
||||||
|
>
|
||||||
|
<span className="text-xl">🧥</span>
|
||||||
|
</motion.div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium">披风</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
<div className="absolute top-3 right-3 flex gap-1.5">
|
||||||
|
<motion.span
|
||||||
|
className={`px-2 py-1 text-white text-xs rounded-full font-medium backdrop-blur-sm ${
|
||||||
|
texture.type === 'SKIN' ? 'bg-blue-500/80' : 'bg-purple-500/80'
|
||||||
|
}`}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
>
|
||||||
|
{texture.type === 'SKIN' ? '皮肤' : '披风'}
|
||||||
|
</motion.span>
|
||||||
|
{texture.is_slim && (
|
||||||
|
<motion.span
|
||||||
|
className="px-2 py-1 bg-pink-500/80 text-white text-xs rounded-full font-medium backdrop-blur-sm"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
>
|
||||||
|
细臂
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Texture Info */}
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white mb-1 truncate">{texture.name}</h3>
|
||||||
|
{texture.description && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2 leading-relaxed">
|
||||||
|
{texture.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<motion.span
|
||||||
|
className="flex items-center space-x-1"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
>
|
||||||
|
<HeartIcon className="w-4 h-4 text-red-400" />
|
||||||
|
<span className="font-medium">{texture.favorite_count}</span>
|
||||||
|
</motion.span>
|
||||||
|
<motion.span
|
||||||
|
className="flex items-center space-x-1"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="font-medium">{texture.download_count}</span>
|
||||||
|
</motion.span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<motion.button
|
||||||
|
onClick={() => window.open(texture.url, '_blank')}
|
||||||
|
className="flex-1 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</motion.button>
|
||||||
|
<motion.button
|
||||||
|
onClick={() => handleFavorite(texture.id)}
|
||||||
|
className={`px-3 py-2 border rounded-lg transition-all duration-200 font-medium ${
|
||||||
|
isFavorited
|
||||||
|
? 'bg-gradient-to-r from-red-500 to-pink-500 border-transparent text-white shadow-md'
|
||||||
|
: 'border-orange-500 text-orange-500 hover:bg-gradient-to-r hover:from-orange-500 hover:to-orange-600 hover:text-white hover:border-transparent hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
{isFavorited ? (
|
||||||
|
<HeartIconSolid className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<HeartIcon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Empty State - 简化 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isLoading && textures.length === 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
className="text-center py-16"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<MagnifyingGlassIcon className="w-10 h-10 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">没有找到相关材质</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">尝试调整搜索条件</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
src/components/ErrorNotification.tsx
Normal file
194
src/components/ErrorNotification.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { XMarkIcon, ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export type ErrorType = 'error' | 'warning' | 'success' | 'info';
|
||||||
|
|
||||||
|
interface ErrorNotificationProps {
|
||||||
|
message: string;
|
||||||
|
type?: ErrorType;
|
||||||
|
duration?: number;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorNotification({ message, type = 'error', duration = 5000, onClose }: ErrorNotificationProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration > 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
onClose?.();
|
||||||
|
}, duration);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [duration, onClose]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error':
|
||||||
|
return <ExclamationTriangleIcon className="w-5 h-5" />;
|
||||||
|
case 'warning':
|
||||||
|
return <ExclamationTriangleIcon className="w-5 h-5" />;
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircleIcon className="w-5 h-5" />;
|
||||||
|
case 'info':
|
||||||
|
return <InformationCircleIcon className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
border: 'border-red-200 dark:border-red-800',
|
||||||
|
text: 'text-red-800 dark:text-red-200',
|
||||||
|
icon: 'text-red-500',
|
||||||
|
close: 'text-red-400 hover:text-red-600 dark:text-red-300 dark:hover:text-red-100'
|
||||||
|
};
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||||
|
border: 'border-yellow-200 dark:border-yellow-800',
|
||||||
|
text: 'text-yellow-800 dark:text-yellow-200',
|
||||||
|
icon: 'text-yellow-500',
|
||||||
|
close: 'text-yellow-400 hover:text-yellow-600 dark:text-yellow-300 dark:hover:text-yellow-100'
|
||||||
|
};
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||||
|
border: 'border-green-200 dark:border-green-800',
|
||||||
|
text: 'text-green-800 dark:text-green-200',
|
||||||
|
icon: 'text-green-500',
|
||||||
|
close: 'text-green-400 hover:text-green-600 dark:text-green-300 dark:hover:text-green-100'
|
||||||
|
};
|
||||||
|
case 'info':
|
||||||
|
return {
|
||||||
|
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
|
border: 'border-blue-200 dark:border-blue-800',
|
||||||
|
text: 'text-blue-800 dark:text-blue-200',
|
||||||
|
icon: 'text-blue-500',
|
||||||
|
close: 'text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-100'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = getStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isVisible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||||
|
className={`fixed top-4 right-4 z-50 max-w-sm w-full ${styles.bg} ${styles.border} border rounded-xl shadow-lg backdrop-blur-sm`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start p-4">
|
||||||
|
<div className={`flex-shrink-0 ${styles.icon} mr-3 mt-0.5`}>
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className={`text-sm font-medium ${styles.text}`}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className={`flex-shrink-0 ml-3 ${styles.close} transition-colors`}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局错误管理器
|
||||||
|
class ErrorManager {
|
||||||
|
private static instance: ErrorManager;
|
||||||
|
private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = [];
|
||||||
|
|
||||||
|
static getInstance(): ErrorManager {
|
||||||
|
if (!ErrorManager.instance) {
|
||||||
|
ErrorManager.instance = new ErrorManager();
|
||||||
|
}
|
||||||
|
return ErrorManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message: string, duration?: number) {
|
||||||
|
this.showNotification(message, 'error', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
showWarning(message: string, duration?: number) {
|
||||||
|
this.showNotification(message, 'warning', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(message: string, duration?: number) {
|
||||||
|
this.showNotification(message, 'success', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
showInfo(message: string, duration?: number) {
|
||||||
|
this.showNotification(message, 'info', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private showNotification(message: string, type: ErrorType, duration?: number) {
|
||||||
|
const notification = {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
duration: duration ?? (type === 'error' ? 5000 : 3000)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.listeners.forEach(listener => listener(notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: (notification: ErrorNotificationProps & { id: string }) => void) {
|
||||||
|
this.listeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners = this.listeners.filter(l => l !== listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errorManager = ErrorManager.getInstance();
|
||||||
|
|
||||||
|
// 错误提示容器组件
|
||||||
|
export function ErrorNotificationContainer() {
|
||||||
|
const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = errorManager.subscribe((notification) => {
|
||||||
|
setNotifications(prev => [...prev, notification]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeNotification = (id: string) => {
|
||||||
|
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<ErrorNotification
|
||||||
|
key={notification.id}
|
||||||
|
{...notification}
|
||||||
|
onClose={() => removeNotification(notification.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
src/components/ErrorPage.tsx
Normal file
279
src/components/ErrorPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
HomeIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
ClockIcon,
|
||||||
|
ServerIcon,
|
||||||
|
WifiIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export interface ErrorPageProps {
|
||||||
|
code?: number;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
description?: string;
|
||||||
|
type?: '404' | '500' | '403' | 'network' | 'timeout' | 'maintenance' | 'custom';
|
||||||
|
actions?: {
|
||||||
|
primary?: {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
secondary?: {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
showContact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorConfigs = {
|
||||||
|
'404': {
|
||||||
|
icon: <XCircleIcon className="w-16 h-16" />,
|
||||||
|
title: '页面不见了',
|
||||||
|
message: '抱歉,我们找不到您要访问的页面。',
|
||||||
|
description: '它可能已被移动、删除,或者您输入的链接不正确。'
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
icon: <ServerIcon className="w-16 h-16" />,
|
||||||
|
title: '服务器错误',
|
||||||
|
message: '抱歉,服务器遇到了一些问题。',
|
||||||
|
description: '我们的团队正在努力解决这个问题,请稍后再试。'
|
||||||
|
},
|
||||||
|
'403': {
|
||||||
|
icon: <ExclamationTriangleIcon className="w-16 h-16" />,
|
||||||
|
title: '访问被拒绝',
|
||||||
|
message: '抱歉,您没有权限访问此页面。',
|
||||||
|
description: '请检查您的账户权限或联系管理员。'
|
||||||
|
},
|
||||||
|
'network': {
|
||||||
|
icon: <WifiIcon className="w-16 h-16" />,
|
||||||
|
title: '网络连接错误',
|
||||||
|
message: '无法连接到服务器。',
|
||||||
|
description: '请检查您的网络连接,然后重试。'
|
||||||
|
},
|
||||||
|
'timeout': {
|
||||||
|
icon: <ClockIcon className="w-16 h-16" />,
|
||||||
|
title: '请求超时',
|
||||||
|
message: '请求处理时间过长。',
|
||||||
|
description: '请刷新页面或稍后再试。'
|
||||||
|
},
|
||||||
|
'maintenance': {
|
||||||
|
icon: <ServerIcon className="w-16 h-16" />,
|
||||||
|
title: '系统维护中',
|
||||||
|
message: '我们正在进行系统维护。',
|
||||||
|
description: '请稍后再试,我们会尽快恢复服务。'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ErrorPage({
|
||||||
|
code,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
description,
|
||||||
|
type = 'custom',
|
||||||
|
actions,
|
||||||
|
showContact = true
|
||||||
|
}: ErrorPageProps) {
|
||||||
|
const config = errorConfigs[type] || {};
|
||||||
|
const displayTitle = title || config.title || '出错了';
|
||||||
|
const displayMessage = message || config.message || '发生了一些错误';
|
||||||
|
const displayDescription = description || config.description || '';
|
||||||
|
|
||||||
|
const defaultActions = {
|
||||||
|
primary: {
|
||||||
|
label: '返回主页',
|
||||||
|
href: '/'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
label: '返回上页',
|
||||||
|
onClick: () => window.history.back()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalActions = { ...defaultActions, ...actions };
|
||||||
|
|
||||||
|
const getIconColor = () => {
|
||||||
|
switch (type) {
|
||||||
|
case '404': return 'text-orange-500';
|
||||||
|
case '500': return 'text-red-500';
|
||||||
|
case '403': return 'text-yellow-500';
|
||||||
|
case 'network': return 'text-blue-500';
|
||||||
|
case 'timeout': return 'text-purple-500';
|
||||||
|
case 'maintenance': return 'text-gray-500';
|
||||||
|
default: return 'text-orange-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCodeColor = () => {
|
||||||
|
switch (type) {
|
||||||
|
case '404': return 'from-orange-400 via-orange-500 to-amber-500';
|
||||||
|
case '500': return 'from-red-400 via-red-500 to-pink-500';
|
||||||
|
case '403': return 'from-yellow-400 via-yellow-500 to-orange-500';
|
||||||
|
case 'network': return 'from-blue-400 via-blue-500 to-cyan-500';
|
||||||
|
case 'timeout': return 'from-purple-400 via-purple-500 to-pink-500';
|
||||||
|
case 'maintenance': return 'from-gray-400 via-gray-500 to-slate-500';
|
||||||
|
default: return 'from-orange-400 via-orange-500 to-amber-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 via-white to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||||
|
<motion.div
|
||||||
|
className="text-center px-4 max-w-2xl mx-auto"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
{/* 错误代码 */}
|
||||||
|
{code && (
|
||||||
|
<motion.div
|
||||||
|
className="mb-8"
|
||||||
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }}
|
||||||
|
>
|
||||||
|
<h1 className={`text-9xl font-black bg-gradient-to-r ${getCodeColor()} bg-clip-text text-transparent mb-4`}>
|
||||||
|
{code}
|
||||||
|
</h1>
|
||||||
|
<div className={`w-24 h-1 bg-gradient-to-r ${getCodeColor()} mx-auto rounded-full`} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图标 */}
|
||||||
|
<motion.div
|
||||||
|
className="mb-8 flex justify-center"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.4, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className={`${getIconColor()}`}>
|
||||||
|
{config.icon || <ExclamationTriangleIcon className="w-16 h-16" />}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
<motion.div
|
||||||
|
className="mb-8"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.6, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{displayTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{displayMessage}
|
||||||
|
</p>
|
||||||
|
{displayDescription && (
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
{displayDescription}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.8, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
{finalActions.primary && (
|
||||||
|
finalActions.primary.href ? (
|
||||||
|
<Link
|
||||||
|
href={finalActions.primary.href}
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<HomeIcon className="w-5 h-5 mr-2" />
|
||||||
|
{finalActions.primary.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={finalActions.primary.onClick}
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<HomeIcon className="w-5 h-5 mr-2" />
|
||||||
|
{finalActions.primary.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{finalActions.secondary && (
|
||||||
|
finalActions.secondary.href ? (
|
||||||
|
<Link
|
||||||
|
href={finalActions.secondary.href}
|
||||||
|
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||||
|
{finalActions.secondary.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={finalActions.secondary.onClick}
|
||||||
|
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||||
|
{finalActions.secondary.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 联系信息 */}
|
||||||
|
{showContact && (
|
||||||
|
<motion.div
|
||||||
|
className="mt-8 text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 1, duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<p>如果问题持续存在,请
|
||||||
|
<Link href="/contact" className="text-orange-500 hover:text-orange-600 underline mx-1">
|
||||||
|
联系我们
|
||||||
|
</Link>
|
||||||
|
的支持团队
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 背景装饰 */}
|
||||||
|
<div className="fixed inset-0 -z-10 overflow-hidden">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-orange-200/20 dark:bg-orange-900/20 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-amber-200/20 dark:bg-amber-900/20 rounded-full blur-3xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预设的错误页面组件
|
||||||
|
export function NotFoundPage() {
|
||||||
|
return <ErrorPage type="404" code={404} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerErrorPage() {
|
||||||
|
return <ErrorPage type="500" code={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForbiddenPage() {
|
||||||
|
return <ErrorPage type="403" code={403} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetworkErrorPage() {
|
||||||
|
return <ErrorPage type="network" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeoutErrorPage() {
|
||||||
|
return <ErrorPage type="timeout" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaintenancePage() {
|
||||||
|
return <ErrorPage type="maintenance" />;
|
||||||
|
}
|
||||||
19
src/components/MainContent.tsx
Normal file
19
src/components/MainContent.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
export function MainContent({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isAuthPage = pathname === '/auth';
|
||||||
|
const isHomePage = pathname === '/';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={`
|
||||||
|
min-h-screen bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900
|
||||||
|
${isAuthPage || isHomePage ? '' : 'pt-16'}
|
||||||
|
`}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
387
src/components/Navbar.tsx
Normal file
387
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { Bars3Icon, XMarkIcon, UserCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isHidden, setIsHidden] = useState(false);
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||||
|
const [navbarHeight, setNavbarHeight] = useState(0);
|
||||||
|
const navbarRef = useRef<HTMLElement>(null);
|
||||||
|
const { user, isAuthenticated, logout } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// 在auth页面隐藏navbar
|
||||||
|
const isAuthPage = pathname === '/auth';
|
||||||
|
|
||||||
|
// 检测navbar高度并设置CSS自定义属性
|
||||||
|
useEffect(() => {
|
||||||
|
const updateHeight = () => {
|
||||||
|
if (navbarRef.current) {
|
||||||
|
const height = navbarRef.current.offsetHeight;
|
||||||
|
setNavbarHeight(height);
|
||||||
|
document.documentElement.style.setProperty('--navbar-height', `${height}px`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateHeight();
|
||||||
|
window.addEventListener('resize', updateHeight);
|
||||||
|
return () => window.removeEventListener('resize', updateHeight);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lastScrollY = 0;
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const currentScrollY = window.scrollY;
|
||||||
|
|
||||||
|
// 检测是否滚动到顶部
|
||||||
|
setIsScrolled(currentScrollY > 20);
|
||||||
|
|
||||||
|
// 显示返回顶部按钮(滚动超过300px)
|
||||||
|
setShowScrollTop(currentScrollY > 300);
|
||||||
|
|
||||||
|
// 更敏感的隐藏逻辑:只要往下滚动就隐藏,不管滚动多少
|
||||||
|
if (!isAuthPage && currentScrollY > lastScrollY && currentScrollY > 10) {
|
||||||
|
setIsHidden(true);
|
||||||
|
} else if (currentScrollY < lastScrollY) { // 往上滚动就显示
|
||||||
|
setIsHidden(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrollY = currentScrollY;
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAuthPage) {
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [isAuthPage]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
router.push('/');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkClick = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在auth页面不渲染navbar
|
||||||
|
if (isAuthPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.nav
|
||||||
|
ref={navbarRef}
|
||||||
|
initial={{ y: 0 }}
|
||||||
|
animate={{ y: isHidden ? -100 : 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
className="fixed top-0 left-0 right-0 z-50 transition-all duration-300 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg border-b border-gray-200/50 dark:border-gray-700/50"
|
||||||
|
style={{ willChange: 'transform' }}
|
||||||
|
>
|
||||||
|
<div className={`
|
||||||
|
max-w-7xl mx-auto px-4 sm:px-6 lg:px-8
|
||||||
|
${isScrolled ? 'py-3' : 'py-4'}
|
||||||
|
transition-all duration-300
|
||||||
|
`}>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
{/* Logo */}
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<Link href="/" className="flex items-center space-x-3 group">
|
||||||
|
<motion.div
|
||||||
|
className="w-10 h-10 bg-gradient-to-br from-orange-400 via-orange-500 to-orange-600 rounded-xl flex items-center justify-center shadow-lg"
|
||||||
|
whileHover={{ rotate: 5, scale: 1.05 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300 }}
|
||||||
|
>
|
||||||
|
<span className="text-white font-bold text-lg">C</span>
|
||||||
|
</motion.div>
|
||||||
|
<motion.span
|
||||||
|
className="text-2xl font-black bg-gradient-to-r from-orange-400 to-orange-600 bg-clip-text text-transparent"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
>
|
||||||
|
CarrotSkin
|
||||||
|
</motion.span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden md:flex items-center space-x-6">
|
||||||
|
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
|
||||||
|
>
|
||||||
|
首页
|
||||||
|
<motion.span
|
||||||
|
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
whileHover={{ scaleX: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||||
|
<Link
|
||||||
|
href="/skins"
|
||||||
|
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
|
||||||
|
>
|
||||||
|
皮肤库
|
||||||
|
<motion.span
|
||||||
|
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
whileHover={{ scaleX: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 用户头像框 - 类似知乎和哔哩哔哩的设计 */}
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="flex items-center space-x-3 group"
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
>
|
||||||
|
{user?.avatar ? (
|
||||||
|
<motion.div
|
||||||
|
className="relative"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.username}
|
||||||
|
className="w-9 h-9 rounded-full border-2 border-orange-500/30 group-hover:border-orange-500 transition-all duration-200 shadow-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileHover={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
className="relative"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
>
|
||||||
|
<UserCircleIcon className="w-9 h-9 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileHover={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
<motion.span
|
||||||
|
className="text-gray-700 dark:text-gray-300 group-hover:text-orange-500 dark:group-hover:text-orange-400 transition-all duration-200 font-medium"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
>
|
||||||
|
{user?.username}
|
||||||
|
</motion.span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="relative overflow-hidden border-2 border-orange-500 text-orange-500 hover:text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 group"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className="absolute inset-0 w-0 bg-gradient-to-r from-orange-400 to-orange-600 transition-all duration-300 group-hover:w-full"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
whileHover={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<span className="relative z-10">退出登录</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||||
|
<Link
|
||||||
|
href="/auth"
|
||||||
|
className="flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 group"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="relative"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
>
|
||||||
|
<UserCircleIcon className="w-7 h-7 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileHover={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
<span className="font-medium">登录</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<div className="md:hidden flex items-center">
|
||||||
|
<motion.button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-colors duration-200 p-2"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
>
|
||||||
|
{isOpen ? <XMarkIcon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
className="md:hidden bg-white/95 dark:bg-gray-900/95 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-4 space-y-1">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.05 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
>
|
||||||
|
首页
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/skins"
|
||||||
|
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
>
|
||||||
|
皮肤库
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="block px-4 py-3 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200"
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{user?.avatar ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.username}
|
||||||
|
className="w-8 h-8 rounded-full border-2 border-orange-500/30"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserCircleIcon className="w-8 h-8 text-gray-400" />
|
||||||
|
)}
|
||||||
|
<span className="text-gray-700 dark:text-gray-300 font-medium">{user?.username}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.25 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="block w-full text-left px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/auth"
|
||||||
|
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.25 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/auth"
|
||||||
|
className="block px-4 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl font-medium text-center"
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/components/ScrollToTop.tsx
Normal file
65
src/components/ScrollToTop.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
export default function ScrollToTop() {
|
||||||
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const currentScrollY = window.scrollY;
|
||||||
|
// 显示返回顶部按钮(滚动超过300px)
|
||||||
|
setShowScrollTop(currentScrollY > 300);
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{showScrollTop && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
|
onClick={scrollToTop}
|
||||||
|
className="fixed bottom-6 right-6 w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center z-[100] group"
|
||||||
|
whileHover={{ scale: 1.1, y: -2 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 transition-transform duration-200 group-hover:-translate-y-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/components/SkinViewer.tsx
Normal file
218
src/components/SkinViewer.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { SkinViewer as SkinViewer3D, WalkingAnimation, FunctionAnimation } from 'skinview3d';
|
||||||
|
|
||||||
|
interface SkinViewerProps {
|
||||||
|
skinUrl: string;
|
||||||
|
capeUrl?: string;
|
||||||
|
isSlim?: boolean;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
autoRotate?: boolean;
|
||||||
|
walking?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkinViewer({
|
||||||
|
skinUrl,
|
||||||
|
capeUrl,
|
||||||
|
isSlim = false,
|
||||||
|
width = 300,
|
||||||
|
height = 300,
|
||||||
|
className = '',
|
||||||
|
autoRotate = true,
|
||||||
|
walking = false,
|
||||||
|
}: SkinViewerProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const viewerRef = useRef<SkinViewer3D | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
|
||||||
|
// 预加载皮肤图片以检查是否可访问
|
||||||
|
useEffect(() => {
|
||||||
|
if (!skinUrl) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setHasError(false);
|
||||||
|
setImageLoaded(false);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous'; // 尝试跨域访问
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
console.log('皮肤图片加载成功:', skinUrl);
|
||||||
|
setImageLoaded(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = (error) => {
|
||||||
|
console.error('皮肤图片加载失败:', skinUrl, error);
|
||||||
|
setHasError(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始加载图片
|
||||||
|
img.src = skinUrl;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 清理
|
||||||
|
img.onload = null;
|
||||||
|
img.onerror = null;
|
||||||
|
};
|
||||||
|
}, [skinUrl]);
|
||||||
|
|
||||||
|
// 初始化3D查看器
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || !imageLoaded || hasError) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('初始化3D皮肤查看器:', { skinUrl, isSlim, width, height });
|
||||||
|
|
||||||
|
// 使用canvas的实际尺寸,参考blessingskin
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const viewer = new SkinViewer3D({
|
||||||
|
canvas: canvas,
|
||||||
|
width: canvas.clientWidth || width,
|
||||||
|
height: canvas.clientHeight || height,
|
||||||
|
skin: skinUrl,
|
||||||
|
cape: capeUrl,
|
||||||
|
model: isSlim ? 'slim' : 'default',
|
||||||
|
zoom: 1.0, // 使用blessingskin的zoom方式
|
||||||
|
});
|
||||||
|
|
||||||
|
viewerRef.current = viewer;
|
||||||
|
|
||||||
|
// 设置背景和控制选项 - 参考blessingskin
|
||||||
|
viewer.background = null; // 透明背景
|
||||||
|
viewer.autoRotate = false; // 禁用自动旋转
|
||||||
|
|
||||||
|
// 禁用所有交互控制
|
||||||
|
viewer.controls.enableRotate = false; // 禁用旋转控制
|
||||||
|
viewer.controls.enableZoom = false; // 禁用缩放
|
||||||
|
viewer.controls.enablePan = false; // 禁用平移
|
||||||
|
|
||||||
|
console.log('3D皮肤查看器初始化成功');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('3D皮肤查看器初始化失败:', error);
|
||||||
|
setHasError(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
if (viewerRef.current) {
|
||||||
|
try {
|
||||||
|
viewerRef.current.dispose();
|
||||||
|
viewerRef.current = null;
|
||||||
|
console.log('3D皮肤查看器已清理');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清理3D皮肤查看器失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [skinUrl, capeUrl, isSlim, width, height, autoRotate, walking, imageLoaded, hasError]);
|
||||||
|
|
||||||
|
// 当皮肤URL改变时更新
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewerRef.current && skinUrl && imageLoaded) {
|
||||||
|
try {
|
||||||
|
console.log('更新皮肤URL:', skinUrl);
|
||||||
|
viewerRef.current.loadSkin(skinUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新皮肤失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [skinUrl, imageLoaded]);
|
||||||
|
|
||||||
|
// 监听容器尺寸变化并调整viewer大小 - 参考blessingskin
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewerRef.current && canvasRef.current) {
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
viewerRef.current.setSize(rect.width, rect.height);
|
||||||
|
}
|
||||||
|
}, [imageLoaded]); // 当图片加载完成后调整尺寸
|
||||||
|
|
||||||
|
// 当披风URL改变时更新
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewerRef.current && capeUrl && imageLoaded) {
|
||||||
|
try {
|
||||||
|
console.log('更新披风URL:', capeUrl);
|
||||||
|
viewerRef.current.loadCape(capeUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新披风失败:', error);
|
||||||
|
}
|
||||||
|
} else if (viewerRef.current && !capeUrl && imageLoaded) {
|
||||||
|
try {
|
||||||
|
viewerRef.current.loadCape(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('移除披风失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [capeUrl, imageLoaded]);
|
||||||
|
|
||||||
|
// 当模型类型改变时更新
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewerRef.current && skinUrl && imageLoaded) {
|
||||||
|
try {
|
||||||
|
console.log('更新模型类型:', isSlim ? 'slim' : 'default');
|
||||||
|
viewerRef.current.loadSkin(skinUrl, { model: isSlim ? 'slim' : 'default' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新模型失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isSlim, skinUrl, imageLoaded]);
|
||||||
|
|
||||||
|
// 错误状态显示
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${className} flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-900/20 dark:to-orange-900/20 rounded-xl border-2 border-dashed border-red-300 dark:border-red-700`}
|
||||||
|
style={{ width: width, height: height }}
|
||||||
|
>
|
||||||
|
<div className="text-center p-4">
|
||||||
|
<div className="text-4xl mb-2">⚠️</div>
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 font-medium mb-1">
|
||||||
|
皮肤加载失败
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-red-500 dark:text-red-500">
|
||||||
|
图片链接可能无效或存在访问限制
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态显示
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${className} flex items-center justify-center bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 rounded-xl`}
|
||||||
|
style={{ width: width, height: height }}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-3 border-orange-500 border-t-transparent rounded-full mx-auto mb-3"></div>
|
||||||
|
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
223
src/contexts/AuthContext.tsx
Normal file
223
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
points: number;
|
||||||
|
role: string;
|
||||||
|
status: number;
|
||||||
|
last_login_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
totalSkins?: number;
|
||||||
|
totalDownloads?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
register: (username: string, email: string, password: string, verificationCode: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (userData: Partial<User>) => void;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost:8080/api/v1';
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Check if user is logged in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuthStatus = async () => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (token) {
|
||||||
|
await refreshUser();
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuthStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAuthToken = (token: string) => {
|
||||||
|
localStorage.setItem('authToken', token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthHeaders = () => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token ? `Bearer ${token}` : '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
const { token, user_info } = result.data;
|
||||||
|
setAuthToken(token);
|
||||||
|
|
||||||
|
const userData: User = {
|
||||||
|
id: user_info.id,
|
||||||
|
username: user_info.username,
|
||||||
|
email: user_info.email,
|
||||||
|
avatar: user_info.avatar,
|
||||||
|
points: user_info.points,
|
||||||
|
role: user_info.role,
|
||||||
|
status: user_info.status,
|
||||||
|
last_login_at: user_info.last_login_at,
|
||||||
|
created_at: user_info.created_at,
|
||||||
|
updated_at: user_info.updated_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || '登录失败,请检查用户名和密码');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Error ? error : new Error('登录失败,请检查用户名和密码');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (username: string, email: string, password: string, verificationCode: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
verification_code: verificationCode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
const { token, user_info } = result.data;
|
||||||
|
setAuthToken(token);
|
||||||
|
|
||||||
|
const userData: User = {
|
||||||
|
id: user_info.id,
|
||||||
|
username: user_info.username,
|
||||||
|
email: user_info.email,
|
||||||
|
avatar: user_info.avatar,
|
||||||
|
points: user_info.points,
|
||||||
|
role: user_info.role,
|
||||||
|
status: user_info.status,
|
||||||
|
created_at: user_info.created_at,
|
||||||
|
updated_at: user_info.updated_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || '注册失败,请稍后重试');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Error ? error : new Error('注册失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshUser = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
const user_info = result.data;
|
||||||
|
const userData: User = {
|
||||||
|
id: user_info.id,
|
||||||
|
username: user_info.username,
|
||||||
|
email: user_info.email,
|
||||||
|
avatar: user_info.avatar,
|
||||||
|
points: user_info.points,
|
||||||
|
role: user_info.role,
|
||||||
|
status: user_info.status,
|
||||||
|
last_login_at: user_info.last_login_at,
|
||||||
|
created_at: user_info.created_at,
|
||||||
|
updated_at: user_info.updated_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
} else {
|
||||||
|
// Token invalid or expired
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh user:', error);
|
||||||
|
logout();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUser = (userData: Partial<User>) => {
|
||||||
|
if (user) {
|
||||||
|
setUser({ ...user, ...userData });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
updateUser,
|
||||||
|
refreshUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
303
src/lib/api.ts
Normal file
303
src/lib/api.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080/api/v1';
|
||||||
|
|
||||||
|
export interface Texture {
|
||||||
|
id: number;
|
||||||
|
uploader_id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: 'SKIN' | 'CAPE';
|
||||||
|
url: string;
|
||||||
|
hash: string;
|
||||||
|
size: number;
|
||||||
|
is_public: boolean;
|
||||||
|
download_count: number;
|
||||||
|
favorite_count: number;
|
||||||
|
is_slim: boolean;
|
||||||
|
status: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
uuid: string;
|
||||||
|
user_id: number;
|
||||||
|
name: string;
|
||||||
|
skin_id?: number;
|
||||||
|
cape_id?: number;
|
||||||
|
is_active: boolean;
|
||||||
|
last_used_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
list: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取认证头
|
||||||
|
function getAuthHeaders(): HeadersInit {
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索材质
|
||||||
|
export async function searchTextures(params: {
|
||||||
|
keyword?: string;
|
||||||
|
type?: 'SKIN' | 'CAPE';
|
||||||
|
public_only?: boolean;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}): Promise<ApiResponse<PaginatedResponse<Texture>>> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params.keyword) queryParams.append('keyword', params.keyword);
|
||||||
|
if (params.type) queryParams.append('type', params.type);
|
||||||
|
if (params.public_only !== undefined) queryParams.append('public_only', String(params.public_only));
|
||||||
|
if (params.page) queryParams.append('page', String(params.page));
|
||||||
|
if (params.page_size) queryParams.append('page_size', String(params.page_size));
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/texture?${queryParams.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取材质详情
|
||||||
|
export async function getTexture(id: number): Promise<ApiResponse<Texture>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/texture/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换收藏状态
|
||||||
|
export async function toggleFavorite(id: number): Promise<ApiResponse<{ is_favorited: boolean }>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/texture/${id}/favorite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户上传的材质列表
|
||||||
|
export async function getMyTextures(params: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}): Promise<ApiResponse<PaginatedResponse<Texture>>> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params.page) queryParams.append('page', String(params.page));
|
||||||
|
if (params.page_size) queryParams.append('page_size', String(params.page_size));
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/texture/my?${queryParams.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户收藏的材质列表
|
||||||
|
export async function getFavoriteTextures(params: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}): Promise<ApiResponse<PaginatedResponse<Texture>>> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params.page) queryParams.append('page', String(params.page));
|
||||||
|
if (params.page_size) queryParams.append('page_size', String(params.page_size));
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/texture/favorites?${queryParams.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户档案列表
|
||||||
|
export async function getProfiles(): Promise<ApiResponse<Profile[]>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/profile`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建档案
|
||||||
|
export async function createProfile(name: string): Promise<ApiResponse<Profile>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/profile`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新档案
|
||||||
|
export async function updateProfile(uuid: string, data: {
|
||||||
|
name?: string;
|
||||||
|
skin_id?: number;
|
||||||
|
cape_id?: number;
|
||||||
|
}): Promise<ApiResponse<Profile>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/profile/${uuid}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除档案
|
||||||
|
export async function deleteProfile(uuid: string): Promise<ApiResponse<null>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/profile/${uuid}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置活跃档案
|
||||||
|
export async function setActiveProfile(uuid: string): Promise<ApiResponse<{ message: string }>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/profile/${uuid}/activate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
export async function getUserProfile(): Promise<ApiResponse<{
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
points: number;
|
||||||
|
role: string;
|
||||||
|
status: number;
|
||||||
|
last_login_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
export async function updateUserProfile(data: {
|
||||||
|
avatar?: string;
|
||||||
|
old_password?: string;
|
||||||
|
new_password?: string;
|
||||||
|
}): Promise<ApiResponse<{
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
points: number;
|
||||||
|
role: string;
|
||||||
|
status: number;
|
||||||
|
last_login_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接上传皮肤文件
|
||||||
|
export async function uploadTexture(file: File, data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type?: 'SKIN' | 'CAPE';
|
||||||
|
is_public?: boolean;
|
||||||
|
is_slim?: boolean;
|
||||||
|
}): Promise<ApiResponse<Texture>> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('name', data.name);
|
||||||
|
if (data.description) formData.append('description', data.description);
|
||||||
|
if (data.type) formData.append('type', data.type);
|
||||||
|
if (data.is_public !== undefined) formData.append('is_public', String(data.is_public));
|
||||||
|
if (data.is_slim !== undefined) formData.append('is_slim', String(data.is_slim));
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/texture/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(typeof window !== 'undefined' ? { Authorization: `Bearer ${localStorage.getItem('authToken')}` } : {}),
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成头像上传URL
|
||||||
|
export async function generateAvatarUploadUrl(fileName: string): Promise<ApiResponse<{
|
||||||
|
post_url: string;
|
||||||
|
form_data: Record<string, string>;
|
||||||
|
avatar_url: string;
|
||||||
|
expires_in: number;
|
||||||
|
}>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/avatar/upload-url`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ file_name: fileName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新头像URL
|
||||||
|
export async function updateAvatarUrl(avatarUrl: string): Promise<ApiResponse<{
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
avatar: string;
|
||||||
|
points: number;
|
||||||
|
role: string;
|
||||||
|
status: number;
|
||||||
|
last_login_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}>> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/avatar?avatar_url=${encodeURIComponent(avatarUrl)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
51
tailwind.config.ts
Normal file
51
tailwind.config.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
orange: {
|
||||||
|
50: '#fff7ed',
|
||||||
|
100: '#ffedd5',
|
||||||
|
200: '#fed7aa',
|
||||||
|
300: '#fdba74',
|
||||||
|
400: '#fb923c',
|
||||||
|
500: '#f97316',
|
||||||
|
600: '#ea580c',
|
||||||
|
700: '#c2410c',
|
||||||
|
800: '#9a3412',
|
||||||
|
900: '#7c2d12',
|
||||||
|
950: '#431407',
|
||||||
|
},
|
||||||
|
minecraft: {
|
||||||
|
grass: '#7cbd6a',
|
||||||
|
dirt: '#8b4513',
|
||||||
|
stone: '#808080',
|
||||||
|
wood: '#8b6914',
|
||||||
|
diamond: '#4fc3f7',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
'minecraft': ['Minecraft', 'monospace'],
|
||||||
|
'sans': ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'float': 'float 3s ease-in-out infinite',
|
||||||
|
'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
float: {
|
||||||
|
'0%, 100%': { transform: 'translateY(0px)' },
|
||||||
|
'50%': { transform: 'translateY(-10px)' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
Reference in New Issue
Block a user