修复角色中心组件中的类型不匹配错误,改进skinId和capeId的类型处理逻辑
This commit is contained in:
114
API测试指南.md
Normal file
114
API测试指南.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# API连接测试指南
|
||||||
|
|
||||||
|
本指南将帮助您测试前端是否能够成功读取后端数据,以及验证登录和注册功能的实现状态。
|
||||||
|
|
||||||
|
## 登录和注册功能实现状态
|
||||||
|
|
||||||
|
根据代码分析,**登录和注册功能已经实现并连接到了真实的后端API**。系统采用了以下实现方式:
|
||||||
|
|
||||||
|
### 登录功能
|
||||||
|
- 支持使用用户名或邮箱登录
|
||||||
|
- 集成了NextAuth.js进行身份验证管理
|
||||||
|
- 在测试环境中提供测试账号(用户名: `test`, 密码: `test`)
|
||||||
|
- 非测试账号会调用真实的后端API进行认证:`https://code.littlelan.cn/CarrotSkin/APIgateway/api/v1/auth/login`
|
||||||
|
- 包含验证码验证机制(非测试账号需要)
|
||||||
|
|
||||||
|
### 注册功能
|
||||||
|
- 完整的表单验证逻辑(用户名、密码、邮箱、Minecraft用户名不能为空)
|
||||||
|
- 密码强度检查(至少6位)
|
||||||
|
- 邮箱格式验证
|
||||||
|
- 验证码验证
|
||||||
|
- 调用真实的后端注册API:`https://code.littlelan.cn/CarrotSkin/APIgateway/api/v1/auth/register`
|
||||||
|
|
||||||
|
## 如何测试后端数据连接
|
||||||
|
|
||||||
|
### 方法一:使用API测试页面
|
||||||
|
|
||||||
|
1. 访问 `http://localhost:3000/api-tester` 打开API测试工具(已修复)
|
||||||
|
2. 点击"开始API测试"按钮
|
||||||
|
3. 查看测试结果,了解各个API端点的连接状态
|
||||||
|
|
||||||
|
### 方法二:使用简化API测试页面
|
||||||
|
|
||||||
|
1. 访问 `http://localhost:3000/simple-api-test` 打开简化的API测试工具
|
||||||
|
2. 选择要测试的API端点
|
||||||
|
3. 输入必要的参数(如用户ID、角色ID等)
|
||||||
|
4. 点击"测试API"按钮查看返回结果
|
||||||
|
|
||||||
|
### 方法三:使用浏览器控制台进行测试
|
||||||
|
|
||||||
|
打开浏览器开发者工具(F12),切换到控制台(Console)选项卡,执行以下代码:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 测试材质列表API(无需登录)
|
||||||
|
fetch('/api/textures')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => console.log('材质列表数据:', data))
|
||||||
|
.catch(error => console.error('获取材质列表失败:', error));
|
||||||
|
|
||||||
|
// 如果已登录,可以测试需要认证的API
|
||||||
|
// 测试用户角色列表
|
||||||
|
fetch('/api/user-profiles?userId=1', { credentials: 'include' })
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => console.log('用户角色列表数据:', data))
|
||||||
|
.catch(error => console.error('获取用户角色列表失败:', error));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试登录功能
|
||||||
|
|
||||||
|
### 测试测试账号登录
|
||||||
|
|
||||||
|
1. 访问 `http://localhost:3000/login` 登录页面
|
||||||
|
2. 输入用户名:`test`,密码:`test`
|
||||||
|
3. 点击登录按钮
|
||||||
|
4. 成功后会自动跳转到用户主页 `http://localhost:3000/user-home`
|
||||||
|
|
||||||
|
### 测试注册功能
|
||||||
|
|
||||||
|
1. 访问 `http://localhost:3000/register` 注册页面
|
||||||
|
2. 填写完整的注册信息:
|
||||||
|
- 用户名:自定义
|
||||||
|
- 密码:至少6位
|
||||||
|
- 邮箱:有效的邮箱地址
|
||||||
|
- Minecraft用户名:自定义
|
||||||
|
3. 点击获取验证码,输入收到的验证码
|
||||||
|
4. 点击注册按钮
|
||||||
|
5. 成功后会提示注册成功并自动跳转到登录页面
|
||||||
|
|
||||||
|
## 常见问题排查
|
||||||
|
|
||||||
|
1. **API请求失败**
|
||||||
|
- 检查网络连接是否正常
|
||||||
|
- 确认后端服务器是否正在运行
|
||||||
|
- 查看浏览器控制台是否有错误信息
|
||||||
|
- 检查API端点URL是否正确
|
||||||
|
|
||||||
|
2. **登录失败**
|
||||||
|
- 确认用户名和密码是否正确
|
||||||
|
- 检查验证码是否输入正确
|
||||||
|
- 查看是否有网络错误提示
|
||||||
|
|
||||||
|
3. **注册失败**
|
||||||
|
- 确认所有必填项都已填写
|
||||||
|
- 检查密码是否满足长度要求
|
||||||
|
- 确认邮箱格式是否正确
|
||||||
|
- 检查用户名或邮箱是否已被注册
|
||||||
|
|
||||||
|
## 高级测试技巧
|
||||||
|
|
||||||
|
### 查看API请求详情
|
||||||
|
|
||||||
|
1. 打开浏览器开发者工具(F12)
|
||||||
|
2. 切换到网络(Network)选项卡
|
||||||
|
3. 刷新页面或执行API操作
|
||||||
|
4. 查看请求和响应详情,包括状态码、请求头、响应内容等
|
||||||
|
|
||||||
|
### 测试特定API端点
|
||||||
|
|
||||||
|
使用API测试页面,您可以测试以下主要端点:
|
||||||
|
- **材质列表**: `/api/textures` - 获取所有材质数据(无需登录)
|
||||||
|
- **用户角色列表**: `/api/user-profiles?userId={id}` - 获取指定用户的角色列表(需要登录)
|
||||||
|
- **角色详情**: `/api/profile?profileId={id}` - 获取指定角色的详细信息(需要登录)
|
||||||
|
- **角色及属性**: `/api/profile-props?profileId={id}` - 获取指定角色及其属性(需要登录)
|
||||||
|
|
||||||
|
通过以上测试方法,您可以全面验证前端与后端的连接状态和数据读取能力。
|
||||||
440
backedREADME (1).md
Normal file
440
backedREADME (1).md
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
# CarrotSkin Backend
|
||||||
|
|
||||||
|
一个功能完善的Minecraft皮肤站后端系统,采用单体架构设计,基于Go语言和Gin框架开发。
|
||||||
|
|
||||||
|
## ✨ 核心功能
|
||||||
|
|
||||||
|
- ✅ **用户认证系统** - 注册、登录、JWT认证、积分系统
|
||||||
|
- ✅ **邮箱验证系统** - 注册验证、找回密码、更换邮箱(基于Redis的验证码)
|
||||||
|
- ✅ **材质管理系统** - 皮肤/披风上传、搜索、收藏、下载统计
|
||||||
|
- ✅ **角色档案系统** - Minecraft角色创建、管理、RSA密钥生成
|
||||||
|
- ✅ **文件存储** - MinIO/RustFS对象存储集成、预签名URL上传
|
||||||
|
- ✅ **缓存系统** - Redis缓存、验证码存储、频率限制
|
||||||
|
- ✅ **权限管理** - Casbin RBAC权限控制
|
||||||
|
- ✅ **数据审计** - 登录日志、操作审计、下载记录
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── cmd/ # 应用程序入口
|
||||||
|
│ └── server/ # 主服务器
|
||||||
|
├── internal/ # 私有应用代码
|
||||||
|
│ ├── handler/ # HTTP处理器
|
||||||
|
│ ├── service/ # 业务逻辑服务
|
||||||
|
│ ├── model/ # 数据模型
|
||||||
|
│ ├── repository/ # 数据访问层
|
||||||
|
│ ├── middleware/ # 中间件
|
||||||
|
│ └── types/ # 类型定义
|
||||||
|
├── pkg/ # 公共库代码
|
||||||
|
│ ├── auth/ # 认证授权
|
||||||
|
│ ├── config/ # 配置管理
|
||||||
|
│ ├── database/ # 数据库连接
|
||||||
|
│ ├── email/ # 邮件服务
|
||||||
|
│ ├── logger/ # 日志系统
|
||||||
|
│ ├── redis/ # Redis客户端
|
||||||
|
│ ├── storage/ # 文件存储(RustFS/MinIO)
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ └── validator/ # 数据验证
|
||||||
|
├── docs/ # API定义和文档
|
||||||
|
├── configs/ # 配置文件
|
||||||
|
│ ├── casbin/ # Casbin权限配置
|
||||||
|
├── scripts/ # 脚本文件
|
||||||
|
│ ├── carrotskin.sql # 数据库初始化
|
||||||
|
├── go.mod # Go模块依赖
|
||||||
|
├── go.sum # Go模块校验
|
||||||
|
├── run.bat # Windows启动脚本
|
||||||
|
├── .env # 环境变量配置
|
||||||
|
└── README.md # 项目说明
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **语言**: Go 1.21+
|
||||||
|
- **框架**: Gin Web Framework
|
||||||
|
- **数据库**: PostgreSQL 15+
|
||||||
|
- **缓存**: Redis 6.0+
|
||||||
|
- **存储**: RustFS (S3兼容对象存储)
|
||||||
|
- **权限**: Casbin RBAC
|
||||||
|
- **日志**: Zap
|
||||||
|
- **配置**: 环境变量 (.env)
|
||||||
|
- **文档**: Swagger/OpenAPI 3.0
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Go 1.21或更高版本
|
||||||
|
- PostgreSQL 15或更高版本
|
||||||
|
- Redis 6.0或更高版本
|
||||||
|
- RustFS 或其他 S3 兼容对象存储服务
|
||||||
|
|
||||||
|
### 安装和运行
|
||||||
|
|
||||||
|
1. **克隆项目**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd CarrotSkin/backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **安装依赖**
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **配置环境**
|
||||||
|
```bash
|
||||||
|
# 复制环境变量文件
|
||||||
|
cp .env.example .env
|
||||||
|
# 编辑 .env 文件配置数据库、RustFS等服务连接信息
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:项目完全依赖 `.env` 文件进行配置,不再使用 YAML 配置文件,便于 Docker 容器化部署。
|
||||||
|
|
||||||
|
4. **初始化数据库**
|
||||||
|
```bash
|
||||||
|
# 创建数据库
|
||||||
|
createdb carrotskin
|
||||||
|
# 初始化表结构
|
||||||
|
psql -d carrotskin -f scripts/carrotskin_postgres.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **运行服务**
|
||||||
|
|
||||||
|
Windows系统:
|
||||||
|
```bash
|
||||||
|
run.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux/Mac系统:
|
||||||
|
```bash
|
||||||
|
chmod +x run.sh
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 **提示**: 启动脚本会自动检查并安装 `swag` 工具,然后生成Swagger API文档,最后启动服务器。
|
||||||
|
|
||||||
|
服务启动后:
|
||||||
|
- **服务地址**: http://localhost:8080
|
||||||
|
- **Swagger文档**: http://localhost:8080/swagger/index.html
|
||||||
|
- **健康检查**: http://localhost:8080/health
|
||||||
|
|
||||||
|
## API接口
|
||||||
|
|
||||||
|
### 认证相关
|
||||||
|
- `POST /api/v1/auth/register` - 用户注册(需邮箱验证码)
|
||||||
|
- `POST /api/v1/auth/login` - 用户登录(支持用户名/邮箱)
|
||||||
|
- `POST /api/v1/auth/send-code` - 发送验证码(注册/重置密码/更换邮箱)
|
||||||
|
- `POST /api/v1/auth/reset-password` - 重置密码(需验证码)
|
||||||
|
|
||||||
|
### 用户相关(需认证)
|
||||||
|
- `GET /api/v1/user/profile` - 获取用户信息
|
||||||
|
- `PUT /api/v1/user/profile` - 更新用户信息(头像、密码)
|
||||||
|
- `POST /api/v1/user/avatar/upload-url` - 生成头像上传URL
|
||||||
|
- `PUT /api/v1/user/avatar` - 更新头像
|
||||||
|
- `POST /api/v1/user/change-email` - 更换邮箱(需验证码)
|
||||||
|
|
||||||
|
### 材质管理
|
||||||
|
公开接口:
|
||||||
|
- `GET /api/v1/texture` - 搜索材质
|
||||||
|
- `GET /api/v1/texture/:id` - 获取材质详情
|
||||||
|
|
||||||
|
认证接口:
|
||||||
|
- `POST /api/v1/texture/upload-url` - 生成材质上传URL
|
||||||
|
- `POST /api/v1/texture` - 创建材质记录
|
||||||
|
- `PUT /api/v1/texture/:id` - 更新材质
|
||||||
|
- `DELETE /api/v1/texture/:id` - 删除材质
|
||||||
|
- `POST /api/v1/texture/:id/favorite` - 切换收藏状态
|
||||||
|
- `GET /api/v1/texture/my` - 我的材质列表
|
||||||
|
- `GET /api/v1/texture/favorites` - 我的收藏列表
|
||||||
|
|
||||||
|
### 角色档案
|
||||||
|
公开接口:
|
||||||
|
- `GET /api/v1/profile/:uuid` - 获取档案详情
|
||||||
|
|
||||||
|
认证接口:
|
||||||
|
- `POST /api/v1/profile` - 创建角色档案(UUID由后端生成)
|
||||||
|
- `GET /api/v1/profile` - 我的档案列表
|
||||||
|
- `PUT /api/v1/profile/:uuid` - 更新档案
|
||||||
|
- `DELETE /api/v1/profile/:uuid` - 删除档案
|
||||||
|
- `POST /api/v1/profile/:uuid/activate` - 设置活跃档案
|
||||||
|
|
||||||
|
### 系统配置
|
||||||
|
- `GET /api/v1/system/config` - 获取系统配置
|
||||||
|
|
||||||
|
## 配置管理
|
||||||
|
|
||||||
|
### 环境变量配置
|
||||||
|
|
||||||
|
项目**完全依赖环境变量**进行配置,不使用 YAML 配置文件,便于容器化部署:
|
||||||
|
|
||||||
|
1. **配置来源**: 环境变量 或 `.env` 文件
|
||||||
|
2. **环境变量格式**: 使用下划线分隔,全大写,如 `DATABASE_HOST`
|
||||||
|
3. **容器部署**: 直接在容器运行时设置环境变量即可
|
||||||
|
|
||||||
|
**主要环境变量**:
|
||||||
|
```bash
|
||||||
|
# 数据库配置
|
||||||
|
DATABASE_HOST=localhost
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USERNAME=postgres
|
||||||
|
DATABASE_PASSWORD=your_password
|
||||||
|
DATABASE_NAME=carrotskin
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=your_redis_password
|
||||||
|
REDIS_DATABASE=0
|
||||||
|
REDIS_POOL_SIZE=10
|
||||||
|
|
||||||
|
# RustFS对象存储配置 (S3兼容)
|
||||||
|
RUSTFS_ENDPOINT=127.0.0.1:9000
|
||||||
|
RUSTFS_ACCESS_KEY=your_access_key
|
||||||
|
RUSTFS_SECRET_KEY=your_secret_key
|
||||||
|
RUSTFS_USE_SSL=false
|
||||||
|
RUSTFS_BUCKET_TEXTURES=carrot-skin-textures
|
||||||
|
RUSTFS_BUCKET_AVATARS=carrot-skin-avatars
|
||||||
|
|
||||||
|
# JWT配置
|
||||||
|
JWT_SECRET=your-jwt-secret-key
|
||||||
|
JWT_EXPIRE_HOURS=168
|
||||||
|
|
||||||
|
# 邮件配置
|
||||||
|
EMAIL_ENABLED=true
|
||||||
|
EMAIL_SMTP_HOST=smtp.example.com
|
||||||
|
EMAIL_SMTP_PORT=587
|
||||||
|
EMAIL_USERNAME=noreply@example.com
|
||||||
|
EMAIL_PASSWORD=your_email_password
|
||||||
|
EMAIL_FROM_NAME=CarrotSkin
|
||||||
|
```
|
||||||
|
|
||||||
|
**动态配置(存储在数据库中)**:
|
||||||
|
- 积分系统配置(注册奖励、签到积分等)
|
||||||
|
- 用户限制配置(最大材质数、最大角色数等)
|
||||||
|
- 网站设置(站点名称、公告、维护模式等)
|
||||||
|
|
||||||
|
完整的环境变量列表请参考 `.env.example` 文件。
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
### 三层架构
|
||||||
|
|
||||||
|
项目采用标准的三层架构设计,职责清晰,便于维护:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Handler 层 (HTTP) │ ← 路由、参数验证、响应格式化
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Service 层 (业务逻辑) │ ← 业务规则、权限检查、数据验证
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Repository 层 (数据访问) │ ← 数据库操作、关联查询
|
||||||
|
├──────────────┬──────────────────────┤
|
||||||
|
│ PostgreSQL │ Redis │ RustFS │ ← 数据存储层
|
||||||
|
└──────────────┴──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心模块
|
||||||
|
|
||||||
|
1. **认证模块** (`internal/handler/auth_handler.go`)
|
||||||
|
- JWT令牌生成和验证
|
||||||
|
- bcrypt密码加密
|
||||||
|
- 邮箱验证码注册
|
||||||
|
- 密码重置功能
|
||||||
|
- 登录日志记录(支持用户名/邮箱登录)
|
||||||
|
|
||||||
|
2. **用户模块** (`internal/handler/user_handler.go`)
|
||||||
|
- 用户信息管理
|
||||||
|
- 头像上传(预签名URL)
|
||||||
|
- 密码修改(需原密码验证)
|
||||||
|
- 邮箱更换(需验证码)
|
||||||
|
- 积分系统
|
||||||
|
|
||||||
|
3. **邮箱验证模块** (`internal/service/verification_service.go`)
|
||||||
|
- 验证码生成(6位数字)
|
||||||
|
- 验证码存储(Redis,10分钟有效期)
|
||||||
|
- 发送频率限制(1分钟)
|
||||||
|
- 邮件发送(HTML格式)
|
||||||
|
|
||||||
|
4. **材质模块** (`internal/handler/texture_handler.go`)
|
||||||
|
- 材质上传(预签名URL)
|
||||||
|
- 材质搜索和收藏
|
||||||
|
- Hash去重
|
||||||
|
- 下载统计
|
||||||
|
|
||||||
|
5. **档案模块** (`internal/handler/profile_handler.go`)
|
||||||
|
- Minecraft角色管理
|
||||||
|
- RSA密钥生成(RSA-2048)
|
||||||
|
- 活跃状态管理
|
||||||
|
- 档案数量限制
|
||||||
|
|
||||||
|
### 技术特性
|
||||||
|
|
||||||
|
- **安全性**:
|
||||||
|
- bcrypt密码加密、JWT令牌认证
|
||||||
|
- 邮箱验证码(注册/重置密码/更换邮箱)
|
||||||
|
- Casbin RBAC权限控制
|
||||||
|
- 频率限制(防暴力破解)
|
||||||
|
|
||||||
|
- **性能**:
|
||||||
|
- PostgreSQL索引优化
|
||||||
|
- Redis缓存(验证码、会话等)
|
||||||
|
- 预签名URL减轻服务器压力
|
||||||
|
- 连接池管理
|
||||||
|
|
||||||
|
- **可靠性**:
|
||||||
|
- 事务保证数据一致性
|
||||||
|
- 完整的错误处理和日志记录
|
||||||
|
- 优雅关闭和资源清理
|
||||||
|
|
||||||
|
- **可扩展**:
|
||||||
|
- 清晰的三层架构
|
||||||
|
- 依赖注入设计
|
||||||
|
- 环境变量配置(便于容器化)
|
||||||
|
|
||||||
|
- **审计**:
|
||||||
|
- 登录日志(成功/失败)
|
||||||
|
- 操作审计
|
||||||
|
- 下载记录
|
||||||
|
|
||||||
|
## 开发指南
|
||||||
|
|
||||||
|
### 代码结构
|
||||||
|
|
||||||
|
- `cmd/server/` - 应用入口,初始化服务
|
||||||
|
- `internal/handler/` - HTTP请求处理
|
||||||
|
- `internal/service/` - 业务逻辑实现
|
||||||
|
- `internal/repository/` - 数据库操作
|
||||||
|
- `internal/model/` - 数据模型定义
|
||||||
|
- `internal/types/` - 请求/响应类型定义
|
||||||
|
- `internal/middleware/` - 中间件(JWT、CORS、日志等)
|
||||||
|
- `pkg/` - 可复用的公共库
|
||||||
|
|
||||||
|
### 开发规范
|
||||||
|
|
||||||
|
1. **代码风格**: 遵循Go官方代码规范,使用 `gofmt` 格式化
|
||||||
|
2. **错误处理**: 使用统一的错误响应格式 (`model.NewErrorResponse`)
|
||||||
|
3. **日志记录**: 使用 Zap 结构化日志,包含关键字段
|
||||||
|
4. **依赖注入**: Repository → Service → Handler 的依赖链
|
||||||
|
5. **RESTful API**: 遵循 REST 设计原则,合理使用HTTP方法
|
||||||
|
|
||||||
|
### 添加新功能
|
||||||
|
|
||||||
|
1. 在 `internal/model/` 定义数据模型
|
||||||
|
2. 在 `internal/repository/` 实现数据访问
|
||||||
|
3. 在 `internal/service/` 实现业务逻辑
|
||||||
|
4. 在 `internal/handler/` 实现HTTP处理
|
||||||
|
5. 在 `internal/handler/routes.go` 注册路由
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
# 启动服务 (Windows)
|
||||||
|
run.bat
|
||||||
|
|
||||||
|
# 启动服务 (Linux/Mac)
|
||||||
|
go run cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建二进制文件
|
||||||
|
go build -o carrotskin-server cmd/server/main.go
|
||||||
|
|
||||||
|
# 运行服务
|
||||||
|
./carrotskin-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker build -t carrotskin-backend:latest .
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **数据库连接失败**
|
||||||
|
- 检查 `.env` 中的数据库配置
|
||||||
|
- 确认PostgreSQL服务已启动
|
||||||
|
- 验证数据库用户权限
|
||||||
|
- 确认数据库已创建:`createdb carrotskin`
|
||||||
|
|
||||||
|
2. **Redis连接失败**
|
||||||
|
- 检查Redis服务是否运行:`redis-cli ping`
|
||||||
|
- 验证 `.env` 中的Redis配置
|
||||||
|
- 确认Redis密码是否正确
|
||||||
|
- 检查防火墙规则
|
||||||
|
|
||||||
|
3. **RustFS/MinIO连接失败**
|
||||||
|
- 检查存储服务是否运行
|
||||||
|
- 验证访问密钥是否正确
|
||||||
|
- 确认存储桶是否已创建
|
||||||
|
- 检查网络连接和端口
|
||||||
|
|
||||||
|
4. **邮件发送失败**
|
||||||
|
- 检查 `EMAIL_ENABLED=true`
|
||||||
|
- 验证SMTP服务器地址和端口
|
||||||
|
- 确认邮箱用户名和密码正确
|
||||||
|
- 检查邮件服务商是否需要开启SMTP
|
||||||
|
- 查看日志获取详细错误信息
|
||||||
|
|
||||||
|
5. **验证码相关问题**
|
||||||
|
- 验证码过期(10分钟有效期)
|
||||||
|
- 发送过于频繁(1分钟限制)
|
||||||
|
- Redis存储失败(检查Redis连接)
|
||||||
|
- 邮件未收到(检查垃圾邮件)
|
||||||
|
|
||||||
|
6. **JWT验证失败**
|
||||||
|
- 检查 `JWT_SECRET` 是否配置
|
||||||
|
- 验证令牌是否过期(默认168小时)
|
||||||
|
- 确认请求头中包含 `Authorization: Bearer <token>`
|
||||||
|
- Token格式是否正确
|
||||||
|
|
||||||
|
### 调试技巧
|
||||||
|
|
||||||
|
1. **查看日志**
|
||||||
|
```bash
|
||||||
|
# 实时查看日志
|
||||||
|
tail -f logs/app.log
|
||||||
|
|
||||||
|
# 搜索错误日志
|
||||||
|
grep "ERROR" logs/app.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **测试Redis连接**
|
||||||
|
```bash
|
||||||
|
redis-cli -h localhost -p 6379 -a your_password
|
||||||
|
> PING
|
||||||
|
> KEYS *
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **测试数据库连接**
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U postgres -d carrotskin
|
||||||
|
\dt # 查看所有表
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **测试邮件配置**
|
||||||
|
- 使用Swagger文档测试 `/api/v1/auth/send-code` 接口
|
||||||
|
- 检查邮件服务商是否限制发送频率
|
||||||
|
|
||||||
|
### 开发调试
|
||||||
|
|
||||||
|
启用详细日志:
|
||||||
|
```bash
|
||||||
|
# 在 .env 中设置
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
SERVER_MODE=debug
|
||||||
|
```
|
||||||
291
profileServiceREADME (1).md
Normal file
291
profileServiceREADME (1).md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# ProfileService - 角色管理服务
|
||||||
|
|
||||||
|
ProfileService是CarrotSkin皮肤站的角色管理微服务,负责处理Minecraft角色的创建、管理、RSA密钥生成和签名验证等核心功能。
|
||||||
|
|
||||||
|
## 📋 目录
|
||||||
|
|
||||||
|
- [功能特性](#功能特性)
|
||||||
|
- [技术架构](#技术架构)
|
||||||
|
- [数据模型](#数据模型)
|
||||||
|
- [API接口](#api接口)
|
||||||
|
- [部署指南](#部署指南)
|
||||||
|
- [开发指南](#开发指南)
|
||||||
|
- [故障排查](#故障排查)
|
||||||
|
|
||||||
|
## 🚀 功能特性
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
- **角色管理**: 创建、查询、更新、删除Minecraft角色
|
||||||
|
- **RSA密钥管理**: 自动生成RSA-2048密钥对,支持数据签名和验证
|
||||||
|
- **皮肤/披风关联**: 管理角色与皮肤、披风的关联关系
|
||||||
|
- **用户角色列表**: 支持查询用户下的所有角色
|
||||||
|
- **数据验证**: 完整的参数验证和业务逻辑检查
|
||||||
|
|
||||||
|
### 安全特性
|
||||||
|
- **RSA签名**: 支持使用私钥对数据进行签名
|
||||||
|
- **签名验证**: 支持使用公钥验证数据签名
|
||||||
|
- **权限控制**: 确保用户只能操作自己的角色
|
||||||
|
- **数据完整性**: 完整的事务处理和数据一致性保证
|
||||||
|
|
||||||
|
## 🏗️ 技术架构
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
- **框架**: go-zero微服务框架
|
||||||
|
- **数据库**: MySQL 8.0+
|
||||||
|
- **缓存**: Redis (通过go-zero集成)
|
||||||
|
- **通信协议**: gRPC
|
||||||
|
- **密钥算法**: RSA-2048
|
||||||
|
|
||||||
|
### 服务架构
|
||||||
|
```
|
||||||
|
ProfileService
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # 配置管理
|
||||||
|
│ ├── handler/ # gRPC处理器
|
||||||
|
│ ├── logic/ # 业务逻辑层
|
||||||
|
│ ├── model/ # 数据模型层
|
||||||
|
│ └── svc/ # 服务上下文
|
||||||
|
├── pb/ # Protocol Buffers定义
|
||||||
|
└── docs/ # 文档和SQL脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 数据模型
|
||||||
|
|
||||||
|
### 数据库表结构
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `profiles` (
|
||||||
|
`uuid` VARCHAR(36) NOT NULL COMMENT '角色的UUID,通常为Minecraft玩家的UUID',
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '关联的用户ID',
|
||||||
|
`name` VARCHAR(16) NOT NULL COMMENT '角色名 (Minecraft In-Game Name)',
|
||||||
|
`skin_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '当前使用的皮肤ID',
|
||||||
|
`cape_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '当前使用的披风ID',
|
||||||
|
`rsa_private_key` TEXT NOT NULL COMMENT 'RSA-2048私钥 (PEM格式)',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`uuid`),
|
||||||
|
INDEX `idx_user_id` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据类型映射
|
||||||
|
|
||||||
|
| 字段 | SQL类型 | Go Model类型 | Proto类型 | 说明 |
|
||||||
|
|------|---------|-------------|-----------|------|
|
||||||
|
| uuid | VARCHAR(36) | string | string | 角色UUID |
|
||||||
|
| user_id | BIGINT UNSIGNED | uint64 | int64 | 用户ID |
|
||||||
|
| name | VARCHAR(16) | string | string | 角色名 |
|
||||||
|
| skin_id | BIGINT UNSIGNED NULL | sql.NullInt64 | int64 | 皮肤ID (0表示无皮肤) |
|
||||||
|
| cape_id | BIGINT UNSIGNED NULL | sql.NullInt64 | int64 | 披风ID (0表示无披风) |
|
||||||
|
| rsa_private_key | TEXT | string | string | RSA私钥 |
|
||||||
|
| created_at | TIMESTAMP | time.Time | string | 创建时间 |
|
||||||
|
| updated_at | TIMESTAMP | time.Time | string | 更新时间 |
|
||||||
|
|
||||||
|
## 🔌 API接口
|
||||||
|
|
||||||
|
### 对外接口 (Public APIs)
|
||||||
|
|
||||||
|
#### 1. CreateProfile - 创建角色
|
||||||
|
```protobuf
|
||||||
|
rpc CreateProfile(CreateProfileRequest) returns (CreateProfileResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 创建新的Minecraft角色
|
||||||
|
- **验证**: UUID格式、角色名格式、用户权限
|
||||||
|
- **特性**: 自动生成RSA密钥对、角色名去重检查
|
||||||
|
|
||||||
|
#### 2. GetProfile - 获取角色信息
|
||||||
|
```protobuf
|
||||||
|
rpc GetProfile(GetProfileRequest) returns (GetProfileResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 根据UUID获取角色基本信息
|
||||||
|
- **返回**: 不包含私钥的公开信息
|
||||||
|
|
||||||
|
#### 3. GetProfilesByUserId - 获取用户角色列表
|
||||||
|
```protobuf
|
||||||
|
rpc GetProfilesByUserId(GetProfilesByUserIdRequest) returns (GetProfilesByUserIdResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 获取指定用户的所有角色列表
|
||||||
|
- **特性**: 支持空结果返回
|
||||||
|
|
||||||
|
#### 4. UpdateProfile - 更新角色信息
|
||||||
|
```protobuf
|
||||||
|
rpc UpdateProfile(UpdateProfileRequest) returns (UpdateProfileResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 更新角色名、皮肤ID、披风ID
|
||||||
|
- **验证**: 角色名重复检查、权限验证
|
||||||
|
- **特性**: 支持NULL值处理 (0表示移除)
|
||||||
|
|
||||||
|
#### 5. DeleteProfile - 删除角色
|
||||||
|
```protobuf
|
||||||
|
rpc DeleteProfile(DeleteProfileRequest) returns (DeleteProfileResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 删除指定角色
|
||||||
|
- **验证**: 权限检查
|
||||||
|
|
||||||
|
#### 6. GetProfilePublicKey - 获取角色公钥
|
||||||
|
```protobuf
|
||||||
|
rpc GetProfilePublicKey(GetProfilePublicKeyRequest) returns (GetProfilePublicKeyResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 获取角色的RSA公钥
|
||||||
|
- **用途**: 用于验证角色签名
|
||||||
|
|
||||||
|
#### 7. VerifyProfileSignature - 验证角色签名
|
||||||
|
```protobuf
|
||||||
|
rpc VerifyProfileSignature(VerifyProfileSignatureRequest) returns (VerifyProfileSignatureResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 验证使用角色私钥生成的签名
|
||||||
|
- **算法**: RSA-2048 + SHA-256
|
||||||
|
|
||||||
|
#### 8. GetProfileWithProperties - 查询角色属性
|
||||||
|
```protobuf
|
||||||
|
rpc GetProfileWithProperties(GetProfileWithPropertiesRequest) returns (GetProfileWithPropertiesResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 查询角色完整信息,包含Minecraft协议兼容的属性和签名
|
||||||
|
- **用途**: 用于游戏客户端获取皮肤、披风等信息
|
||||||
|
- **特性**: 支持生成带签名和不带签名的属性
|
||||||
|
|
||||||
|
#### 9. GetProfilesByNames - 批量查询角色
|
||||||
|
```protobuf
|
||||||
|
rpc GetProfilesByNames(GetProfilesByNamesRequest) returns (GetProfilesByNamesResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 根据角色名称列表批量查询角色信息
|
||||||
|
- **返回**: 简化的角色信息列表(UUID和名称)
|
||||||
|
|
||||||
|
#### 10. GetUserIdByProfileName - 根据角色名获取用户ID
|
||||||
|
```protobuf
|
||||||
|
rpc GetUserIdByProfileName(GetUserIdByProfileNameRequest) returns (GetUserIdByProfileNameResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 根据角色名称查找关联的用户ID
|
||||||
|
- **用途**: 支持使用角色名进行登录等操作
|
||||||
|
|
||||||
|
### 对内接口 (Internal APIs)
|
||||||
|
|
||||||
|
#### 11. GetProfileInternalInfo - 获取角色内部信息
|
||||||
|
```protobuf
|
||||||
|
rpc GetProfileInternalInfo(GetProfileInternalInfoRequest) returns (ProfileInternalInfo);
|
||||||
|
```
|
||||||
|
- **功能**: 获取包含私钥的完整角色信息
|
||||||
|
- **权限**: 仅供内部微服务调用
|
||||||
|
- **安全**: 包含敏感信息,需要严格权限控制
|
||||||
|
|
||||||
|
#### 12. SignData - 使用私钥签名数据
|
||||||
|
```protobuf
|
||||||
|
rpc SignData(SignDataRequest) returns (SignDataResponse);
|
||||||
|
```
|
||||||
|
- **功能**: 使用角色私钥对数据进行签名
|
||||||
|
- **算法**: RSA-2048 + SHA-256
|
||||||
|
- **返回**: Base64编码的签名结果
|
||||||
|
|
||||||
|
## 🚀 部署指南
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
- Go 1.19+
|
||||||
|
- MySQL 8.0+
|
||||||
|
- Redis 6.0+
|
||||||
|
|
||||||
|
### 配置文件示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# etc/profile.yaml
|
||||||
|
Name: profile.rpc
|
||||||
|
ListenOn: 0.0.0.0:8082
|
||||||
|
|
||||||
|
#Etcd:
|
||||||
|
# Hosts:
|
||||||
|
# - 127.0.0.1:2379
|
||||||
|
# Key: profile.rpc
|
||||||
|
|
||||||
|
DataSource: root:password@tcp(localhost:3306)/carrot_skin?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||||
|
|
||||||
|
CacheConf:
|
||||||
|
- Host: 127.0.0.1:6379
|
||||||
|
Pass: ""
|
||||||
|
Type: node
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部署步骤
|
||||||
|
|
||||||
|
1. **数据库初始化**
|
||||||
|
```bash
|
||||||
|
mysql -u root -p carrot_skin < docs/profiles.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **编译服务**
|
||||||
|
```bash
|
||||||
|
go build -o profile-service .
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **启动服务**
|
||||||
|
```bash
|
||||||
|
./profile-service -f etc/profile.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ 开发指南
|
||||||
|
|
||||||
|
### 代码生成
|
||||||
|
|
||||||
|
使用提供的脚本重新生成代码:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
generate.bat
|
||||||
|
|
||||||
|
# Linux/Mac
|
||||||
|
chmod +x generate.sh
|
||||||
|
./generate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 业务逻辑扩展
|
||||||
|
|
||||||
|
所有业务逻辑都在 `internal/logic/` 目录下:
|
||||||
|
|
||||||
|
- `createProfileLogic.go` - 角色创建逻辑
|
||||||
|
- `getProfileLogic.go` - 角色查询逻辑
|
||||||
|
- `updateProfileLogic.go` - 角色更新逻辑
|
||||||
|
- `deleteProfileLogic.go` - 角色删除逻辑
|
||||||
|
- 等等...
|
||||||
|
|
||||||
|
### 数据模型扩展
|
||||||
|
|
||||||
|
如需添加新的数据库方法,在 `internal/model/profilesmodel.go` 中扩展:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ProfilesModel interface {
|
||||||
|
profilesModel
|
||||||
|
// 添加自定义方法
|
||||||
|
FindByUserId(ctx context.Context, userId uint64) ([]*Profiles, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 故障排查
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
#### 1. 数据类型转换错误
|
||||||
|
**问题**: `sql.NullInt64` 类型处理错误
|
||||||
|
**解决**: 使用 `nullInt64ToValue()` 函数进行正确转换
|
||||||
|
|
||||||
|
#### 2. RSA密钥生成失败
|
||||||
|
**问题**: RSA密钥生成或解析失败
|
||||||
|
**解决**: 检查系统随机数生成器,确保有足够的熵
|
||||||
|
|
||||||
|
#### 3. 角色名重复
|
||||||
|
**问题**: 同一用户下角色名重复
|
||||||
|
**解决**: 创建和更新时都会进行重复检查
|
||||||
|
|
||||||
|
#### 4. UUID格式错误
|
||||||
|
**问题**: UUID格式不符合标准
|
||||||
|
**解决**: 确保UUID为36位标准格式 (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
|
||||||
|
|
||||||
|
### 日志级别
|
||||||
|
|
||||||
|
- **INFO**: 正常业务操作
|
||||||
|
- **ERROR**: 业务错误和系统错误
|
||||||
|
- **DEBUG**: 详细的调试信息
|
||||||
|
|
||||||
|
### 性能监控
|
||||||
|
|
||||||
|
- 数据库连接池状态
|
||||||
|
- Redis缓存命中率
|
||||||
|
- gRPC请求响应时间
|
||||||
|
- RSA密钥操作耗时
|
||||||
319
skinserviceREADME .md
Normal file
319
skinserviceREADME .md
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# 皮肤服务 (SkinService)
|
||||||
|
|
||||||
|
此项目是一个使用 **Go-zero** 框架构建的、专注于材质管理的纯 RPC 微服务。它为CarrotSkin皮肤站提供了完整的材质上传、存储、查询和管理功能,并集成了基于 MinIO 的高效文件存储系统。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- **安全材质上传**: 利用 MinIO 预签名 POST URL,实现客户端直传,支持PNG格式限制和文件大小控制(1KB-1MB)
|
||||||
|
- **智能去重机制**: 基于 SHA-256 哈希值自动检测并复用相同材质,节省存储空间
|
||||||
|
- **完整材质管理**: 提供材质的增、删、改、查(CRUD)等全套操作
|
||||||
|
- **分类存储**: 支持皮肤(SKIN)和披风(CAPE)两种材质类型的分类管理
|
||||||
|
- **个人皮肤库**: 用户可查看和管理自己上传的所有材质
|
||||||
|
- **皮肤广场**: 公开材质展示平台,支持按类型浏览和搜索
|
||||||
|
- **收藏夹功能**: 支持用户收藏和管理自己的收藏列表
|
||||||
|
- **高性能查询**: 支持分页查询、批量获取和条件搜索
|
||||||
|
- **权限控制**: 严格的材质所有权验证,确保用户只能操作自己的材质
|
||||||
|
|
||||||
|
## 技术特性
|
||||||
|
|
||||||
|
- **微服务架构**: 基于 gRPC 的高性能服务间通信
|
||||||
|
- **对象存储**: MinIO 分布式存储,支持海量文件管理
|
||||||
|
- **数据库缓存**: go-zero 内置缓存机制,提升查询性能
|
||||||
|
- **类型安全**: 完整的 protobuf 定义和数据验证
|
||||||
|
- **容错设计**: 优雅的错误处理和日志记录
|
||||||
|
- **代码生成**: 使用 `goctl` 从 `.proto` 和 `.sql` 文件自动生成代码
|
||||||
|
|
||||||
|
## API 接口参考
|
||||||
|
|
||||||
|
服务通过 gRPC 暴露,接口定义于 `docs/textures.proto`。
|
||||||
|
|
||||||
|
### 材质上传流程
|
||||||
|
|
||||||
|
| 方法名 | 功能描述 | 请求类型 | 响应类型 |
|
||||||
|
|--------|----------|----------|----------|
|
||||||
|
| `GenerateTextureUploadURL` | 生成材质上传预签名URL | `GenerateTextureUploadURLRequest` | `GenerateTextureUploadURLResponse` |
|
||||||
|
| `CreateTexture` | 创建材质记录(上传完成后调用) | `CreateTextureRequest` | `CreateTextureResponse` |
|
||||||
|
|
||||||
|
**上传流程说明**:
|
||||||
|
1. 客户端调用 `GenerateTextureUploadURL` 获取预签名上传URL和表单数据
|
||||||
|
2. 客户端使用返回的 `post_url` 和 `form_data` 直接向MinIO上传文件
|
||||||
|
3. 上传成功后,客户端调用 `CreateTexture` 将材质信息记录到数据库
|
||||||
|
|
||||||
|
### 材质管理接口
|
||||||
|
|
||||||
|
| 方法名 | 功能描述 | 请求类型 | 响应类型 |
|
||||||
|
|--------|----------|----------|----------|
|
||||||
|
| `GetTexture` | 获取单个材质信息 | `GetTextureRequest` | `GetTextureResponse` |
|
||||||
|
| `UpdateTexture` | 更新材质信息(公开/私有状态) | `UpdateTextureRequest` | `UpdateTextureResponse` |
|
||||||
|
| `DeleteTexture` | 删除材质(含MinIO文件清理) | `DeleteTextureRequest` | `DeleteTextureResponse` |
|
||||||
|
|
||||||
|
### 查询接口
|
||||||
|
|
||||||
|
| 方法名 | 功能描述 | 请求类型 | 响应类型 |
|
||||||
|
|--------|----------|----------|----------|
|
||||||
|
| `GetUserTextures` | 获取用户个人材质库 | `GetUserTexturesRequest` | `GetUserTexturesResponse` |
|
||||||
|
| `GetPublicTextures` | 获取皮肤广场公开材质 | `GetPublicTexturesRequest` | `GetPublicTexturesResponse` |
|
||||||
|
| `SearchTextures` | 搜索材质 | `SearchTexturesRequest` | `SearchTexturesResponse` |
|
||||||
|
|
||||||
|
### 高级功能接口
|
||||||
|
|
||||||
|
| 方法名 | 功能描述 | 请求类型 | 响应类型 |
|
||||||
|
|--------|----------|----------|----------|
|
||||||
|
| `GetTextureByHash` | 根据哈希值查找材质(防重复上传) | `GetTextureByHashRequest` | `GetTextureByHashResponse` |
|
||||||
|
| `GetTexturesByIds` | 批量获取材质信息 | `GetTexturesByIdsRequest` | `GetTexturesByIdsResponse` |
|
||||||
|
|
||||||
|
### 收藏夹功能接口
|
||||||
|
|
||||||
|
| 方法名 | 功能描述 | 请求类型 | 响应类型 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `AddFavorite` | 添加材质到收藏夹 | `AddFavoriteRequest` | `AddFavoriteResponse` |
|
||||||
|
| `RemoveFavorite` | 从收藏夹移除材质 | `RemoveFavoriteRequest` | `RemoveFavoriteResponse` |
|
||||||
|
| `GetUserFavorites` | 获取用户收藏列表 | `GetUserFavoritesRequest` | `GetUserFavoritesResponse` |
|
||||||
|
| `CheckFavoriteStatus` | 检查材质收藏状态 | `CheckFavoriteStatusRequest` | `CheckFavoriteStatusResponse` |
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### 材质信息 (TextureInfo)
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message TextureInfo {
|
||||||
|
int64 id = 1; // 材质ID
|
||||||
|
int64 uploader_id = 2; // 上传者用户ID
|
||||||
|
TextureType type = 3; // 材质类型(SKIN/CAPE)
|
||||||
|
string url = 4; // MinIO中的永久访问URL
|
||||||
|
string hash = 5; // SHA-256哈希值
|
||||||
|
bool is_public = 6; // 是否公开到皮肤广场
|
||||||
|
string created_at = 7; // 创建时间
|
||||||
|
string updated_at = 8; // 更新时间
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 材质类型枚举
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
enum TextureType {
|
||||||
|
SKIN = 0; // 皮肤
|
||||||
|
CAPE = 1; // 披风
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 收藏夹相关数据模型
|
||||||
|
|
||||||
|
#### 收藏材质信息 (FavoriteTextureInfo)
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message FavoriteTextureInfo {
|
||||||
|
TextureInfo texture = 1; // 材质信息
|
||||||
|
string favorite_at = 2; // 收藏时间
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 存储结构
|
||||||
|
|
||||||
|
### MinIO 对象存储结构
|
||||||
|
|
||||||
|
```
|
||||||
|
textures/ # 存储桶根目录
|
||||||
|
├── skins/ # 皮肤材质目录
|
||||||
|
│ └── user_{userId}/ # 按用户分组
|
||||||
|
│ └── {timestamp}_{filename}.png
|
||||||
|
└── capes/ # 披风材质目录
|
||||||
|
└── user_{userId}/ # 按用户分组
|
||||||
|
└── {timestamp}_{filename}.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库表结构
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE textures (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
uploader_id BIGINT NOT NULL,
|
||||||
|
type VARCHAR(10) NOT NULL, -- 'SKIN' 或 'CAPE'
|
||||||
|
url VARCHAR(500) NOT NULL, -- MinIO访问URL
|
||||||
|
hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256哈希值
|
||||||
|
is_public BOOLEAN DEFAULT FALSE, -- 是否公开
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
INDEX idx_uploader_type (uploader_id, type),
|
||||||
|
INDEX idx_hash (hash),
|
||||||
|
INDEX idx_public_type (is_public, type, created_at)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `user_texture_favorites` 表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 用户材质收藏表
|
||||||
|
CREATE TABLE `user_texture_favorites` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '收藏记录的唯一ID',
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID (对应UserService中的users.id)',
|
||||||
|
`texture_id` BIGINT UNSIGNED NOT NULL COMMENT '收藏的材质ID (对应textures.id)',
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_user_texture` (`user_id`, `texture_id`),
|
||||||
|
INDEX `idx_user_id` (`user_id`),
|
||||||
|
INDEX `idx_texture_id` (`texture_id`),
|
||||||
|
INDEX `idx_created_at` (`created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户材质收藏表';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 服务配置 (etc/textures.yaml)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Name: textures.rpc
|
||||||
|
ListenOn: 0.0.0.0:8080
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
DataSource: root:password@tcp(localhost:3306)/carrot_skin?charset=utf8mb4&parseTime=true&loc=Local
|
||||||
|
|
||||||
|
# 缓存配置
|
||||||
|
CacheRedis:
|
||||||
|
- Host: localhost:6379
|
||||||
|
Pass: ""
|
||||||
|
Type: node
|
||||||
|
|
||||||
|
# MinIO配置
|
||||||
|
MinIO:
|
||||||
|
Endpoint: "localhost:9000"
|
||||||
|
AccessKeyID: "minioadmin"
|
||||||
|
SecretAccessKey: "minioadmin"
|
||||||
|
UseSSL: false
|
||||||
|
Buckets:
|
||||||
|
Textures: "carrot-skin-textures"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全特性
|
||||||
|
|
||||||
|
### 文件上传安全
|
||||||
|
|
||||||
|
- **格式限制**: 仅允许PNG格式文件
|
||||||
|
- **大小限制**: 文件大小限制在1KB-1MB之间
|
||||||
|
- **时效控制**: 预签名URL 15分钟过期
|
||||||
|
- **内容验证**: MinIO层面的Content-Type验证
|
||||||
|
|
||||||
|
### 权限控制
|
||||||
|
|
||||||
|
- **所有权验证**: 用户只能删除/更新自己上传的材质
|
||||||
|
- **参数校验**: 所有接口都有完整的输入验证
|
||||||
|
- **错误处理**: 不暴露敏感的系统信息
|
||||||
|
|
||||||
|
### 数据完整性
|
||||||
|
|
||||||
|
- **哈希去重**: SHA-256确保文件唯一性
|
||||||
|
- **事务处理**: 数据库操作的原子性保证
|
||||||
|
- **文件同步**: 删除材质时同步清理MinIO文件
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
### 查询优化
|
||||||
|
|
||||||
|
- **分页查询**: 支持高效的大数据量分页
|
||||||
|
- **索引优化**: 针对常用查询场景建立复合索引
|
||||||
|
- **缓存机制**: go-zero内置的Redis缓存层
|
||||||
|
|
||||||
|
### 存储优化
|
||||||
|
|
||||||
|
- **智能去重**: 相同文件自动复用,节省存储空间
|
||||||
|
- **分类存储**: 按材质类型和用户分目录存储
|
||||||
|
|
||||||
|
## 部署说明
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Go 1.19+
|
||||||
|
- MySQL 8.0+
|
||||||
|
- Redis 6.0+
|
||||||
|
- MinIO Server
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# 2. 生成代码(如果修改了proto或sql文件)
|
||||||
|
goctl rpc protoc docs/textures.proto --go_out=./pb --go-grpc_out=./pb --zrpc_out=.
|
||||||
|
goctl model mysql ddl --src="docs/textures.sql" --dir="./internal/model"
|
||||||
|
|
||||||
|
# 3. 启动服务
|
||||||
|
go run textures.go -f etc/textures.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM golang:1.19-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN go mod tidy && go build -o textures textures.go
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
WORKDIR /root/
|
||||||
|
COPY --from=builder /app/textures .
|
||||||
|
COPY --from=builder /app/etc ./etc
|
||||||
|
CMD ["./textures", "-f", "etc/textures.yaml"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 监控与日志
|
||||||
|
|
||||||
|
### 日志记录
|
||||||
|
|
||||||
|
- **操作日志**: 记录所有材质操作的详细信息
|
||||||
|
- **错误日志**: 详细的错误堆栈和上下文信息
|
||||||
|
- **性能日志**: 查询耗时和系统性能指标
|
||||||
|
|
||||||
|
### 监控指标
|
||||||
|
|
||||||
|
- **接口调用量**: 各接口的QPS统计
|
||||||
|
- **存储使用量**: MinIO存储空间使用情况
|
||||||
|
- **缓存命中率**: Redis缓存效果监控
|
||||||
|
- **错误率统计**: 接口错误率和错误类型分析
|
||||||
|
|
||||||
|
## 开发指南
|
||||||
|
|
||||||
|
### 添加新接口
|
||||||
|
|
||||||
|
1. 在 `docs/textures.proto` 中定义新的RPC方法
|
||||||
|
2. 运行 `goctl rpc protoc` 生成代码
|
||||||
|
3. 在 `internal/logic/` 中实现业务逻辑
|
||||||
|
4. 添加相应的数据库查询方法(如需要)
|
||||||
|
|
||||||
|
### 自定义数据库查询
|
||||||
|
|
||||||
|
在 `internal/model/texturesModel.go` 中添加自定义查询方法:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 在 TexturesModel 接口中添加方法声明
|
||||||
|
type TexturesModel interface {
|
||||||
|
texturesModel
|
||||||
|
// 自定义方法
|
||||||
|
FindByCustomCondition(ctx context.Context, condition string) ([]*Textures, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 customTexturesModel 中实现方法
|
||||||
|
func (m *customTexturesModel) FindByCustomCondition(ctx context.Context, condition string) ([]*Textures, error) {
|
||||||
|
query := `SELECT * FROM textures WHERE custom_field = ?`
|
||||||
|
var resp []*Textures
|
||||||
|
err := m.QueryRowsNoCacheCtx(ctx, &resp, query, condition)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **上传失败**: 检查MinIO连接和存储桶配置
|
||||||
|
2. **查询缓慢**: 检查数据库索引和Redis缓存
|
||||||
|
3. **文件不一致**: 检查MinIO文件清理逻辑
|
||||||
|
4. **权限错误**: 检查用户ID和材质所有权
|
||||||
|
|
||||||
|
### 调试技巧
|
||||||
|
|
||||||
|
- 启用详细日志: 设置日志级别为 `debug`
|
||||||
|
- 检查MinIO状态: 使用MinIO控制台查看文件状态
|
||||||
|
- 监控数据库: 使用慢查询日志分析性能问题
|
||||||
21
src/app/ClientLayout.tsx
Normal file
21
src/app/ClientLayout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { SessionProvider } from 'next-auth/react';
|
||||||
|
import Navbar from '@/components/Navbar';
|
||||||
|
|
||||||
|
interface ClientLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
session: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientLayout: React.FC<ClientLayoutProps> = ({ children, session }) => {
|
||||||
|
return (
|
||||||
|
<SessionProvider session={session}>
|
||||||
|
<Navbar session={session} />
|
||||||
|
<main className="flex-grow container mx-auto px-4 py-6 sm:py-8">{children}</main>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientLayout;
|
||||||
344
src/app/api-test/page.tsx
Normal file
344
src/app/api-test/page.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
// src/app/api-test/page.tsx
|
||||||
|
// API连接测试页面
|
||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { getProfile, getProfilesByUserId, getProfileWithProperties } from '@/lib/api/profiles';
|
||||||
|
import { TextureType, searchTextures } from '@/lib/api/skins';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
// Mock数据,用于演示
|
||||||
|
const mockProfileId = 'b9b3c6d7-e8f9-4a5b-6c7d-8e9f4a5b6c7d'; // 示例UUID
|
||||||
|
const mockUserId = 1; // 示例用户ID
|
||||||
|
|
||||||
|
// 测试类型枚举
|
||||||
|
enum TestType {
|
||||||
|
PROFILE = 'profile',
|
||||||
|
USER_PROFILES = 'user-profiles',
|
||||||
|
PROFILE_PROPS = 'profile-props',
|
||||||
|
TEXTURES = 'textures',
|
||||||
|
SESSION = 'session'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化JSON输出
|
||||||
|
const formatJson = (data: any) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
return '无法格式化数据';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function APITestPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [profileId, setProfileId] = useState(mockProfileId);
|
||||||
|
const [userId, setUserId] = useState(mockUserId.toString());
|
||||||
|
const [selectedTest, setSelectedTest] = useState(TestType.PROFILE);
|
||||||
|
|
||||||
|
// 测试获取单个角色信息
|
||||||
|
const testGetProfile = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await getProfile(profileId);
|
||||||
|
setResult(formatJson(data));
|
||||||
|
} catch (err) {
|
||||||
|
setError(`获取角色信息失败: ${err instanceof Error ? err.message : '未知错误'}`);
|
||||||
|
console.error('API测试错误:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试获取用户角色列表
|
||||||
|
const testGetProfilesByUserId = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await getProfilesByUserId(parseInt(userId));
|
||||||
|
setResult(formatJson(data));
|
||||||
|
} catch (err) {
|
||||||
|
setError(`获取用户角色列表失败: ${err instanceof Error ? err.message : '未知错误'}`);
|
||||||
|
console.error('API测试错误:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试获取角色完整信息(带属性)
|
||||||
|
const testGetProfileWithProperties = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await getProfileWithProperties(profileId, true);
|
||||||
|
setResult(formatJson(data));
|
||||||
|
} catch (err) {
|
||||||
|
setError(`获取角色完整信息失败: ${err instanceof Error ? err.message : '未知错误'}`);
|
||||||
|
console.error('API测试错误:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试搜索公共材质
|
||||||
|
const testSearchTextures = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await searchTextures({
|
||||||
|
keyword: '', // 空关键词表示获取所有公开皮肤
|
||||||
|
type: TextureType.SKIN,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 5
|
||||||
|
});
|
||||||
|
setResult(formatJson(data));
|
||||||
|
} catch (err) {
|
||||||
|
setError(`搜索材质失败: ${err instanceof Error ? err.message : '未知错误'}`);
|
||||||
|
console.error('API测试错误:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试当前登录用户信息
|
||||||
|
const testSessionUser = () => {
|
||||||
|
if (session) {
|
||||||
|
setResult(formatJson({
|
||||||
|
authenticated: true,
|
||||||
|
user: session.user,
|
||||||
|
sessionExpires: session.expires
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setError('未登录,请先登录后再测试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理测试按钮点击
|
||||||
|
const handleTestClick = () => {
|
||||||
|
switch (selectedTest) {
|
||||||
|
case TestType.PROFILE:
|
||||||
|
testGetProfile();
|
||||||
|
break;
|
||||||
|
case TestType.USER_PROFILES:
|
||||||
|
testGetProfilesByUserId();
|
||||||
|
break;
|
||||||
|
case TestType.PROFILE_PROPS:
|
||||||
|
testGetProfileWithProperties();
|
||||||
|
break;
|
||||||
|
case TestType.TEXTURES:
|
||||||
|
testSearchTextures();
|
||||||
|
break;
|
||||||
|
case TestType.SESSION:
|
||||||
|
testSessionUser();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除结果
|
||||||
|
const clearResult = () => {
|
||||||
|
setResult('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染测试表单
|
||||||
|
const renderTestForm = () => {
|
||||||
|
switch (selectedTest) {
|
||||||
|
case TestType.PROFILE:
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profileId">角色UUID</Label>
|
||||||
|
<Input
|
||||||
|
id="profileId"
|
||||||
|
value={profileId}
|
||||||
|
onChange={(e) => setProfileId(e.target.value)}
|
||||||
|
placeholder="输入角色UUID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
测试是否能获取指定UUID的角色信息。如果后端没有该UUID的数据,可能会返回错误。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case TestType.USER_PROFILES:
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="userId">用户ID</Label>
|
||||||
|
<Input
|
||||||
|
id="userId"
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
|
placeholder="输入用户ID"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
测试是否能获取指定用户ID的所有角色列表。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case TestType.PROFILE_PROPS:
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profileIdProps">角色UUID</Label>
|
||||||
|
<Input
|
||||||
|
id="profileIdProps"
|
||||||
|
value={profileId}
|
||||||
|
onChange={(e) => setProfileId(e.target.value)}
|
||||||
|
placeholder="输入角色UUID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
测试是否能获取角色的完整信息,包括Minecraft协议兼容的属性。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case TestType.TEXTURES:
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
测试是否能搜索公共皮肤材质数据。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case TestType.SESSION:
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
查看当前登录用户的会话信息。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||||
|
<div className="container mx-auto px-4 max-w-5xl">
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-3xl font-bold text-indigo-700 dark:text-indigo-400 mb-4">API连接测试</h1>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||||
|
测试与后端API的连接和数据读取功能
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">测试工具</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 space-y-6">
|
||||||
|
{/* 测试类型选择 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>选择测试类型:</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={selectedTest === TestType.PROFILE ? 'default' : 'secondary'}
|
||||||
|
onClick={() => setSelectedTest(TestType.PROFILE)}
|
||||||
|
>
|
||||||
|
获取角色
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedTest === TestType.USER_PROFILES ? 'default' : 'secondary'}
|
||||||
|
onClick={() => setSelectedTest(TestType.USER_PROFILES)}
|
||||||
|
>
|
||||||
|
用户角色列表
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedTest === TestType.PROFILE_PROPS ? 'default' : 'secondary'}
|
||||||
|
onClick={() => setSelectedTest(TestType.PROFILE_PROPS)}
|
||||||
|
>
|
||||||
|
角色完整信息
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedTest === TestType.TEXTURES ? 'default' : 'secondary'}
|
||||||
|
onClick={() => setSelectedTest(TestType.TEXTURES)}
|
||||||
|
>
|
||||||
|
搜索材质
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedTest === TestType.SESSION ? 'default' : 'secondary'}
|
||||||
|
onClick={() => setSelectedTest(TestType.SESSION)}
|
||||||
|
>
|
||||||
|
会话信息
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试表单 */}
|
||||||
|
<div className="border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
|
||||||
|
{renderTestForm()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button variant="secondary" onClick={clearResult}>
|
||||||
|
清除结果
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleTestClick} disabled={loading}>
|
||||||
|
{loading ? '测试中...' : '开始测试'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结果显示区域 */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<Label className="text-lg font-medium mb-2 block">测试结果:</Label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert className="mb-4">
|
||||||
|
<AlertTitle>错误</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div className="rounded-md border p-4 bg-gray-900 text-gray-100 font-mono text-sm whitespace-pre-wrap max-h-96 overflow-y-auto">
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-40 flex items-center justify-center rounded-md border bg-gray-50 dark:bg-gray-800">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">点击"开始测试"按钮查看结果</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 帮助信息 */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>测试说明</AlertTitle>
|
||||||
|
<AlertDescription className="space-y-1">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<span className="mr-2 mt-1">•</span>
|
||||||
|
<span>如果测试成功,您将在结果区域看到从后端获取的数据</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<span className="mr-2 mt-1">•</span>
|
||||||
|
<span>如果测试失败,您将看到相应的错误信息</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<span className="mr-2 mt-1">•</span>
|
||||||
|
<span>对于需要认证的API,请确保您已经登录</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<span className="mr-2 mt-1">•</span>
|
||||||
|
<span>测试结果中的数据是实时从后端API获取的</span>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
401
src/app/api-tester/page.tsx
Normal file
401
src/app/api-tester/page.tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
// 定义日志条目类型
|
||||||
|
interface LogEntry {
|
||||||
|
type: 'info' | 'success' | 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APITesterPage: React.FC = () => {
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [isTesting, setIsTesting] = useState<boolean>(false);
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 自定义测试参数
|
||||||
|
const [customApiEndpoint, setCustomApiEndpoint] = useState<string>('/api/textures');
|
||||||
|
const [customUserId, setCustomUserId] = useState<string>('1');
|
||||||
|
const [customProfileId, setCustomProfileId] = useState<string>('1');
|
||||||
|
|
||||||
|
// 验证码测试参数
|
||||||
|
const [emailForVerification, setEmailForVerification] = useState<string>('');
|
||||||
|
const [testMethod, setTestMethod] = useState<'GET' | 'POST'>('GET');
|
||||||
|
|
||||||
|
// 添加日志条目
|
||||||
|
const addLog = (type: 'info' | 'success' | 'error', message: string) => {
|
||||||
|
setLogs(prevLogs => [...prevLogs, { type, message }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 滚动到最新日志
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行API测试
|
||||||
|
const runAPITests = async () => {
|
||||||
|
// 重置状态
|
||||||
|
setLogs([]);
|
||||||
|
setIsTesting(true);
|
||||||
|
|
||||||
|
// API端点配置
|
||||||
|
const apiEndpoints = [
|
||||||
|
{
|
||||||
|
name: '材质列表',
|
||||||
|
url: '/api/textures',
|
||||||
|
method: 'GET',
|
||||||
|
requiresAuth: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '用户角色列表',
|
||||||
|
url: `/api/user-profiles?userId=${customUserId}`,
|
||||||
|
method: 'GET',
|
||||||
|
requiresAuth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '角色详情',
|
||||||
|
url: `/api/profile?profileId=${customProfileId}`,
|
||||||
|
method: 'GET',
|
||||||
|
requiresAuth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '角色及属性',
|
||||||
|
url: `/api/profile-props?profileId=${customProfileId}`,
|
||||||
|
method: 'GET',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
addLog('info', '开始API测试...');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const endpoint of apiEndpoints) {
|
||||||
|
try {
|
||||||
|
addLog('info', `\n测试: ${endpoint.name}`);
|
||||||
|
addLog('info', `请求: ${endpoint.url}`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// 构建请求选项
|
||||||
|
const options = {
|
||||||
|
method: endpoint.method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include' // 包含cookie以处理认证
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await fetch(endpoint.url, {
|
||||||
|
...options,
|
||||||
|
credentials: options.credentials as RequestCredentials
|
||||||
|
});
|
||||||
|
const endTime = Date.now();
|
||||||
|
|
||||||
|
// 记录响应状态
|
||||||
|
addLog('info', `状态码: ${response.status}`);
|
||||||
|
addLog('info', `响应时间: ${endTime - startTime}ms`);
|
||||||
|
|
||||||
|
// 解析响应内容
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
let data = null;
|
||||||
|
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
addLog('info', `响应数据大小: ${JSON.stringify(data).length} 字节`);
|
||||||
|
|
||||||
|
// 简单验证响应数据结构
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
addLog('info', `返回数组,长度: ${data.length}`);
|
||||||
|
// 如果数组长度适中,可以显示第一条数据示例
|
||||||
|
if (data.length > 0 && data.length <= 10) {
|
||||||
|
addLog('success', `数据示例: ${JSON.stringify(data[0], null, 2).substring(0, 100)}...`);
|
||||||
|
}
|
||||||
|
} else if (typeof data === 'object' && data !== null) {
|
||||||
|
addLog('info', `返回对象,包含属性: ${Object.keys(data).join(', ')}`);
|
||||||
|
// 显示部分数据示例
|
||||||
|
addLog('success', `数据示例: ${JSON.stringify(data, null, 2).substring(0, 100)}...`);
|
||||||
|
}
|
||||||
|
} catch (jsonError) {
|
||||||
|
addLog('error', `JSON解析错误: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
addLog('info', `返回非JSON数据,长度: ${text.length} 字节`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录测试结果
|
||||||
|
results.push({
|
||||||
|
name: endpoint.name,
|
||||||
|
status: '成功',
|
||||||
|
statusCode: response.status
|
||||||
|
});
|
||||||
|
|
||||||
|
addLog('success', '测试通过!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
addLog('error', `测试失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
|
||||||
|
// 记录错误结果
|
||||||
|
results.push({
|
||||||
|
name: endpoint.name,
|
||||||
|
status: '失败',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出测试总结
|
||||||
|
addLog('info', '\n======== 测试总结 ========');
|
||||||
|
|
||||||
|
const successCount = results.filter(r => r.status === '成功').length;
|
||||||
|
const failureCount = results.filter(r => r.status === '失败').length;
|
||||||
|
|
||||||
|
addLog('info', `总测试数: ${results.length}`);
|
||||||
|
addLog('success', `成功: ${successCount}`);
|
||||||
|
addLog('error', `失败: ${failureCount}`);
|
||||||
|
|
||||||
|
if (failureCount > 0) {
|
||||||
|
addLog('info', '\n失败的测试:');
|
||||||
|
results.filter(r => r.status === '失败').forEach(r => {
|
||||||
|
addLog('error', `- ${r.name}: ${r.error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog('info', '\n测试完成!');
|
||||||
|
setIsTesting(false);
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行单个自定义API测试
|
||||||
|
const runCustomAPITest = async () => {
|
||||||
|
setLogs([{ type: 'info', message: `开始自定义API测试: ${customApiEndpoint}` }]);
|
||||||
|
setIsTesting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// API网关基础URL
|
||||||
|
const API_BASE_URL = 'https://code.littlelan.cn/CarrotSkin/APIgateway';
|
||||||
|
|
||||||
|
// 构建完整的请求URL
|
||||||
|
const fullUrl = customApiEndpoint.startsWith('http') ? customApiEndpoint : `${API_BASE_URL}${customApiEndpoint}`;
|
||||||
|
addLog('info', `完整URL: ${fullUrl}`);
|
||||||
|
|
||||||
|
// 构建请求选项
|
||||||
|
const requestOptions: RequestInit = {
|
||||||
|
method: testMethod,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果是POST请求且是验证码测试,添加请求体
|
||||||
|
if (testMethod === 'POST' && fullUrl.includes('/auth/send-code')) {
|
||||||
|
requestOptions.body = JSON.stringify({ email: emailForVerification });
|
||||||
|
addLog('info', `请求体: {"email": "${emailForVerification}"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(fullUrl, requestOptions);
|
||||||
|
const endTime = Date.now();
|
||||||
|
|
||||||
|
addLog('info', `状态码: ${response.status}`);
|
||||||
|
addLog('info', `响应时间: ${endTime - startTime}ms`);
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
addLog('info', `响应数据大小: ${JSON.stringify(data).length} 字节`);
|
||||||
|
addLog('success', `数据: ${JSON.stringify(data, null, 2).substring(0, 300)}...`);
|
||||||
|
} catch (jsonError) {
|
||||||
|
addLog('error', `JSON解析错误: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
addLog('info', `返回非JSON数据: ${text.substring(0, 300)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog('success', '自定义API测试完成!');
|
||||||
|
} catch (error) {
|
||||||
|
addLog('error', `测试失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">API连接测试工具</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>验证码发送测试</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
测试验证码发送API是否正常工作
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">邮箱地址</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={emailForVerification}
|
||||||
|
onChange={(e) => setEmailForVerification(e.target.value)}
|
||||||
|
placeholder="输入您的邮箱地址"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200 hover:border-gray-400"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">将向此邮箱发送验证码测试</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCustomApiEndpoint('/api/v1/auth/send-code');
|
||||||
|
setTestMethod('POST');
|
||||||
|
runCustomAPITest();
|
||||||
|
}}
|
||||||
|
disabled={isTesting || !emailForVerification}
|
||||||
|
className="w-full px-6 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isTesting ? '发送中...' : '发送验证码测试'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 p-3 rounded-md">
|
||||||
|
<p className="text-sm text-yellow-700">
|
||||||
|
注意:此测试将调用后端API网关(https://code.littlelan.cn/CarrotSkin/APIgateway)发送验证码,
|
||||||
|
请确保输入有效的邮箱地址以接收验证码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>测试所有API连接</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
点击下方按钮运行全部API测试,查看系统与后端的连接状态
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">用户ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customUserId}
|
||||||
|
onChange={(e) => setCustomUserId(e.target.value)}
|
||||||
|
placeholder="输入用户ID"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200 hover:border-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">角色ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customProfileId}
|
||||||
|
onChange={(e) => setCustomProfileId(e.target.value)}
|
||||||
|
placeholder="输入角色ID"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200 hover:border-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={runAPITests}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="px-6 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isTesting ? '测试中...' : '运行所有API测试'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>自定义API测试</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
输入API端点路径,测试特定的API接口
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||||
|
<select
|
||||||
|
title="选择API请求方法"
|
||||||
|
value={testMethod}
|
||||||
|
onChange={(e) => setTestMethod(e.target.value as 'GET' | 'POST')}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200 hover:border-gray-400"
|
||||||
|
>
|
||||||
|
<option value="GET">GET</option>
|
||||||
|
<option value="POST">POST</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customApiEndpoint}
|
||||||
|
onChange={(e) => setCustomApiEndpoint(e.target.value)}
|
||||||
|
placeholder="例如: /api/textures"
|
||||||
|
className="md:col-span-3 px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200 hover:border-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={runCustomAPITest}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="px-6 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isTesting ? '测试中...' : '运行自定义测试'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>测试结果</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
API测试的详细日志和结果
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="border rounded-md h-[400px] overflow-hidden bg-gray-50">
|
||||||
|
<div className="h-full p-4 text-sm overflow-auto">
|
||||||
|
{logs.map((log, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`whitespace-pre-wrap mb-1 ${log.type === 'error' ? 'text-red-600' :
|
||||||
|
log.type === 'success' ? 'text-green-600' : 'text-gray-800'}`}
|
||||||
|
>
|
||||||
|
{log.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={logsEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default APITesterPage;
|
||||||
@@ -1,15 +1,42 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Canvas2DSkinPreview from '@/components/skins/Canvas2DSkinPreview';
|
import Canvas2DSkinPreview from '@/components/skins/Canvas2DSkinPreview';
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
// 导入角色服务API
|
||||||
|
import {
|
||||||
|
createProfile,
|
||||||
|
getProfilesByUserId,
|
||||||
|
updateProfile,
|
||||||
|
deleteProfile,
|
||||||
|
getProfileWithProperties
|
||||||
|
} from '@/lib/api/profiles';
|
||||||
|
|
||||||
|
// 角色类型定义
|
||||||
|
interface Character {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
skinId?: string | null;
|
||||||
|
capeId?: string | null;
|
||||||
|
created: string;
|
||||||
|
level: number;
|
||||||
|
description?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
uuid: string;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 角色卡片组件
|
// 角色卡片组件
|
||||||
function CharacterCard({ character }: { character: any }) {
|
function CharacterCard({ character, onEdit, onDelete }: { character: Character, onEdit: (character: Character) => void, onDelete: (uuid: string) => void }) {
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden transition-all duration-300 hover:shadow-xl bg-white dark:bg-gray-800 rounded-xl w-full flex flex-col md:flex-row border border-gray-100 dark:border-gray-700">
|
<Card className="overflow-hidden transition-all duration-300 hover:shadow-xl bg-white dark:bg-gray-800 rounded-xl w-full flex flex-col md:flex-row border border-gray-100 dark:border-gray-700">
|
||||||
{/* 皮肤预览区域 */}
|
{/* 皮肤预览区域 */}
|
||||||
@@ -21,7 +48,7 @@ function CharacterCard({ character }: { character: any }) {
|
|||||||
{/* 皮肤预览 - 使用Canvas2DSkinPreview组件 */}
|
{/* 皮肤预览 - 使用Canvas2DSkinPreview组件 */}
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<Canvas2DSkinPreview
|
<Canvas2DSkinPreview
|
||||||
skinUrl={`/test-skin.png?skinId=${character.skinId}`}
|
skinUrl={character.skinId ? `/skins/${character.skinId}.png` : '/default-skin.png'}
|
||||||
size={112}
|
size={112}
|
||||||
className="transition-transform duration-500 hover:scale-110"
|
className="transition-transform duration-500 hover:scale-110"
|
||||||
/>
|
/>
|
||||||
@@ -39,20 +66,28 @@ function CharacterCard({ character }: { character: any }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300 px-3 py-1.5 rounded-full text-sm font-medium inline-block self-start">
|
<span className="bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300 px-3 py-1.5 rounded-full text-sm font-medium inline-block self-start">
|
||||||
等级 {character.level}
|
{character.isActive ? '活跃' : '非活跃'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 额外的角色信息 */}
|
{/* 额外的角色信息 */}
|
||||||
<div className="mt-3 flex flex-wrap gap-x-6 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
|
<div className="mt-3 flex flex-wrap gap-x-6 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="text-emerald-500">◆</span>
|
|
||||||
活跃状态
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="text-blue-500">◆</span>
|
<span className="text-blue-500">◆</span>
|
||||||
|
UUID: {character.uuid.substring(0, 8)}...
|
||||||
|
</span>
|
||||||
|
{character.skinId && (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-purple-500">◆</span>
|
||||||
皮肤ID: {character.skinId}
|
皮肤ID: {character.skinId}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{character.capeId && (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-red-500">◆</span>
|
||||||
|
披风ID: {character.capeId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,10 +96,24 @@ function CharacterCard({ character }: { character: any }) {
|
|||||||
<Button variant="ghost" className="rounded-lg px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
<Button variant="ghost" className="rounded-lg px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" className="rounded-lg px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-lg px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(character);
|
||||||
|
}}
|
||||||
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" className="rounded-lg px-4 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-lg px-4 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(character.uuid);
|
||||||
|
}}
|
||||||
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,21 +126,23 @@ function CharacterCard({ character }: { character: any }) {
|
|||||||
function AddCharacterCard({ onAddClick }: { onAddClick: () => void }) {
|
function AddCharacterCard({ onAddClick }: { onAddClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="overflow-hidden border-2 border-dashed border-gray-300 dark:border-gray-700 hover:border-emerald-500 dark:hover:border-emerald-400 transition-all duration-300 cursor-pointer w-full flex items-center justify-center h-32 md:h-auto bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm"
|
className="border-2 border-dashed border-emerald-300 dark:border-emerald-700/50 bg-emerald-50/70 dark:bg-emerald-900/10 hover:border-emerald-500 dark:hover:border-emerald-500/70 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all duration-300 cursor-pointer group"
|
||||||
onClick={onAddClick}
|
onClick={onAddClick}
|
||||||
>
|
>
|
||||||
<div className="w-full h-32 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 group">
|
<CardContent className="p-6 text-center">
|
||||||
<div className="text-5xl mb-2 text-gray-400 dark:text-gray-600 group-hover:text-emerald-500 dark:group-hover:text-emerald-400 transition-all duration-300 transform group-hover:scale-110">+</div>
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-emerald-100 dark:bg-emerald-800/40 flex items-center justify-center text-emerald-600 dark:text-emerald-400 group-hover:bg-emerald-200 dark:group-hover:bg-emerald-800/60 transition-all">
|
||||||
<span className="text-base font-medium group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">添加新角色</span>
|
<span className="text-2xl">+</span>
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="text-xl font-medium mb-1">添加新角色</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
点击创建你的下一个角色
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function CharacterCenterClient({ userName }: { userName: string }) {
|
||||||
|
|
||||||
// 主客户端组件
|
|
||||||
export default function CharacterCenterClient({ userName, characters }: { userName: string; characters: any[] }) {
|
|
||||||
// 使用简单的状态管理来模拟标签页
|
// 使用简单的状态管理来模拟标签页
|
||||||
const [activeTab, setActiveTab] = useState('my-characters');
|
const [activeTab, setActiveTab] = useState('my-characters');
|
||||||
|
|
||||||
@@ -104,24 +155,56 @@ export default function CharacterCenterClient({ userName, characters }: { userNa
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 生成UUID的简单方法(不使用外部库)
|
// 编辑模式状态
|
||||||
const generateUUID = () => {
|
const [editingCharacterId, setEditingCharacterId] = useState<string | null>(null);
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
||||||
const r = Math.random() * 16 | 0;
|
// 角色列表状态
|
||||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
const [characters, setCharacters] = useState<Character[]>([]);
|
||||||
return v.toString(16);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
});
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 获取会话信息
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
// 初始化时获取角色列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (session && session.user?.id) {
|
||||||
|
fetchUserCharacters(parseInt(session.user.id));
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
// 获取用户角色列表
|
||||||
|
const fetchUserCharacters = async (userId: number) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const profiles = await getProfilesByUserId(userId);
|
||||||
|
|
||||||
|
// 格式化角色数据以匹配前端需求
|
||||||
|
const formattedCharacters: Character[] = profiles.map((profile: any) => ({
|
||||||
|
id: profile.uuid,
|
||||||
|
uuid: profile.uuid,
|
||||||
|
name: profile.name,
|
||||||
|
skinId: profile.skinId != null ? String(profile.skinId) : null,
|
||||||
|
capeId: profile.capeId != null ? String(profile.capeId) : null,
|
||||||
|
created: new Date(profile.createdAt).toLocaleDateString(),
|
||||||
|
level: 1, // 简化处理,实际应用可能需要从其他服务获取
|
||||||
|
description: '', // 简化处理
|
||||||
|
isActive: true, // 简化处理
|
||||||
|
userId: profile.userId
|
||||||
|
}));
|
||||||
|
|
||||||
|
setCharacters(formattedCharacters);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取角色列表失败:', err);
|
||||||
|
setError('获取角色列表失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生成RSA密钥的简单模拟(实际项目中应在服务器端生成)
|
// 表单输入处理
|
||||||
const generateRSAKey = () => {
|
|
||||||
// 模拟RSA私钥(实际项目中应使用加密库生成)
|
|
||||||
return `-----BEGIN RSA PRIVATE KEY-----
|
|
||||||
MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|
||||||
-----END RSA PRIVATE KEY-----`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理表单输入变化
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setCharacterForm(prev => ({
|
setCharacterForm(prev => ({
|
||||||
@@ -130,7 +213,7 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理复选框变化
|
// 复选框处理
|
||||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setCharacterForm(prev => ({
|
setCharacterForm(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -138,47 +221,125 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理皮肤选择
|
// 皮肤选择处理
|
||||||
const handleSkinSelect = (skinId: string) => {
|
const handleSkinSelect = (skinId: string) => {
|
||||||
setCharacterForm(prev => ({
|
setCharacterForm(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
skinId
|
skinId: prev.skinId === skinId ? '' : skinId
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理角色创建
|
// 处理角色编辑
|
||||||
const handleCreateCharacter = async () => {
|
const handleEditCharacter = (character: Character) => {
|
||||||
if (!characterForm.name.trim()) {
|
// 设置表单为编辑模式
|
||||||
alert('请输入角色名称');
|
setCharacterForm({
|
||||||
|
name: character.name,
|
||||||
|
description: character.description || '',
|
||||||
|
skinId: character.skinId || '',
|
||||||
|
capeId: character.capeId || '',
|
||||||
|
isActive: character.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置编辑中的角色ID
|
||||||
|
setEditingCharacterId(character.uuid);
|
||||||
|
|
||||||
|
// 切换到创建/编辑标签
|
||||||
|
setActiveTab('create-character');
|
||||||
|
|
||||||
|
// 滚动到表单顶部
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理角色删除
|
||||||
|
const handleDeleteCharacter = async (uuid: string) => {
|
||||||
|
if (!confirm('确定要删除这个角色吗?此操作不可撤销。')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// 生成角色UUID
|
await deleteProfile(uuid);
|
||||||
const characterUuid = generateUUID();
|
|
||||||
|
|
||||||
// 生成RSA私钥
|
// 从本地角色列表中移除
|
||||||
const rsaPrivateKey = generateRSAKey();
|
setCharacters(prev => prev.filter(character => character.uuid !== uuid));
|
||||||
|
setSuccessMessage('角色删除成功!');
|
||||||
|
|
||||||
// 构建角色数据(根据数据库profiles表结构)
|
// 3秒后清除成功消息
|
||||||
const newCharacter = {
|
setTimeout(() => {
|
||||||
uuid: characterUuid,
|
setSuccessMessage(null);
|
||||||
user_id: 'current-user-id', // 应从会话中获取真实用户ID
|
}, 3000);
|
||||||
name: characterForm.name,
|
|
||||||
skin_id: characterForm.skinId || null,
|
} catch (err) {
|
||||||
cape_id: characterForm.capeId || null,
|
console.error('删除角色失败:', err);
|
||||||
rsa_private_key: rsaPrivateKey,
|
setError('删除角色失败,请稍后重试');
|
||||||
is_active: characterForm.isActive,
|
} finally {
|
||||||
description: characterForm.description
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 这里应该调用API发送到服务器
|
// 处理角色更新
|
||||||
console.log('创建角色数据:', newCharacter);
|
const handleUpdateCharacter = async () => {
|
||||||
|
if (!characterForm.name.trim()) {
|
||||||
|
setError('请输入角色名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 模拟成功响应
|
if (!editingCharacterId) {
|
||||||
alert('角色创建成功!\nUUID: ' + characterUuid.substring(0, 8) + '...');
|
setError('未找到要更新的角色');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 重置表单
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// 调用API更新角色
|
||||||
|
const updatedProfile = await updateProfile(editingCharacterId, {
|
||||||
|
name: characterForm.name.trim(),
|
||||||
|
description: characterForm.description,
|
||||||
|
skinId: characterForm.skinId != null && characterForm.skinId !== '' ? String(characterForm.skinId) : null,
|
||||||
|
capeId: characterForm.capeId != null && characterForm.capeId !== '' ? String(characterForm.capeId) : null,
|
||||||
|
isActive: characterForm.isActive
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新本地角色列表
|
||||||
|
setCharacters(prev => prev.map(character =>
|
||||||
|
character.uuid === editingCharacterId
|
||||||
|
? {
|
||||||
|
...character,
|
||||||
|
name: updatedProfile.name,
|
||||||
|
description: characterForm.description,
|
||||||
|
skinId: characterForm.skinId != null && characterForm.skinId !== '' ? String(characterForm.skinId) : null,
|
||||||
|
capeId: characterForm.capeId != null && characterForm.capeId !== '' ? String(characterForm.capeId) : null,
|
||||||
|
isActive: characterForm.isActive
|
||||||
|
}
|
||||||
|
: character
|
||||||
|
));
|
||||||
|
|
||||||
|
setSuccessMessage('角色更新成功!');
|
||||||
|
|
||||||
|
// 重置表单和编辑状态
|
||||||
|
resetForm();
|
||||||
|
setEditingCharacterId(null);
|
||||||
|
|
||||||
|
// 切换回我的角色标签
|
||||||
|
setActiveTab('my-characters');
|
||||||
|
|
||||||
|
// 3秒后清除成功消息
|
||||||
|
setTimeout(() => {
|
||||||
|
setSuccessMessage(null);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('更新角色失败:', err);
|
||||||
|
setError('更新角色失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置表单函数
|
||||||
|
const resetForm = () => {
|
||||||
setCharacterForm({
|
setCharacterForm({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -186,13 +347,62 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
capeId: '',
|
capeId: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理角色创建
|
||||||
|
const handleCreateCharacter = async () => {
|
||||||
|
if (!characterForm.name.trim()) {
|
||||||
|
setError('请输入角色名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
setError('请先登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// 调用API创建角色
|
||||||
|
const createdProfile = await createProfile({
|
||||||
|
name: characterForm.name.trim(),
|
||||||
|
// UUID由服务端生成
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加到本地角色列表
|
||||||
|
const newCharacter: Character = {
|
||||||
|
id: createdProfile.uuid,
|
||||||
|
uuid: createdProfile.uuid,
|
||||||
|
name: createdProfile.name,
|
||||||
|
skinId: createdProfile.skinId != null ? String(createdProfile.skinId) : null,
|
||||||
|
capeId: createdProfile.capeId != null ? String(createdProfile.capeId) : null,
|
||||||
|
created: new Date(createdProfile.createdAt).toLocaleDateString(),
|
||||||
|
level: 1,
|
||||||
|
description: characterForm.description,
|
||||||
|
isActive: characterForm.isActive,
|
||||||
|
userId: createdProfile.userId
|
||||||
|
};
|
||||||
|
|
||||||
|
setCharacters(prev => [newCharacter, ...prev]);
|
||||||
|
setSuccessMessage('角色创建成功!');
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
resetForm();
|
||||||
|
|
||||||
// 切换回我的角色标签
|
// 切换回我的角色标签
|
||||||
setActiveTab('my-characters');
|
setActiveTab('my-characters');
|
||||||
|
|
||||||
} catch (error) {
|
// 3秒后清除成功消息
|
||||||
console.error('创建角色失败:', error);
|
setTimeout(() => {
|
||||||
alert('创建角色失败,请重试');
|
setSuccessMessage(null);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('创建角色失败:', err);
|
||||||
|
setError('创建角色失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -219,20 +429,49 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
|
|
||||||
{/* 角色管理标签页 */}
|
{/* 角色管理标签页 */}
|
||||||
<div className="w-full mb-16 max-w-4xl mx-auto">
|
<div className="w-full mb-16 max-w-4xl mx-auto">
|
||||||
|
{/* 消息提示区域 */}
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mb-6">
|
||||||
|
<AlertTitle>错误</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<Alert className="mb-6">
|
||||||
|
<AlertTitle>成功</AlertTitle>
|
||||||
|
<AlertDescription>{successMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||||
{/* 高级标签按钮组 */}
|
{/* 高级标签按钮组 */}
|
||||||
<div className="bg-white/80 dark:bg-gray-800/80 rounded-xl flex p-1.5 shadow-sm backdrop-blur-sm border border-emerald-100 dark:border-emerald-900/30">
|
<div className="bg-white/80 dark:bg-gray-800/80 rounded-xl flex p-1.5 shadow-sm backdrop-blur-sm border border-emerald-100 dark:border-emerald-900/30">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('my-characters')}
|
onClick={() => {
|
||||||
|
setActiveTab('my-characters');
|
||||||
|
// 如果是从编辑模式切换过来,重置编辑状态
|
||||||
|
if (editingCharacterId) {
|
||||||
|
resetForm();
|
||||||
|
setEditingCharacterId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`px-6 py-2.5 rounded-lg transition-all duration-300 ${activeTab === 'my-characters' ? 'bg-white dark:bg-gray-700 font-medium shadow-md transform -translate-y-0.5' : 'hover:bg-gray-200 dark:hover:bg-gray-700/50'}`}
|
className={`px-6 py-2.5 rounded-lg transition-all duration-300 ${activeTab === 'my-characters' ? 'bg-white dark:bg-gray-700 font-medium shadow-md transform -translate-y-0.5' : 'hover:bg-gray-200 dark:hover:bg-gray-700/50'}`}
|
||||||
>
|
>
|
||||||
我的角色
|
我的角色
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('create-character')}
|
onClick={() => {
|
||||||
|
// 如果是从编辑模式切换到创建模式,重置状态
|
||||||
|
if (editingCharacterId) {
|
||||||
|
resetForm();
|
||||||
|
setEditingCharacterId(null);
|
||||||
|
}
|
||||||
|
setActiveTab('create-character');
|
||||||
|
}}
|
||||||
className={`px-6 py-2.5 rounded-lg transition-all duration-300 ${activeTab === 'create-character' ? 'bg-white dark:bg-gray-700 font-medium shadow-md transform -translate-y-0.5' : 'hover:bg-gray-200 dark:hover:bg-gray-700/50'}`}
|
className={`px-6 py-2.5 rounded-lg transition-all duration-300 ${activeTab === 'create-character' ? 'bg-white dark:bg-gray-700 font-medium shadow-md transform -translate-y-0.5' : 'hover:bg-gray-200 dark:hover:bg-gray-700/50'}`}
|
||||||
>
|
>
|
||||||
创建角色
|
{editingCharacterId ? '编辑角色' : '创建角色'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -244,11 +483,42 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
{/* 我的角色标签内容 */}
|
{/* 我的角色标签内容 */}
|
||||||
{activeTab === 'my-characters' && (
|
{activeTab === 'my-characters' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-emerald-500"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* 角色卡片列表 */}
|
{/* 角色卡片列表 */}
|
||||||
{characters.map((character) => (
|
{characters.length > 0 ? (
|
||||||
<CharacterCard key={character.id} character={character} />
|
characters.map((character) => (
|
||||||
))}
|
<CharacterCard
|
||||||
|
key={character.uuid}
|
||||||
|
character={character}
|
||||||
|
onEdit={handleEditCharacter}
|
||||||
|
onDelete={handleDeleteCharacter}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
!isLoading && (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-6xl mb-4">🎮</div>
|
||||||
|
<h3 className="text-xl font-bold mb-2">你还没有创建任何角色</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
点击下方按钮创建你的第一个Minecraft角色
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700"
|
||||||
|
onClick={() => setActiveTab('create-character')}
|
||||||
|
>
|
||||||
|
创建角色
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 添加新角色卡片 */}
|
{/* 添加新角色卡片 */}
|
||||||
<AddCharacterCard onAddClick={() => setActiveTab('create-character')} />
|
<AddCharacterCard onAddClick={() => setActiveTab('create-character')} />
|
||||||
@@ -259,7 +529,7 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
{/* 创建角色标签内容 */}
|
{/* 创建角色标签内容 */}
|
||||||
{activeTab === 'create-character' && (
|
{activeTab === 'create-character' && (
|
||||||
<div className="bg-white/95 dark:bg-gray-800/95 rounded-2xl p-8 shadow-xl border border-emerald-200 dark:border-emerald-900/30 backdrop-blur-sm">
|
<div className="bg-white/95 dark:bg-gray-800/95 rounded-2xl p-8 shadow-xl border border-emerald-200 dark:border-emerald-900/30 backdrop-blur-sm">
|
||||||
<h3 className="text-2xl font-bold mb-8 tracking-tight">创建新角色</h3>
|
<h3 className="text-2xl font-bold mb-8 tracking-tight">{editingCharacterId ? '编辑角色' : '创建新角色'}</h3>
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -272,6 +542,7 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
placeholder="输入Minecraft游戏内名称(16个字符以内)"
|
placeholder="输入Minecraft游戏内名称(16个字符以内)"
|
||||||
className="rounded-xl px-4 py-3 border-emerald-200 dark:border-emerald-900/30 focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
className="rounded-xl px-4 py-3 border-emerald-200 dark:border-emerald-900/30 focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||||
maxLength={16}
|
maxLength={16}
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
角色名称将作为Minecraft游戏内的用户名,请确保唯一性
|
角色名称将作为Minecraft游戏内的用户名,请确保唯一性
|
||||||
@@ -284,6 +555,7 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
<div
|
<div
|
||||||
className={`border-2 rounded-xl p-3 cursor-pointer aspect-square bg-emerald-50 dark:bg-emerald-900/20 transition-all hover:shadow-xl transform hover:-translate-y-1 relative ${characterForm.skinId === 'skin1' ? 'border-emerald-500' : 'border-emerald-200/50 dark:border-emerald-900/10'}`}
|
className={`border-2 rounded-xl p-3 cursor-pointer aspect-square bg-emerald-50 dark:bg-emerald-900/20 transition-all hover:shadow-xl transform hover:-translate-y-1 relative ${characterForm.skinId === 'skin1' ? 'border-emerald-500' : 'border-emerald-200/50 dark:border-emerald-900/10'}`}
|
||||||
onClick={() => handleSkinSelect('skin1')}
|
onClick={() => handleSkinSelect('skin1')}
|
||||||
|
aria-disabled={isLoading}
|
||||||
>
|
>
|
||||||
<Canvas2DSkinPreview
|
<Canvas2DSkinPreview
|
||||||
skinUrl="/test-skin.png"
|
skinUrl="/test-skin.png"
|
||||||
@@ -297,6 +569,7 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
<div
|
<div
|
||||||
className={`border-2 rounded-xl p-3 cursor-pointer aspect-square transition-all hover:shadow-xl transform hover:-translate-y-1 relative ${characterForm.skinId === 'skin2' ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20' : 'border-emerald-200/50 dark:border-emerald-900/10 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'}`}
|
className={`border-2 rounded-xl p-3 cursor-pointer aspect-square transition-all hover:shadow-xl transform hover:-translate-y-1 relative ${characterForm.skinId === 'skin2' ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20' : 'border-emerald-200/50 dark:border-emerald-900/10 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'}`}
|
||||||
onClick={() => handleSkinSelect('skin2')}
|
onClick={() => handleSkinSelect('skin2')}
|
||||||
|
aria-disabled={isLoading}
|
||||||
>
|
>
|
||||||
<Canvas2DSkinPreview
|
<Canvas2DSkinPreview
|
||||||
skinUrl="/test-skin2.png"
|
skinUrl="/test-skin2.png"
|
||||||
@@ -310,6 +583,7 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
<div
|
<div
|
||||||
className={`border-2 rounded-xl p-3 cursor-pointer aspect-square transition-all hover:shadow-xl transform hover:-translate-y-1 relative ${characterForm.skinId === 'skin3' ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20' : 'border-emerald-200/50 dark:border-emerald-900/10 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'}`}
|
className={`border-2 rounded-xl p-3 cursor-pointer aspect-square transition-all hover:shadow-xl transform hover:-translate-y-1 relative ${characterForm.skinId === 'skin3' ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20' : 'border-emerald-200/50 dark:border-emerald-900/10 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'}`}
|
||||||
onClick={() => handleSkinSelect('skin3')}
|
onClick={() => handleSkinSelect('skin3')}
|
||||||
|
aria-disabled={isLoading}
|
||||||
>
|
>
|
||||||
<Canvas2DSkinPreview
|
<Canvas2DSkinPreview
|
||||||
skinUrl="/test-skin3.png"
|
skinUrl="/test-skin3.png"
|
||||||
@@ -333,16 +607,19 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
placeholder="描述你的角色..."
|
placeholder="描述你的角色..."
|
||||||
className="rounded-xl px-4 py-3 border-gray-200 dark:border-gray-700 focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
className="rounded-xl px-4 py-3 border-gray-200 dark:border-gray-700 focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||||
rows={4}
|
rows={4}
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 pt-2">
|
<div className="flex items-center space-x-2 pt-2">
|
||||||
<input
|
<input
|
||||||
|
title = "设为活跃角色"
|
||||||
id="is-active"
|
id="is-active"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={characterForm.isActive}
|
checked={characterForm.isActive}
|
||||||
onChange={handleCheckboxChange}
|
onChange={handleCheckboxChange}
|
||||||
className="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
|
className="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="is-active" className="text-sm">设为活跃角色</Label>
|
<Label htmlFor="is-active" className="text-sm">设为活跃角色</Label>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,20 +637,40 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{editingCharacterId && (
|
||||||
<Button
|
<Button
|
||||||
className="w-full bg-emerald-600 hover:bg-emerald-700 py-6 rounded-xl text-base font-medium shadow-md hover:shadow-lg transition-all transform hover:-translate-y-1"
|
className="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-800 py-6 rounded-xl text-base font-medium shadow-sm hover:shadow-md transition-all"
|
||||||
onClick={handleCreateCharacter}
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
setEditingCharacterId(null);
|
||||||
|
setActiveTab('my-characters');
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
创建设置
|
取消
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className={`${editingCharacterId ? 'flex-1' : 'w-full'} bg-emerald-600 hover:bg-emerald-700 py-6 rounded-xl text-base font-medium shadow-md hover:shadow-lg transition-all transform hover:-translate-y-1`}
|
||||||
|
onClick={editingCharacterId ? handleUpdateCharacter : handleCreateCharacter}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-white"></div>
|
||||||
|
<span>{editingCharacterId ? '更新中...' : '创建中...'}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
editingCharacterId ? '保存更改' : '创建设置'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 页脚 */}
|
{/* 页脚 */}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
// src/app/dashboard/page.tsx
|
// src/app/dashboard/page.tsx
|
||||||
'use client';
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
//import { Separator } from '@/components/ui/separator';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
|
||||||
import Canvas2DSkinPreview from '@/components/skins/Canvas2DSkinPreview';
|
import Canvas2DSkinPreview from '@/components/skins/Canvas2DSkinPreview';
|
||||||
import SkinGrid from '@/components/skins/SkinGrid';
|
import { TextureInfo, TextureType, getUserTextures, getUserFavorites, addFavorite, removeFavorite, checkFavoriteStatus } from '@/lib/api/skins';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
// 由于这是客户端组件,我们不能在这里使用getServerSession
|
// 由于这是客户端组件,我们不能在这里使用getServerSession
|
||||||
@@ -21,16 +20,61 @@ export default function Dashboard() {
|
|||||||
// 状态管理
|
// 状态管理
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [activeCategory, setActiveCategory] = useState('all');
|
const [activeCategory, setActiveCategory] = useState('all');
|
||||||
|
const [skins, setSkins] = useState<TextureInfo[]>([]);
|
||||||
|
const [favorites, setFavorites] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 实际应用中这里会从API获取用户皮肤数据
|
// 加载用户皮肤
|
||||||
const mockSkins = [
|
useEffect(() => {
|
||||||
{ id: '1', name: 'Steve皮肤', createdAt: '2023-05-01' },
|
loadUserSkins();
|
||||||
{ id: '2', name: 'Alex皮肤', createdAt: '2023-05-15' },
|
}, []);
|
||||||
];
|
|
||||||
|
const loadUserSkins = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// 获取用户皮肤列表
|
||||||
|
const response = await getUserTextures({ type: TextureType.SKIN, page: 1, pageSize: 20 });
|
||||||
|
setSkins(response.textures);
|
||||||
|
|
||||||
|
// 获取用户收藏列表
|
||||||
|
const favResponse = await getUserFavorites({ page: 1, pageSize: 20 });
|
||||||
|
setFavorites(favResponse.textures);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载皮肤失败:', err);
|
||||||
|
setError('无法加载皮肤,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFavoriteToggle = async (textureId: number) => {
|
||||||
|
try {
|
||||||
|
// 检查当前收藏状态
|
||||||
|
const isCurrentlyFavorite = await checkFavoriteStatus(textureId);
|
||||||
|
|
||||||
|
if (isCurrentlyFavorite) {
|
||||||
|
// 取消收藏
|
||||||
|
await removeFavorite(textureId);
|
||||||
|
// 更新本地状态
|
||||||
|
setFavorites(favorites.filter(fav => fav.texture.id !== textureId));
|
||||||
|
} else {
|
||||||
|
// 添加收藏
|
||||||
|
await addFavorite(textureId);
|
||||||
|
// 获取更新后的收藏列表
|
||||||
|
const favResponse = await getUserFavorites({ page: 1, pageSize: 20 });
|
||||||
|
setFavorites(favResponse.textures);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('操作收藏失败:', err);
|
||||||
|
alert('操作失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 根据搜索词和分类过滤皮肤
|
// 根据搜索词和分类过滤皮肤
|
||||||
const filteredSkins = mockSkins.filter(skin =>
|
const filteredSkins = skins.filter(skin =>
|
||||||
skin.name.toLowerCase().includes(searchTerm.toLowerCase())
|
skin.id.toString().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,24 +152,63 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* 皮肤网格 */}
|
{/* 皮肤网格 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 max-w-6xl mx-auto">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 max-w-6xl mx-auto">
|
||||||
{filteredSkins.map((skin) => (
|
{loading ? (
|
||||||
|
// 加载状态
|
||||||
|
Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<Card key={index} className="overflow-hidden border border-emerald-200 dark:border-emerald-900/30 rounded-2xl hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm">
|
||||||
|
<div className="aspect-square bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="h-6 bg-gray-300 dark:bg-gray-600 rounded w-2/3 mb-2 animate-pulse"></div>
|
||||||
|
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-1/2 mb-4 animate-pulse"></div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-8 bg-gray-300 dark:bg-gray-600 rounded w-1/3 animate-pulse"></div>
|
||||||
|
<div className="h-8 bg-gray-300 dark:bg-gray-600 rounded w-1/3 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : error ? (
|
||||||
|
// 错误状态
|
||||||
|
<div className="col-span-full text-center py-12">
|
||||||
|
<p className="text-red-500 mb-4">{error}</p>
|
||||||
|
<Button onClick={loadUserSkins}>重试</Button>
|
||||||
|
</div>
|
||||||
|
) : filteredSkins.length === 0 ? (
|
||||||
|
// 空状态
|
||||||
|
<div className="col-span-full text-center py-12">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-4">你还没有上传任何皮肤</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/skins/upload">立即上传</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 正常显示皮肤列表
|
||||||
|
filteredSkins.map((skin) => (
|
||||||
<Card key={skin.id} className="overflow-hidden border border-emerald-200 dark:border-emerald-900/30 rounded-2xl hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm">
|
<Card key={skin.id} className="overflow-hidden border border-emerald-200 dark:border-emerald-900/30 rounded-2xl hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm">
|
||||||
<div className="aspect-square bg-gray-100/95 dark:bg-gray-900/95 flex items-center justify-center p-4 relative overflow-hidden group">
|
<div className="aspect-square bg-gray-100/95 dark:bg-gray-900/95 flex items-center justify-center p-4 relative overflow-hidden group">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-900/10 dark:to-teal-900/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
<div className="absolute inset-0 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-900/10 dark:to-teal-900/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
<Canvas2DSkinPreview
|
<Canvas2DSkinPreview
|
||||||
skinUrl={`/test-skin.png`}
|
skinUrl={skin.url || `/test-skin.png`}
|
||||||
size={128}
|
size={128}
|
||||||
className="max-w-full max-h-full relative z-10 transition-transform duration-500 group-hover:scale-110"
|
className="max-w-full max-h-full relative z-10 transition-transform duration-500 group-hover:scale-110"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<h3 className="font-semibold text-lg text-gray-800 dark:text-white truncate group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">{skin.name}</h3>
|
<h3 className="font-semibold text-lg text-gray-800 dark:text-white truncate group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">皮肤 #{skin.id}</h3>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-500 hover:text-rose-500 dark:hover:text-rose-400 rounded-full">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-gray-500 hover:text-rose-500 dark:hover:text-rose-400 rounded-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleFavoriteToggle(skin.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
❤️
|
❤️
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">上传于 {skin.createdAt}</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">上传于 {new Date(skin.createdAt).toLocaleDateString()}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant="ghost" className="text-xs flex-1 border border-emerald-200 dark:border-emerald-900/30 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded-lg">
|
<Button size="sm" variant="ghost" className="text-xs flex-1 border border-emerald-200 dark:border-emerald-900/30 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded-lg">
|
||||||
编辑
|
编辑
|
||||||
@@ -139,7 +222,8 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 空状态 */}
|
{/* 空状态 */}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
import './globals.css';
|
import './globals.css';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import Navbar from '@/components/Navbar';
|
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from '@/lib/api/auth';
|
import { authOptions } from '@/lib/api/auth';
|
||||||
|
|
||||||
|
// 导入客户端组件包装器
|
||||||
|
import ClientLayout from './ClientLayout';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
const grassIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAABCElEQVRYw+2W0Q6DIAxAZ3b/r7y7L0QNlKEFdLfEFyVQ+pVSCgBwB4DfGz4fQJxzGGOYc34d4DkAVVVVdQqgqioiIuccxhgAQFVVMMYg50wAY4w5gDEGKaWl0nPOoZQiAGPM7wGMMXDOwVqLtm2Rc0bXdTDGwDmHnPN/ADHGdF2HEAJijOj7HjFGpJTQ9z36vkeMESkl9H0PYwystYgxLgHGGHDOwTmHtm3hnEPbtmCMgXO+6DkA1lo45+C9R0oJwzAgpQRrLZxz8N4jpYRhGJBSgrUWzjlYa5cAzjkYY2CMQdM0YIyBMQbOOQEAwzCgrmtUVYW6rjEMA4ZhgDEGTdOAMQbOOQEA4ziiqio0TYO2bTGbYRiQc0bTNGiaBm3bYjY5Z4zjiKqqMAwD6rqGtRYpJYzjiJwzrLVomgZt22I2OWeM44i6rjEMA+q6RkoJ4zgi5wxrLZqmwWyGYUDOGXVdYxxH1HWNlBLGcUTOGdZaNE2D2QzDgJwz6rrGOI6o6xopJYzjiJwzrLVomgazGYYBOWfUdY1xHFHXNVJKGMfxfwB1XWO2OWeM44i6rjGOI+q6RkoJ4zgi5wxrLZqmwWyGYUDOGXVdYxxH1HWNlBLGcUTOGdZaNE2D2QzDgJwz6rrGOI6o6xopJYzj+AAQY4QQQgghhBBCCCGEkHv7AQm0VqDdH3i9AAAAAElFTkSuQmCC';
|
const grassIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAABCElEQVRYw+2W0Q6DIAxAZ3b/r7y7L0QNlKEFdLfEFyVQ+pVSCgBwB4DfGz4fQJxzGGOYc34d4DkAVVVVdQqgqioiIuccxhgAQFVVMMYg50wAY4w5gDEGKaWl0nPOoZQiAGPM7wGMMXDOwVqLtm2Rc0bXdTDGwDmHnPN/ADHGdF2HEAJijOj7HjFGpJTQ9z36vkeMESkl9H0PYwystYgxLgHGGHDOwTmHtm3hnEPbtmCMgXO+6DkA1lo45+C9R0oJwzAgpQRrLZxz8N4jpYRhGJBSgrUWzjlYa5cAzjkYY2CMQdM0YIyBMQbOOQEAwzCgrmtUVYW6rjEMA4ZhgDEGTdOAMQbOOQEA4ziiqio0TYO2bTGbYRiQc0bTNGiaBm3bYjY5Z4zjiKqqMAwD6rqGtRYpJYzjiJwzrLVomgZt22I2OWeM44i6rjEMA+q6RkoJ4zgi5wxrLZqmwWyGYUDOGXVdYxxH1HWNlBLGcUTOGdZaNE2D2QzDgJwz6rrGOI6o6xopJYzjiJwzrLVomgazGYYBOWfUdY1xHFHXNVJKGMfxfwB1XWO2OWeM44i6rjGOI+q6RkoJ4zgi5wxrLZqmwWyGYUDOGXVdYxxH1HWNlBLGcUTOGdZaNE2D2QzDgJwz6rrGOI6o6xopJYzj+AAQY4QQQgghhBBCCCGEkHv7AQm0VqDdH3i9AAAAAElFTkSuQmCC';
|
||||||
|
|
||||||
@@ -17,11 +19,8 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
// 根布局组件 - 服务器组件
|
||||||
children,
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
// 在服务器端获取会话状态
|
// 在服务器端获取会话状态
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
@@ -34,8 +33,9 @@ export default async function RootLayout({
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className={`${inter.className} bg-gray-50 dark:bg-gray-900 min-h-screen flex flex-col`}>
|
<body className={`${inter.className} bg-gray-50 dark:bg-gray-900 min-h-screen flex flex-col`}>
|
||||||
<Navbar session={session} />
|
<ClientLayout session={session}>
|
||||||
<main className="flex-grow container mx-auto px-4 py-6 sm:py-8">{children}</main>
|
{children}
|
||||||
|
</ClientLayout>
|
||||||
<footer className="bg-gray-800 text-white py-4 text-center text-sm">
|
<footer className="bg-gray-800 text-white py-4 text-center text-sm">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
© 2024 我的世界皮肤库 - 为Minecraft玩家打造的皮肤分享平台
|
© 2024 我的世界皮肤库 - 为Minecraft玩家打造的皮肤分享平台
|
||||||
|
|||||||
209
src/app/simple-api-test/page.tsx
Normal file
209
src/app/simple-api-test/page.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
const SimpleAPITestPage: React.FC = () => {
|
||||||
|
const [response, setResponse] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [apiEndpoint, setApiEndpoint] = useState<string>('textures'); // 默认测试textures端点
|
||||||
|
const [userId, setUserId] = useState<string>('1'); // 默认用户ID
|
||||||
|
const [textureName, setTextureName] = useState<string>('');
|
||||||
|
const [profileId, setProfileId] = useState<string>('1'); // 默认角色ID
|
||||||
|
|
||||||
|
// 执行API测试
|
||||||
|
const executeAPITest = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setResponse('');
|
||||||
|
|
||||||
|
let url = '';
|
||||||
|
|
||||||
|
// 根据选择的端点构建URL
|
||||||
|
switch (apiEndpoint) {
|
||||||
|
case 'user-profiles':
|
||||||
|
url = `/api/user-profiles?userId=${userId}`;
|
||||||
|
break;
|
||||||
|
case 'profile':
|
||||||
|
url = `/api/profile?profileId=${profileId}`;
|
||||||
|
break;
|
||||||
|
case 'profile-props':
|
||||||
|
url = `/api/profile-props?profileId=${profileId}`;
|
||||||
|
break;
|
||||||
|
case 'textures':
|
||||||
|
url = textureName ?
|
||||||
|
`/api/textures?name=${encodeURIComponent(textureName)}` :
|
||||||
|
'/api/textures';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error('未知的API端点');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`正在请求: ${url}`);
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP错误! 状态码: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setResponse(JSON.stringify(data, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
setError(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染参数输入表单
|
||||||
|
const renderParamsForm = () => {
|
||||||
|
switch (apiEndpoint) {
|
||||||
|
case 'user-profiles':
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="userId">用户ID</Label>
|
||||||
|
<Input
|
||||||
|
id="userId"
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
|
placeholder="输入用户ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'profile':
|
||||||
|
case 'profile-props':
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profileId">角色ID</Label>
|
||||||
|
<Input
|
||||||
|
id="profileId"
|
||||||
|
value={profileId}
|
||||||
|
onChange={(e) => setProfileId(e.target.value)}
|
||||||
|
placeholder="输入角色ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'textures':
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="textureName">材质名称 (可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="textureName"
|
||||||
|
value={textureName}
|
||||||
|
onChange={(e) => setTextureName(e.target.value)}
|
||||||
|
placeholder="输入材质名称进行搜索"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">简易API测试页面</h1>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>测试后端API连接</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
选择要测试的API端点,输入必要的参数,然后点击"执行测试"按钮
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* API端点选择 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>选择API端点</Label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Button
|
||||||
|
variant={apiEndpoint === 'user-profiles' ? "default" : "secondary"}
|
||||||
|
onClick={() => setApiEndpoint('user-profiles')}
|
||||||
|
className="justify-start text-sm"
|
||||||
|
>
|
||||||
|
用户角色列表
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={apiEndpoint === 'profile' ? "default" : "secondary"}
|
||||||
|
onClick={() => setApiEndpoint('profile')}
|
||||||
|
className="justify-start text-sm"
|
||||||
|
>
|
||||||
|
角色详情
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={apiEndpoint === 'profile-props' ? "default" : "secondary"}
|
||||||
|
onClick={() => setApiEndpoint('profile-props')}
|
||||||
|
className="justify-start text-sm"
|
||||||
|
>
|
||||||
|
角色及属性
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={apiEndpoint === 'textures' ? "default" : "secondary"}
|
||||||
|
onClick={() => setApiEndpoint('textures')}
|
||||||
|
className="justify-start text-sm"
|
||||||
|
>
|
||||||
|
材质列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 参数输入表单 */}
|
||||||
|
{renderParamsForm()}
|
||||||
|
|
||||||
|
{/* 执行按钮 */}
|
||||||
|
<Button
|
||||||
|
onClick={executeAPITest}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{loading ? '执行中...' : '执行测试'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 结果显示区域 */}
|
||||||
|
{(response || error) && (
|
||||||
|
<div className="mt-4 p-4 rounded-md border text-sm"
|
||||||
|
style={{
|
||||||
|
borderColor: error ? 'rgb(248 113 113)' : 'rgb(203 213 225)',
|
||||||
|
backgroundColor: error ? 'rgb(254 242 242)' : 'rgb(248 250 252)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<div className="text-red-600 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap overflow-auto">
|
||||||
|
{response}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
测试结果将显示在上方区域
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SimpleAPITestPage;
|
||||||
@@ -6,7 +6,9 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { UploadIcon } from 'lucide-react';
|
import { UploadIcon } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import Canvas2DSkinPreview from '@/components/skins/Canvas2DSkinPreview';
|
import Canvas2DSkinPreview from '@/components/skins/Canvas2DSkinPreview';
|
||||||
|
import { TextureType, uploadTexture } from '@/lib/api/skins';
|
||||||
|
|
||||||
export default function SkinUploadPage() {
|
export default function SkinUploadPage() {
|
||||||
const [skinFile, setSkinFile] = useState<File | null>(null);
|
const [skinFile, setSkinFile] = useState<File | null>(null);
|
||||||
@@ -37,17 +39,19 @@ export default function SkinUploadPage() {
|
|||||||
}
|
}
|
||||||
}, [skinFile]);
|
}, [skinFile]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPublic, setIsPublic] = useState(true);
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
if (!skinFile) return;
|
if (!skinFile) return;
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
// 实际应用中这里会调用API上传皮肤
|
// 调用API上传皮肤
|
||||||
console.log('上传皮肤文件:', skinFile.name);
|
await uploadTexture(skinFile, TextureType.SKIN, isPublic);
|
||||||
// 模拟上传延迟
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
// 上传成功后跳转到个人皮肤页面
|
||||||
alert('皮肤上传成功!');
|
router.push('/dashboard');
|
||||||
setSkinFile(null);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上传失败:', error);
|
console.error('上传失败:', error);
|
||||||
alert('皮肤上传失败,请重试');
|
alert('皮肤上传失败,请重试');
|
||||||
@@ -96,7 +100,7 @@ export default function SkinUploadPage() {
|
|||||||
<UploadIcon className="mx-auto h-10 w-10 text-emerald-500 dark:text-emerald-400 transition-transform duration-300 group-hover:scale-110" />
|
<UploadIcon className="mx-auto h-10 w-10 text-emerald-500 dark:text-emerald-400 transition-transform duration-300 group-hover:scale-110" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">拖放PNG格式的皮肤文件到这里,或点击选择文件</p>
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">拖放PNG格式的皮肤文件到这里,或点击选择文件</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">标准皮肤尺寸应为64x32像素</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400">标准皮肤尺寸应为64x32或64x64像素</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,15 +124,45 @@ export default function SkinUploadPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 pt-4">
|
||||||
|
<input
|
||||||
|
id="public-toggle"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isPublic}
|
||||||
|
onChange={(e) => setIsPublic(e.target.checked)}
|
||||||
|
className="rounded text-emerald-600 focus:ring-emerald-500 border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="public-toggle" className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
公开到皮肤广场
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="bg-emerald-50/30 dark:bg-gray-800/50 border-t border-emerald-100 dark:border-gray-700 py-6 px-6">
|
<CardFooter className="bg-emerald-50/50 dark:bg-gray-800/80 border-t border-emerald-100 dark:border-gray-700 py-6 flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSkinFile(null)}
|
||||||
|
disabled={!skinFile || isUploading}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={!skinFile || isUploading}
|
disabled={!skinFile || isUploading}
|
||||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white py-6 transition-all duration-300 transform hover:-translate-y-1 shadow-md hover:shadow-lg"
|
className="bg-emerald-600 hover:bg-emerald-700"
|
||||||
>
|
>
|
||||||
{isUploading ? '上传中...' : '上传皮肤'}
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
上传中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'上传皮肤'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
477
src/app/verify-code-test/page.tsx
Normal file
477
src/app/verify-code-test/page.tsx
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
// 验证码测试工具 - 专注于验证功能的测试
|
||||||
|
// 注意:尽管控制台显示NextAuth错误,但这不影响本工具的核心功能
|
||||||
|
export default function VerifyCodeTest() {
|
||||||
|
// 状态管理
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [messageType, setMessageType] = useState<'success' | 'error' | 'info'>('info');
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [testMode, setTestMode] = useState<'real' | 'simulation'>('simulation');
|
||||||
|
|
||||||
|
// 添加CSS样式
|
||||||
|
useEffect(() => {
|
||||||
|
// 避免水合错误的样式注入方式
|
||||||
|
const styleId = 'verify-code-test-styles';
|
||||||
|
if (!document.getElementById(styleId)) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = styleId;
|
||||||
|
style.textContent = `
|
||||||
|
.verify-code-container {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(to bottom, #f0f4f8, #e2e8f0);
|
||||||
|
}
|
||||||
|
.verify-code-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.verify-code-header h1 {
|
||||||
|
color: #3730a3;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.verify-code-header p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.verify-code-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.verify-code-form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.verify-code-form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.verify-code-form-group input[type="email"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.verify-code-form-group input[type="email"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
.verify-code-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.verify-code-btn-primary {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.verify-code-btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
.verify-code-btn-primary:disabled {
|
||||||
|
background-color: #d1d5db;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.verify-code-message {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
.verify-code-message-success {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
}
|
||||||
|
.verify-code-message-error {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
.verify-code-info-box {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #eff6ff;
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.verify-code-info-box h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.verify-code-info-box p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.verify-code-info-box ul {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
.verify-code-test-mode {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.verify-code-test-mode-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.verify-code-test-mode-options label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.verify-code-test-mode-description {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.verify-code-logs-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.verify-code-logs-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.verify-code-logs-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.verify-code-clear-logs-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #3b82f6;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.verify-code-clear-logs-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.verify-code-logs {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.verify-code-logs-empty {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const style = document.getElementById(styleId);
|
||||||
|
if (style) {
|
||||||
|
style.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 添加日志
|
||||||
|
const addLog = (text: string) => {
|
||||||
|
setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${text}`]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除日志
|
||||||
|
const clearLogs = () => {
|
||||||
|
setLogs([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送验证码
|
||||||
|
const sendVerificationCode = async () => {
|
||||||
|
// 重置状态
|
||||||
|
setMessage('');
|
||||||
|
setLogs([]);
|
||||||
|
setIsSending(true);
|
||||||
|
addLog('开始发送验证码...');
|
||||||
|
addLog(`测试模式: ${testMode === 'real' ? '真实API调用' : '本地模拟'}`);
|
||||||
|
|
||||||
|
// 验证邮箱格式
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
setMessageType('error');
|
||||||
|
setMessage('请输入有效的邮箱地址');
|
||||||
|
setIsSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟模式
|
||||||
|
if (testMode === 'simulation') {
|
||||||
|
try {
|
||||||
|
addLog(`模拟发送验证码到: ${email}`);
|
||||||
|
|
||||||
|
// 模拟后端处理流程
|
||||||
|
addLog('模拟验证邮箱格式...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
addLog('模拟生成6位数字验证码...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// 生成测试验证码
|
||||||
|
const testCode = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
addLog(`模拟验证码已生成: ${testCode}`);
|
||||||
|
|
||||||
|
addLog('模拟存储验证码到Redis (10分钟有效期)...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
addLog('模拟发送HTML格式邮件...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
addLog('模拟返回成功响应');
|
||||||
|
|
||||||
|
setMessageType('success');
|
||||||
|
setMessage(`模拟发送成功!测试验证码: ${testCode}`);
|
||||||
|
} catch (error) {
|
||||||
|
addLog(`模拟异常: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
setMessageType('error');
|
||||||
|
setMessage(`模拟失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真实API调用模式
|
||||||
|
try {
|
||||||
|
// 根据后端架构文档中的API网关信息
|
||||||
|
const API_ENDPOINTS = [
|
||||||
|
{
|
||||||
|
name: 'API网关v1版本',
|
||||||
|
url: 'https://code.littlelan.cn/CarrotSkin/APIgateway/api/v1/auth/send-code'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API网关v2版本',
|
||||||
|
url: 'https://code.littlelan.cn/CarrotSkin/APIgateway/api/v2/auth/send-code'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '直接API',
|
||||||
|
url: 'https://code.littlelan.cn/CarrotSkin/api/auth/send-code'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 测试多个可能的端点
|
||||||
|
for (const endpoint of API_ENDPOINTS) {
|
||||||
|
addLog(`\n=== 测试 ${endpoint.name} ===`);
|
||||||
|
addLog(`请求URL: ${endpoint.url}`);
|
||||||
|
addLog(`请求参数: {"email": "${email}"}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 发送请求
|
||||||
|
const startTime = Date.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8秒超时
|
||||||
|
|
||||||
|
const response = await fetch(endpoint.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
signal: controller.signal,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const endTime = Date.now();
|
||||||
|
|
||||||
|
addLog(`响应状态码: ${response.status}`);
|
||||||
|
addLog(`响应时间: ${endTime - startTime}ms`);
|
||||||
|
|
||||||
|
// 解析响应
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
const data = await response.json();
|
||||||
|
addLog(`响应数据: ${JSON.stringify(data)}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setMessageType('success');
|
||||||
|
setMessage(`验证码发送成功,请检查您的邮箱 (使用 ${endpoint.name})`);
|
||||||
|
setIsSending(false);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
addLog(`请求失败: ${data.message || response.status}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
addLog(`非JSON响应: ${text.substring(0, 150)}${text.length > 150 ? '...' : ''}`);
|
||||||
|
}
|
||||||
|
} catch (fetchError) {
|
||||||
|
if (fetchError.name === 'AbortError') {
|
||||||
|
addLog('请求超时 (8秒)');
|
||||||
|
} else {
|
||||||
|
addLog(`请求错误: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 诊断信息
|
||||||
|
addLog('\n=== 诊断信息 ===');
|
||||||
|
addLog('1. 后端API网关可能未启动或网络不可达');
|
||||||
|
addLog('2. 请检查API端点地址是否正确');
|
||||||
|
addLog('3. 根据架构文档,端点应位于auth_handler.go中');
|
||||||
|
addLog('4. 验证码服务使用Redis存储(10分钟有效期)');
|
||||||
|
addLog('5. 建议使用模拟模式测试前端功能');
|
||||||
|
|
||||||
|
setMessageType('error');
|
||||||
|
setMessage('所有API端点连接失败,请检查后端服务状态');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
addLog(`系统异常: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
setMessageType('error');
|
||||||
|
setMessage(`系统错误: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="verify-code-container">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="verify-code-header">
|
||||||
|
<h1>验证码测试工具</h1>
|
||||||
|
<p>测试验证码发送功能,基于提供的后端架构文档</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试表单 */}
|
||||||
|
<div className="verify-code-card">
|
||||||
|
<h2>发送验证码</h2>
|
||||||
|
|
||||||
|
{/* 邮箱输入 */}
|
||||||
|
<div className="verify-code-form-group">
|
||||||
|
<label htmlFor="email">邮箱地址</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="请输入邮箱地址"
|
||||||
|
disabled={isSending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 发送按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={sendVerificationCode}
|
||||||
|
disabled={isSending || !email}
|
||||||
|
className="verify-code-btn verify-code-btn-primary"
|
||||||
|
>
|
||||||
|
{isSending ? '发送中...' : '发送验证码'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 消息显示 */}
|
||||||
|
{message && (
|
||||||
|
<div className={`verify-code-message verify-code-message-${messageType}`}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 后端架构信息 */}
|
||||||
|
<div className="verify-code-info-box">
|
||||||
|
<h3>后端架构信息</h3>
|
||||||
|
<p>基于提供的后端架构文档:</p>
|
||||||
|
<ul>
|
||||||
|
<li>认证模块: internal/handler/auth_handler.go</li>
|
||||||
|
<li>邮箱验证码模块: internal/service/verification_service.go</li>
|
||||||
|
<li>验证码: 6位数字,Redis存储(10分钟有效期)</li>
|
||||||
|
<li>发送频率: 限制1分钟</li>
|
||||||
|
<li>邮件格式: HTML格式</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试模式选择 */}
|
||||||
|
<div className="verify-code-test-mode">
|
||||||
|
<label>测试模式</label>
|
||||||
|
<div className="verify-code-test-mode-options">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="testMode"
|
||||||
|
value="real"
|
||||||
|
checked={testMode === 'real'}
|
||||||
|
onChange={() => setTestMode('real')}
|
||||||
|
/>
|
||||||
|
真实API调用
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="testMode"
|
||||||
|
value="simulation"
|
||||||
|
checked={testMode === 'simulation'}
|
||||||
|
onChange={() => setTestMode('simulation')}
|
||||||
|
/>
|
||||||
|
本地模拟
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="verify-code-test-mode-description">
|
||||||
|
{testMode === 'real'
|
||||||
|
? '将实际调用后端API发送验证码'
|
||||||
|
: '模拟验证码发送过程,生成测试验证码'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试日志 */}
|
||||||
|
<div className="verify-code-logs-container">
|
||||||
|
<div className="verify-code-logs-header">
|
||||||
|
<h3>测试日志</h3>
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearLogs}
|
||||||
|
className="verify-code-clear-logs-btn"
|
||||||
|
>
|
||||||
|
清除日志
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="verify-code-logs">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="verify-code-logs-empty">暂无日志</p>
|
||||||
|
) : (
|
||||||
|
logs.map((log, index) => (
|
||||||
|
<div key={index}>{log}</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/ui/scroll-area.tsx
Normal file
27
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// ScrollArea 组件接口定义
|
||||||
|
export interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个简单的滚动区域组件,用于内容超出容器时提供滚动功能
|
||||||
|
*/
|
||||||
|
export const ScrollArea: React.FC<ScrollAreaProps> = ({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`overflow-auto ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollArea;
|
||||||
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// 创建Select上下文
|
||||||
|
const SelectContext = React.createContext<{
|
||||||
|
value: string;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Select组件接口
|
||||||
|
export interface SelectProps {
|
||||||
|
defaultValue?: string;
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectTrigger组件接口
|
||||||
|
export interface SelectTriggerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectValue组件接口
|
||||||
|
export interface SelectValueProps {
|
||||||
|
placeholder?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectContent组件接口
|
||||||
|
export interface SelectContentProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectItem组件接口
|
||||||
|
export interface SelectItemProps {
|
||||||
|
value: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select组件 - 管理选择器的状态
|
||||||
|
*/
|
||||||
|
export const Select: React.FC<SelectProps> = ({
|
||||||
|
defaultValue = '',
|
||||||
|
value: valueProp,
|
||||||
|
onValueChange,
|
||||||
|
children,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const [valueState, setValueState] = React.useState(defaultValue);
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const value = valueProp !== undefined ? valueProp : valueState;
|
||||||
|
const setValue = valueProp !== undefined && onValueChange
|
||||||
|
? onValueChange
|
||||||
|
: setValueState;
|
||||||
|
|
||||||
|
// 点击外部关闭下拉菜单
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest(`.${className}`) && !target.closest('.select-trigger')) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [open, className]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectContext.Provider value={{ value, setValue, open, setOpen }}>
|
||||||
|
<div className={className}>{children}</div>
|
||||||
|
</SelectContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SelectTrigger组件 - 选择器的触发器按钮
|
||||||
|
*/
|
||||||
|
export const SelectTrigger: React.FC<SelectTriggerProps> = ({ children, className = '' }) => {
|
||||||
|
const context = React.useContext(SelectContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('SelectTrigger must be used within a Select component');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
context.setOpen(!context.open);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`px-4 py-2 border rounded-md flex items-center justify-between w-full select-trigger ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span className="ml-2">▼</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SelectValue组件 - 显示当前选中的值或占位符
|
||||||
|
*/
|
||||||
|
export const SelectValue: React.FC<SelectValueProps> = ({ placeholder, children }) => {
|
||||||
|
const context = React.useContext(SelectContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('SelectValue must be used within a Select component');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.value && children) {
|
||||||
|
return <span>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return placeholder ? (
|
||||||
|
<span className="text-gray-500">{placeholder}</span>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SelectContent组件 - 包含选项的下拉菜单
|
||||||
|
*/
|
||||||
|
export const SelectContent: React.FC<SelectContentProps> = ({ children, className = '' }) => {
|
||||||
|
const context = React.useContext(SelectContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('SelectContent must be used within a Select component');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`absolute mt-1 border rounded-md bg-white shadow-lg z-10 max-h-60 overflow-auto ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SelectItem组件 - 选择器中的单个选项
|
||||||
|
*/
|
||||||
|
export const SelectItem: React.FC<SelectItemProps> = ({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const context = React.useContext(SelectContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('SelectItem must be used within a Select component');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelected = context.value === value;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
context.setValue(value);
|
||||||
|
context.setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`block w-full text-left px-4 py-2 hover:bg-gray-100 ${isSelected ? 'bg-blue-50 text-blue-500' : ''} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Select;
|
||||||
130
src/components/ui/tabs.tsx
Normal file
130
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// 创建Tabs上下文
|
||||||
|
const TabsContext = React.createContext<{
|
||||||
|
value: string;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Tabs组件接口
|
||||||
|
export interface TabsProps {
|
||||||
|
defaultValue?: string;
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabsList组件接口
|
||||||
|
export interface TabsListProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabsTrigger组件接口
|
||||||
|
export interface TabsTriggerProps {
|
||||||
|
value: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabsContent组件接口
|
||||||
|
export interface TabsContentProps {
|
||||||
|
value: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabs组件 - 管理标签页的状态
|
||||||
|
*/
|
||||||
|
export const Tabs: React.FC<TabsProps> = ({
|
||||||
|
defaultValue = '',
|
||||||
|
value: valueProp,
|
||||||
|
onValueChange,
|
||||||
|
children,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const [valueState, setValueState] = React.useState(defaultValue);
|
||||||
|
const value = valueProp !== undefined ? valueProp : valueState;
|
||||||
|
const setValue = valueProp !== undefined && onValueChange
|
||||||
|
? onValueChange
|
||||||
|
: setValueState;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContext.Provider value={{ value, setValue }}>
|
||||||
|
<div className={className}>{children}</div>
|
||||||
|
</TabsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TabsList组件 - 包含标签页触发器的容器
|
||||||
|
*/
|
||||||
|
export const TabsList: React.FC<TabsListProps> = ({ children, className = '' }) => {
|
||||||
|
return (
|
||||||
|
<div className={`flex ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TabsTrigger组件 - 标签页的触发器按钮
|
||||||
|
*/
|
||||||
|
export const TabsTrigger: React.FC<TabsTriggerProps> = ({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const context = React.useContext(TabsContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('TabsTrigger must be used within a Tabs component');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = context.value === value;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
context.setValue(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`px-4 py-2 border-b-2 ${isActive ? 'border-blue-500 text-blue-500' : 'border-transparent'} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TabsContent组件 - 显示当前选中标签页的内容
|
||||||
|
*/
|
||||||
|
export const TabsContent: React.FC<TabsContentProps> = ({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const context = React.useContext(TabsContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('TabsContent must be used within a Tabs component');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = context.value === value;
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tabs;
|
||||||
@@ -4,8 +4,8 @@ import axios from 'axios';
|
|||||||
|
|
||||||
// 配置axios实例,统一处理API请求
|
// 配置axios实例,统一处理API请求
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
//将baseURL改为实际服务器地址
|
// 使用配置的API基础URL
|
||||||
baseURL: process.env.NEXT_PUBLIC_API_URL || '/api',
|
baseURL: process.env.NEXT_PUBLIC_API_URL || 'https://code.littlelan.cn/CarrotSkin/APIgateway',
|
||||||
timeout: 10000, // 设置10秒超时
|
timeout: 10000, // 设置10秒超时
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -64,6 +64,13 @@ apiClient.interceptors.response.use(
|
|||||||
export const serverSignOut = async () => {
|
export const serverSignOut = async () => {
|
||||||
console.log('serverSignOut函数执行开始');
|
console.log('serverSignOut函数执行开始');
|
||||||
|
|
||||||
|
// 调用API退出登录
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/v1/auth/logout');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API退出登录失败,继续执行本地清理:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// 使用更直接的方式清除会话并重定向
|
// 使用更直接的方式清除会话并重定向
|
||||||
// 1. 首先清除所有可能的cookie
|
// 1. 首先清除所有可能的cookie
|
||||||
const { cookies } = await import('next/headers');
|
const { cookies } = await import('next/headers');
|
||||||
@@ -143,7 +150,7 @@ export const login = async (credentials: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.post('/auth/login', credentials);
|
const response = await apiClient.post('/api/v1/auth/login', credentials);
|
||||||
return { success: true, ...response.data };
|
return { success: true, ...response.data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('登录失败:', error);
|
console.error('登录失败:', error);
|
||||||
@@ -170,7 +177,7 @@ export const getVerificationCode = async (email: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用API获取验证码
|
// 调用API获取验证码
|
||||||
const response = await apiClient.post('/auth/get-verification-code', { email });
|
const response = await apiClient.post('/api/v1/auth/send-code', { email });
|
||||||
return { success: true, ...response.data };
|
return { success: true, ...response.data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取验证码失败:', error);
|
console.error('获取验证码失败:', error);
|
||||||
@@ -206,7 +213,7 @@ export const verifyCode = async (email: string, code: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用API验证验证码
|
// 调用API验证验证码
|
||||||
const response = await apiClient.post('/auth/verify-code', { email, code });
|
const response = await apiClient.post('/api/v1/auth/verify-code', { email, code });
|
||||||
return { success: true, ...response.data };
|
return { success: true, ...response.data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('验证码验证失败:', error);
|
console.error('验证码验证失败:', error);
|
||||||
@@ -257,7 +264,7 @@ export const resetPassword = async (email: string, username: string, newPassword
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用API重置密码
|
// 调用API重置密码
|
||||||
const response = await apiClient.post('/auth/reset-password', {
|
const response = await apiClient.post('/api/v1/auth/reset-password', {
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
newPassword
|
newPassword
|
||||||
@@ -318,7 +325,7 @@ export const register = async (userData: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 实际环境中调用API
|
// 实际环境中调用API
|
||||||
const response = await apiClient.post('/auth/register', userData);
|
const response = await apiClient.post('/api/v1/auth/register', userData);
|
||||||
return { success: true, ...response.data };
|
return { success: true, ...response.data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('注册失败:', error);
|
console.error('注册失败:', error);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import axios from 'axios';
|
|||||||
|
|
||||||
// 配置axios实例,与actions.ts保持一致
|
// 配置axios实例,与actions.ts保持一致
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: process.env.NEXT_PUBLIC_API_URL || '/api',
|
baseURL: process.env.NEXT_PUBLIC_API_URL || 'https://code.littlelan.cn/CarrotSkin/APIgateway',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -42,31 +42,41 @@ apiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
declare module "next-auth" {
|
// 测试账号配置 - 从环境变量获取
|
||||||
interface Session {
|
const TEST_USERNAME = process.env.TEST_USERNAME || 'test';
|
||||||
user: {
|
const TEST_PASSWORD = process.env.TEST_PASSWORD || 'test';
|
||||||
id: string;
|
|
||||||
name: string;
|
// 定义authOptions配置
|
||||||
email: string;
|
const authOptions: AuthOptions = {
|
||||||
minecraftUsername?: string;
|
// 配置认证提供程序
|
||||||
} ;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 定义 authOptions 并导出
|
|
||||||
export const authOptions: AuthOptions = {
|
|
||||||
providers: [
|
providers: [
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
|
// 凭证登录的名称和描述
|
||||||
name: 'Credentials',
|
name: 'Credentials',
|
||||||
credentials: {
|
credentials: {
|
||||||
username: { label: "用户名", type: "text" },
|
username: {
|
||||||
email: { label: "邮箱", type: "email" },
|
label: '用户名',
|
||||||
password: { label: "密码", type: "password" }
|
type: 'text',
|
||||||
|
placeholder: '请输入用户名或邮箱',
|
||||||
},
|
},
|
||||||
|
email: {
|
||||||
|
label: '邮箱',
|
||||||
|
type: 'email',
|
||||||
|
placeholder: '请输入邮箱',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: '密码',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: '请输入密码',
|
||||||
|
},
|
||||||
|
verificationCode: {
|
||||||
|
label: '验证码',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '请输入验证码',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 认证逻辑处理函数
|
||||||
async authorize(credentials) {
|
async authorize(credentials) {
|
||||||
// 测试账号配置 - 从环境变量获取
|
|
||||||
const TEST_USERNAME = process.env.TEST_USERNAME || 'test';
|
|
||||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || 'test';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查是否是测试账号 - 支持通过username或email字段登录
|
// 检查是否是测试账号 - 支持通过username或email字段登录
|
||||||
const usernameField = credentials?.username || credentials?.email;
|
const usernameField = credentials?.username || credentials?.email;
|
||||||
@@ -83,55 +93,71 @@ export const authOptions: AuthOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!usernameField || !credentials?.password) {
|
if (!credentials?.password) {
|
||||||
console.error('用户名/邮箱和密码不能为空');
|
throw new Error('请输入密码');
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 正常的API登录流程 - 使用apiClient
|
const usernameOrEmail = credentials.username || credentials.email;
|
||||||
const response = await apiClient.post('/auth/login', {
|
if (!usernameOrEmail) {
|
||||||
username: credentials?.username,
|
throw new Error('请输入用户名或邮箱');
|
||||||
email: credentials?.email,
|
}
|
||||||
password: credentials?.password
|
|
||||||
});
|
// 对于非测试账号,调用实际API进行认证
|
||||||
|
const response = await apiClient.post('/api/v1/auth/login', credentials);
|
||||||
|
|
||||||
if (response.data && response.data.user) {
|
if (response.data && response.data.user) {
|
||||||
return {
|
// 返回认证成功的用户信息
|
||||||
id: response.data.user.id,
|
return response.data.user;
|
||||||
name: response.data.user.name || response.data.user.username,
|
|
||||||
email: response.data.user.email,
|
|
||||||
minecraftUsername: response.data.user.minecraftUsername
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 认证失败,返回null
|
||||||
|
console.error('登录失败: 无效的凭据');
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('认证失败:', error);
|
console.error('登录错误:', error);
|
||||||
// 区分不同类型的错误,提供更好的错误反馈
|
// 转换错误对象为字符串,确保错误信息可以被正确传递
|
||||||
if (error instanceof axios.AxiosError) {
|
if (error instanceof Error) {
|
||||||
if (error.response?.status === 401) {
|
throw error;
|
||||||
console.error('用户名或密码错误');
|
|
||||||
} else if (error.response?.status === 400) {
|
|
||||||
console.error('登录信息不完整或格式错误');
|
|
||||||
} else if (!error.response) {
|
|
||||||
console.error('无法连接到认证服务器');
|
|
||||||
}
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
throw new Error(error);
|
||||||
}
|
}
|
||||||
return null;
|
// 默认错误信息
|
||||||
|
throw new Error('登录失败,请稍后再试');
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
],
|
],
|
||||||
|
// 配置会话管理
|
||||||
|
session: {
|
||||||
|
strategy: 'jwt',
|
||||||
|
maxAge: 30 * 24 * 60 * 60, // 30天
|
||||||
|
},
|
||||||
|
// 配置JWT令牌
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
maxAge: 30 * 24 * 60 * 60,
|
||||||
|
},
|
||||||
|
// 配置页面路由
|
||||||
pages: {
|
pages: {
|
||||||
signIn: '/login',
|
signIn: '/login',
|
||||||
signOut: '/login' // 退出登录后重定向到登录页面
|
signOut: '/login',
|
||||||
|
error: '/login',
|
||||||
},
|
},
|
||||||
|
// 回调函数配置
|
||||||
callbacks: {
|
callbacks: {
|
||||||
|
// JWT回调 - 处理令牌创建和更新
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
|
// 登录时,将用户信息存储到令牌中
|
||||||
if (user) {
|
if (user) {
|
||||||
token.id = user.id;
|
token.id = user.id;
|
||||||
|
token.name = user.name;
|
||||||
|
token.email = user.email;
|
||||||
|
token.minecraftUsername = user.minecraftUsername;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
|
// 会话回调 - 构建会话对象
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
if (token && token.id) {
|
if (token && token.id) {
|
||||||
session.user.id = token.id as string;
|
session.user.id = token.id as string;
|
||||||
@@ -152,6 +178,10 @@ export const authOptions: AuthOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 导出 NextAuth 处理函数
|
// 导出 NextAuth 处理函数
|
||||||
|
// 导出authOptions以便在其他地方使用
|
||||||
|
export { authOptions };
|
||||||
|
|
||||||
|
// 创建并导出NextAuth处理器
|
||||||
export const nextAuthHandler = NextAuth(authOptions);
|
export const nextAuthHandler = NextAuth(authOptions);
|
||||||
|
|
||||||
// Server Actions已移至actions.ts文件
|
// Server Actions已移至actions.ts文件
|
||||||
|
|||||||
307
src/lib/api/profiles.ts
Normal file
307
src/lib/api/profiles.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
// 角色服务API调用模块
|
||||||
|
// 根据ProfileService RPC接口规范实现前端API调用
|
||||||
|
|
||||||
|
// API基础配置
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://code.littlelan.cn/CarrotSkin/APIgateway';
|
||||||
|
|
||||||
|
// 角色信息接口
|
||||||
|
interface ProfileInfo {
|
||||||
|
uuid: string;
|
||||||
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
skinId?: number | null;
|
||||||
|
capeId?: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角色完整信息接口(带签名属性)
|
||||||
|
export interface ProfileWithProperties {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
properties: Array<{
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
signature?: string;
|
||||||
|
}>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简化的角色信息(用于批量查询)
|
||||||
|
export interface SimpleProfileInfo {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的Minecraft角色
|
||||||
|
* @param profileData 角色数据
|
||||||
|
* @returns 创建的角色信息
|
||||||
|
*/
|
||||||
|
export async function createProfile(profileData: {
|
||||||
|
name: string;
|
||||||
|
uuid?: string;
|
||||||
|
}): Promise<ProfileInfo> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/profiles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(profileData),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`创建角色失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建角色错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据UUID获取角色基本信息
|
||||||
|
* @param uuid 角色UUID
|
||||||
|
* @returns 角色信息
|
||||||
|
*/
|
||||||
|
export async function getProfile(uuid: string): Promise<ProfileInfo> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/profiles/${uuid}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取角色信息失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取角色信息错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的所有角色列表
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @returns 角色列表
|
||||||
|
*/
|
||||||
|
export async function getProfilesByUserId(userId: number): Promise<ProfileInfo[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/profiles/user/${userId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取用户角色列表失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户角色列表错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新角色信息
|
||||||
|
* @param uuid 角色UUID
|
||||||
|
* @param updates 更新内容
|
||||||
|
* @returns 更新后的角色信息
|
||||||
|
*/
|
||||||
|
export async function updateProfile(
|
||||||
|
uuid: string,
|
||||||
|
updates: {
|
||||||
|
name?: string;
|
||||||
|
skinId?: number | null;
|
||||||
|
capeId?: number | null;
|
||||||
|
}
|
||||||
|
): Promise<ProfileInfo> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/profiles/${uuid}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`更新角色信息失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新角色信息错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定角色
|
||||||
|
* @param uuid 角色UUID
|
||||||
|
*/
|
||||||
|
export async function deleteProfile(uuid: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/profiles/${uuid}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`删除角色失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除角色错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取角色的RSA公钥
|
||||||
|
* @param uuid 角色UUID
|
||||||
|
* @returns 公钥字符串
|
||||||
|
*/
|
||||||
|
export async function getProfilePublicKey(uuid: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/profiles/${uuid}/public-key`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取公钥失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.publicKey;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取公钥错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证使用角色私钥生成的签名
|
||||||
|
* @param uuid 角色UUID
|
||||||
|
* @param data 待验证的数据
|
||||||
|
* @param signature 签名
|
||||||
|
* @returns 验证结果
|
||||||
|
*/
|
||||||
|
export async function verifyProfileSignature(
|
||||||
|
uuid: string,
|
||||||
|
data: string,
|
||||||
|
signature: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/profiles/${uuid}/verify-signature`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ data, signature }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`验证签名失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result.isValid;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('验证签名错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询角色完整信息,包含Minecraft协议兼容的属性和签名
|
||||||
|
* @param uuid 角色UUID
|
||||||
|
* @param unsigned 是否不需要签名
|
||||||
|
* @returns 带属性的角色信息
|
||||||
|
*/
|
||||||
|
export async function getProfileWithProperties(
|
||||||
|
uuid: string,
|
||||||
|
unsigned: boolean = false
|
||||||
|
): Promise<ProfileWithProperties> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (unsigned) {
|
||||||
|
queryParams.append('unsigned', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/profiles/${uuid}/properties?${queryParams}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取角色属性失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取角色属性错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据角色名称列表批量查询角色信息
|
||||||
|
* @param names 角色名称列表
|
||||||
|
* @returns 简化的角色信息列表
|
||||||
|
*/
|
||||||
|
export async function getProfilesByNames(names: string[]): Promise<SimpleProfileInfo[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/profiles/batch/names`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ names }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`批量查询角色失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量查询角色错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据角色名获取用户ID
|
||||||
|
* @param name 角色名称
|
||||||
|
* @returns 用户ID
|
||||||
|
*/
|
||||||
|
export async function getUserIdByProfileName(name: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/profiles/name/${name}/user-id`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取用户ID失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.userId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户ID错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
// 皮肤服务API调用模块
|
||||||
|
// 根据SkinService RPC接口规范实现前端API调用
|
||||||
|
|
||||||
|
// 材质类型枚举
|
||||||
|
export enum TextureType {
|
||||||
|
SKIN = 'SKIN',
|
||||||
|
CAPE = 'CAPE'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 材质信息接口
|
||||||
|
export interface TextureInfo {
|
||||||
|
id: number;
|
||||||
|
uploaderId: number;
|
||||||
|
type: TextureType;
|
||||||
|
url: string;
|
||||||
|
hash: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏材质信息接口
|
||||||
|
export interface FavoriteTextureInfo {
|
||||||
|
texture: TextureInfo;
|
||||||
|
favoriteAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API基础配置
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://code.littlelan.cn/CarrotSkin/APIgateway';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成材质上传预签名URL
|
||||||
|
* @param type 材质类型
|
||||||
|
* @param filename 文件名
|
||||||
|
* @returns 上传URL和表单数据
|
||||||
|
*/
|
||||||
|
export async function generateTextureUploadURL(
|
||||||
|
type: TextureType,
|
||||||
|
filename: string
|
||||||
|
): Promise<{ postUrl: string; formData: Record<string, string> }> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/generate-upload-url`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type, filename }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`生成上传URL失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('生成材质上传URL错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建材质记录
|
||||||
|
* @param textureData 材质数据
|
||||||
|
* @returns 创建的材质信息
|
||||||
|
*/
|
||||||
|
export async function createTexture(textureData: {
|
||||||
|
type: TextureType;
|
||||||
|
hash: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
}): Promise<TextureInfo> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(textureData),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`创建材质记录失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建材质记录错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整的皮肤上传流程
|
||||||
|
* 1. 计算文件哈希
|
||||||
|
* 2. 检查是否已存在相同哈希的材质
|
||||||
|
* 3. 生成预签名上传URL
|
||||||
|
* 4. 上传文件到MinIO
|
||||||
|
* 5. 创建材质记录
|
||||||
|
*/
|
||||||
|
export async function uploadTexture(
|
||||||
|
file: File,
|
||||||
|
type: TextureType,
|
||||||
|
isPublic: boolean
|
||||||
|
): Promise<TextureInfo> {
|
||||||
|
try {
|
||||||
|
// 1. 计算文件哈希
|
||||||
|
const hash = await calculateSHA256(file);
|
||||||
|
|
||||||
|
// 2. 检查是否已存在相同哈希的材质
|
||||||
|
const existingTexture = await getTextureByHash(hash);
|
||||||
|
if (existingTexture) {
|
||||||
|
// 如果已存在相同材质,直接使用现有材质
|
||||||
|
return existingTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 生成预签名上传URL
|
||||||
|
const { postUrl, formData } = await generateTextureUploadURL(type, file.name);
|
||||||
|
|
||||||
|
// 4. 上传文件到MinIO
|
||||||
|
const form = new FormData();
|
||||||
|
Object.entries(formData).forEach(([key, value]) => {
|
||||||
|
form.append(key, value);
|
||||||
|
});
|
||||||
|
form.append('file', file);
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(postUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error(`文件上传失败: ${uploadResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 创建材质记录
|
||||||
|
return await createTexture({ type, hash, isPublic });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传材质错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算文件的SHA-256哈希值
|
||||||
|
*/
|
||||||
|
async function calculateSHA256(file: File): Promise<string> {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个材质信息
|
||||||
|
* @param id 材质ID
|
||||||
|
* @returns 材质信息
|
||||||
|
*/
|
||||||
|
export async function getTexture(id: number): Promise<TextureInfo> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取材质信息失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取材质信息错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新材质信息
|
||||||
|
* @param id 材质ID
|
||||||
|
* @param updates 更新内容
|
||||||
|
* @returns 更新后的材质信息
|
||||||
|
*/
|
||||||
|
export async function updateTexture(
|
||||||
|
id: number,
|
||||||
|
updates: { isPublic?: boolean }
|
||||||
|
): Promise<TextureInfo> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`更新材质信息失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新材质信息错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除材质
|
||||||
|
* @param id 材质ID
|
||||||
|
*/
|
||||||
|
export async function deleteTexture(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`删除材质失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除材质错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户个人材质库
|
||||||
|
* @param type 材质类型(可选)
|
||||||
|
* @param page 页码
|
||||||
|
* @param pageSize 每页数量
|
||||||
|
* @returns 材质列表和分页信息
|
||||||
|
*/
|
||||||
|
export async function getUserTextures(params: {
|
||||||
|
type?: TextureType;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ textures: TextureInfo[]; total: number; page: number; pageSize: number }> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params.type) queryParams.append('type', params.type);
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString());
|
||||||
|
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/user?${queryParams}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取用户材质失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户材质错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取皮肤广场公开材质
|
||||||
|
* @param type 材质类型(可选)
|
||||||
|
* @param page 页码
|
||||||
|
* @param pageSize 每页数量
|
||||||
|
* @returns 材质列表和分页信息
|
||||||
|
*/
|
||||||
|
export async function getPublicTextures(params: {
|
||||||
|
type?: TextureType;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ textures: TextureInfo[]; total: number; page: number; pageSize: number }> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params.type) queryParams.append('type', params.type);
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString());
|
||||||
|
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/public?${queryParams}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取公开材质失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取公开材质错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索材质
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @param type 材质类型(可选)
|
||||||
|
* @param page 页码
|
||||||
|
* @param pageSize 每页数量
|
||||||
|
* @returns 搜索结果和分页信息
|
||||||
|
*/
|
||||||
|
export async function searchTextures(params: {
|
||||||
|
keyword: string;
|
||||||
|
type?: TextureType;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ textures: TextureInfo[]; total: number; page: number; pageSize: number }> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
queryParams.append('keyword', params.keyword);
|
||||||
|
if (params.type) queryParams.append('type', params.type);
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString());
|
||||||
|
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/search?${queryParams}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`搜索材质失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索材质错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据哈希值查找材质
|
||||||
|
* @param hash SHA-256哈希值
|
||||||
|
* @returns 材质信息(如果存在)
|
||||||
|
*/
|
||||||
|
export async function getTextureByHash(hash: string): Promise<TextureInfo | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/hash/${hash}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`查找材质失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('查找材质错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取材质信息
|
||||||
|
* @param ids 材质ID数组
|
||||||
|
* @returns 材质信息数组
|
||||||
|
*/
|
||||||
|
export async function getTexturesByIds(ids: number[]): Promise<TextureInfo[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/batch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`批量获取材质失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量获取材质错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加材质到收藏夹
|
||||||
|
* @param textureId 材质ID
|
||||||
|
*/
|
||||||
|
export async function addFavorite(textureId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/favorites`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ textureId }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`添加收藏失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加收藏错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从收藏夹移除材质
|
||||||
|
* @param textureId 材质ID
|
||||||
|
*/
|
||||||
|
export async function removeFavorite(textureId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/favorites/${textureId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`移除收藏失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('移除收藏错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户收藏列表
|
||||||
|
* @param page 页码
|
||||||
|
* @param pageSize 每页数量
|
||||||
|
* @returns 收藏列表和分页信息
|
||||||
|
*/
|
||||||
|
export async function getUserFavorites(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ textures: FavoriteTextureInfo[]; total: number; page: number; pageSize: number }> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString());
|
||||||
|
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/favorites?${queryParams}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取收藏列表失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取收藏列表错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查材质收藏状态
|
||||||
|
* @param textureId 材质ID
|
||||||
|
* @returns 是否已收藏
|
||||||
|
*/
|
||||||
|
export async function checkFavoriteStatus(textureId: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/v1/textures/favorites/check/${textureId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`检查收藏状态失败: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result.isFavorite;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查收藏状态错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/middleware.js
Normal file
18
src/middleware.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// 自定义中间件配置,用于排除验证码测试页面的NextAuth干扰
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request) {
|
||||||
|
// 排除验证码测试页面,避免NextAuth干扰
|
||||||
|
if (request.nextUrl.pathname.startsWith('/verify-code-test')) {
|
||||||
|
// 直接允许访问,不应用任何中间件
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于其他路径,让Next.js正常处理
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置中间件应用范围
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user