修复角色中心组件中的类型不匹配错误,改进skinId和capeId的类型处理逻辑

This commit is contained in:
Mikuisnotavailable
2025-10-16 01:59:17 +08:00
parent d9a15dd13d
commit 167c51b20d
21 changed files with 4548 additions and 329 deletions

114
API测试指南.md Normal file
View 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
View 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位数字
- 验证码存储Redis10分钟有效期
- 发送频率限制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
View 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
View 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
View 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
View 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
View 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;

View File

@@ -1,15 +1,42 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import Canvas2DSkinPreview from '@/components/skins/Canvas2DSkinPreview';
import { Card, CardContent, CardFooter, 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 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 (
<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组件 */}
<div className="relative z-10">
<Canvas2DSkinPreview
skinUrl={`/test-skin.png?skinId=${character.skinId}`}
skinUrl={character.skinId ? `/skins/${character.skinId}.png` : '/default-skin.png'}
size={112}
className="transition-transform duration-500 hover:scale-110"
/>
@@ -39,20 +66,28 @@ function CharacterCard({ character }: { character: any }) {
</p>
</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">
{character.level}
{character.isActive ? '活跃' : '非活跃'}
</span>
</div>
{/* 额外的角色信息 */}
<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="text-blue-500"></span>
ID: {character.skinId}
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}
</span>
)}
{character.capeId && (
<span className="flex items-center gap-1.5">
<span className="text-red-500"></span>
ID: {character.capeId}
</span>
)}
</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>
<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 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>
</div>
@@ -77,21 +126,23 @@ function CharacterCard({ character }: { character: any }) {
function AddCharacterCard({ onAddClick }: { onAddClick: () => void }) {
return (
<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}
>
<div className="w-full h-32 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 group">
<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>
<span className="text-base font-medium group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors"></span>
</div>
<CardContent className="p-6 text-center">
<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-2xl">+</span>
</div>
<h3 className="text-xl font-medium mb-1"></h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
</p>
</CardContent>
</Card>
);
}
// 主客户端组件
export default function CharacterCenterClient({ userName, characters }: { userName: string; characters: any[] }) {
export default function CharacterCenterClient({ userName }: { userName: string }) {
// 使用简单的状态管理来模拟标签页
const [activeTab, setActiveTab] = useState('my-characters');
@@ -104,24 +155,56 @@ export default function CharacterCenterClient({ userName, characters }: { userNa
isActive: true,
});
// 生成UUID的简单方法不使用外部库
const generateUUID = () => {
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);
return v.toString(16);
});
// 编辑模式状态
const [editingCharacterId, setEditingCharacterId] = useState<string | null>(null);
// 角色列表状态
const [characters, setCharacters] = useState<Character[]>([]);
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 { name, value } = e.target;
setCharacterForm(prev => ({
@@ -130,7 +213,7 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
}));
};
// 处理复选框变化
// 复选框处理
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCharacterForm(prev => ({
...prev,
@@ -138,61 +221,188 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
}));
};
// 处理皮肤选择
// 皮肤选择处理
const handleSkinSelect = (skinId: string) => {
setCharacterForm(prev => ({
...prev,
skinId
skinId: prev.skinId === skinId ? '' : skinId
}));
};
// 处理角色编辑
const handleEditCharacter = (character: Character) => {
// 设置表单为编辑模式
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;
}
setIsLoading(true);
setError(null);
try {
await deleteProfile(uuid);
// 从本地角色列表中移除
setCharacters(prev => prev.filter(character => character.uuid !== uuid));
setSuccessMessage('角色删除成功!');
// 3秒后清除成功消息
setTimeout(() => {
setSuccessMessage(null);
}, 3000);
} catch (err) {
console.error('删除角色失败:', err);
setError('删除角色失败,请稍后重试');
} finally {
setIsLoading(false);
}
};
// 处理角色更新
const handleUpdateCharacter = async () => {
if (!characterForm.name.trim()) {
setError('请输入角色名称');
return;
}
if (!editingCharacterId) {
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({
name: '',
description: '',
skinId: '',
capeId: '',
isActive: true,
});
};
// 处理角色创建
const handleCreateCharacter = async () => {
if (!characterForm.name.trim()) {
alert('请输入角色名称');
setError('请输入角色名称');
return;
}
if (!session || !session.user?.id) {
setError('请先登录');
return;
}
setIsLoading(true);
setError(null);
try {
// 生成角色UUID
const characterUuid = generateUUID();
// 调用API创建角色
const createdProfile = await createProfile({
name: characterForm.name.trim(),
// UUID由服务端生成
});
// 生成RSA私钥
const rsaPrivateKey = generateRSAKey();
// 构建角色数据根据数据库profiles表结构
const newCharacter = {
uuid: characterUuid,
user_id: 'current-user-id', // 应从会话中获取真实用户ID
name: characterForm.name,
skin_id: characterForm.skinId || null,
cape_id: characterForm.capeId || null,
rsa_private_key: rsaPrivateKey,
is_active: characterForm.isActive,
description: characterForm.description
// 添加到本地角色列表
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
};
// 这里应该调用API发送到服务器
console.log('创建角色数据:', newCharacter);
// 模拟成功响应
alert('角色创建成功!\nUUID: ' + characterUuid.substring(0, 8) + '...');
setCharacters(prev => [newCharacter, ...prev]);
setSuccessMessage('角色创建成功!');
// 重置表单
setCharacterForm({
name: '',
description: '',
skinId: '',
capeId: '',
isActive: true,
});
resetForm();
// 切换回我的角色标签
setActiveTab('my-characters');
} catch (error) {
console.error('创建角色失败:', error);
alert('创建角色失败,请重试');
// 3秒后清除成功消息
setTimeout(() => {
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">
{/* 消息提示区域 */}
{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="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
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'}`}
>
</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'}`}
>
{editingCharacterId ? '编辑角色' : '创建角色'}
</button>
</div>
@@ -244,11 +483,42 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
{/* 我的角色标签内容 */}
{activeTab === 'my-characters' && (
<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">
{/* 角色卡片列表 */}
{characters.map((character) => (
<CharacterCard key={character.id} character={character} />
))}
{characters.length > 0 ? (
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')} />
@@ -257,123 +527,150 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY
)}
{/* 创建角色标签内容 */}
{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">
<h3 className="text-2xl font-bold mb-8 tracking-tight"></h3>
<div className="max-w-2xl">
<div className="space-y-6">
<div className="space-y-3">
<Label htmlFor="character-name" className="text-base font-medium"> <span className="text-red-500">*</span></Label>
<Input
id="character-name"
name="name"
value={characterForm.name}
onChange={handleInputChange}
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"
maxLength={16}
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Minecraft游戏内的用户名
</p>
</div>
<div className="space-y-3">
<Label className="text-base font-medium"></Label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<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'}`}
onClick={() => handleSkinSelect('skin1')}
>
<Canvas2DSkinPreview
skinUrl="/test-skin.png"
size={128}
className="w-full h-full"
{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">
<h3 className="text-2xl font-bold mb-8 tracking-tight">{editingCharacterId ? '编辑角色' : '创建新角色'}</h3>
<div className="max-w-2xl">
<div className="space-y-6">
<div className="space-y-3">
<Label htmlFor="character-name" className="text-base font-medium"> <span className="text-red-500">*</span></Label>
<Input
id="character-name"
name="name"
value={characterForm.name}
onChange={handleInputChange}
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"
maxLength={16}
disabled={isLoading}
/>
{characterForm.skinId === 'skin1' && (
<div className="absolute top-3 right-3 w-3 h-3 bg-emerald-500 rounded-full"></div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
Minecraft游戏内的用户名
</p>
</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'}`}
onClick={() => handleSkinSelect('skin2')}
>
<Canvas2DSkinPreview
skinUrl="/test-skin2.png"
size={128}
className="max-w-full max-h-full"
/>
{characterForm.skinId === 'skin2' && (
<div className="absolute top-3 right-3 w-3 h-3 bg-emerald-500 rounded-full"></div>
)}
<div className="space-y-3">
<Label className="text-base font-medium"></Label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<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'}`}
onClick={() => handleSkinSelect('skin1')}
aria-disabled={isLoading}
>
<Canvas2DSkinPreview
skinUrl="/test-skin.png"
size={128}
className="w-full h-full"
/>
{characterForm.skinId === 'skin1' && (
<div className="absolute top-3 right-3 w-3 h-3 bg-emerald-500 rounded-full"></div>
)}
</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'}`}
onClick={() => handleSkinSelect('skin2')}
aria-disabled={isLoading}
>
<Canvas2DSkinPreview
skinUrl="/test-skin2.png"
size={128}
className="max-w-full max-h-full"
/>
{characterForm.skinId === 'skin2' && (
<div className="absolute top-3 right-3 w-3 h-3 bg-emerald-500 rounded-full"></div>
)}
</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'}`}
onClick={() => handleSkinSelect('skin3')}
aria-disabled={isLoading}
>
<Canvas2DSkinPreview
skinUrl="/test-skin3.png"
size={128}
className="w-full h-full"
/>
{characterForm.skinId === 'skin3' && (
<div className="absolute top-3 right-3 w-3 h-3 bg-emerald-500 rounded-full"></div>
)}
</div>
</div>
</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'}`}
onClick={() => handleSkinSelect('skin3')}
>
<Canvas2DSkinPreview
skinUrl="/test-skin3.png"
size={128}
className="w-full h-full"
<div className="space-y-3">
<Label htmlFor="character-description" className="text-base font-medium"></Label>
<textarea
id="character-description"
name="description"
value={characterForm.description}
onChange={handleInputChange}
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"
rows={4}
disabled={isLoading}
/>
{characterForm.skinId === 'skin3' && (
<div className="absolute top-3 right-3 w-3 h-3 bg-emerald-500 rounded-full"></div>
</div>
<div className="flex items-center space-x-2 pt-2">
<input
title = "设为活跃角色"
id="is-active"
type="checkbox"
checked={characterForm.isActive}
onChange={handleCheckboxChange}
className="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
disabled={isLoading}
/>
<Label htmlFor="is-active" className="text-sm"></Label>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg border border-amber-200 dark:border-amber-900/30">
<h4 className="font-medium text-amber-800 dark:text-amber-400 mb-2 flex items-center">
<span className="mr-2"></span>
</h4>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1.5">
<li> UUID</li>
<li> RSA密钥用于身份验证</li>
<li> Minecraft命名规范</li>
<li> 10</li>
</ul>
</div>
<div className="flex gap-4">
{editingCharacterId && (
<Button
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={() => {
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>
</div>
</div>
</div>
<div className="space-y-3">
<Label htmlFor="character-description" className="text-base font-medium"></Label>
<textarea
id="character-description"
name="description"
value={characterForm.description}
onChange={handleInputChange}
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"
rows={4}
/>
</div>
<div className="flex items-center space-x-2 pt-2">
<input
id="is-active"
type="checkbox"
checked={characterForm.isActive}
onChange={handleCheckboxChange}
className="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
/>
<Label htmlFor="is-active" className="text-sm"></Label>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg border border-amber-200 dark:border-amber-900/30">
<h4 className="font-medium text-amber-800 dark:text-amber-400 mb-2 flex items-center">
<span className="mr-2"></span>
</h4>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1.5">
<li> UUID</li>
<li> RSA密钥用于身份验证</li>
<li> Minecraft命名规范</li>
<li> 10</li>
</ul>
</div>
<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"
onClick={handleCreateCharacter}
>
</Button>
</div>
</div>
)}
</div>
)}
</div>
</main>
{/* 页脚 */}

View File

@@ -1,13 +1,12 @@
// src/app/dashboard/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
//import { Separator } from '@/components/ui/separator';
import Link from 'next/link';
import { useState } from 'react';
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() {
// 由于这是客户端组件我们不能在这里使用getServerSession
@@ -21,16 +20,61 @@ export default function Dashboard() {
// 状态管理
const [searchTerm, setSearchTerm] = useState('');
const [activeCategory, setActiveCategory] = useState('all');
// 实际应用中这里会从API获取用户皮肤数据
const mockSkins = [
{ id: '1', name: 'Steve皮肤', createdAt: '2023-05-01' },
{ id: '2', name: 'Alex皮肤', createdAt: '2023-05-15' },
];
const [skins, setSkins] = useState<TextureInfo[]>([]);
const [favorites, setFavorites] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 加载用户皮肤
useEffect(() => {
loadUserSkins();
}, []);
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 =>
skin.name.toLowerCase().includes(searchTerm.toLowerCase())
const filteredSkins = skins.filter(skin =>
skin.id.toString().includes(searchTerm.toLowerCase())
);
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">
{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">
{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">
<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>
<Canvas2DSkinPreview
skinUrl={`/test-skin.png`}
skinUrl={skin.url || `/test-skin.png`}
size={128}
className="max-w-full max-h-full relative z-10 transition-transform duration-500 group-hover:scale-110"
/>
</div>
<CardContent className="p-5">
<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>
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-500 hover:text-rose-500 dark:hover:text-rose-400 rounded-full">
<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"
onClick={(e) => {
e.stopPropagation();
handleFavoriteToggle(skin.id);
}}
>
</Button>
</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">
<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>
</CardContent>
</Card>
))}
))
)}
</div>
{/* 空状态 */}

View File

@@ -2,10 +2,12 @@
import './globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Navbar from '@/components/Navbar';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/api/auth';
// 导入客户端组件包装器
import ClientLayout from './ClientLayout';
const inter = Inter({ subsets: ['latin'] });
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,
}: {
children: React.ReactNode;
}) {
// 根布局组件 - 服务器组件
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// 在服务器端获取会话状态
const session = await getServerSession(authOptions);
@@ -34,8 +33,9 @@ export default async function RootLayout({
/>
</head>
<body className={`${inter.className} bg-gray-50 dark:bg-gray-900 min-h-screen flex flex-col`}>
<Navbar session={session} />
<main className="flex-grow container mx-auto px-4 py-6 sm:py-8">{children}</main>
<ClientLayout session={session}>
{children}
</ClientLayout>
<footer className="bg-gray-800 text-white py-4 text-center text-sm">
<div className="container mx-auto px-4">
© 2024 - Minecraft玩家打造的皮肤分享平台

View 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;

View File

@@ -6,7 +6,9 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Label } from '@/components/ui/label';
import { UploadIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import Canvas2DSkinPreview from '@/components/skins/Canvas2DSkinPreview';
import { TextureType, uploadTexture } from '@/lib/api/skins';
export default function SkinUploadPage() {
const [skinFile, setSkinFile] = useState<File | null>(null);
@@ -37,17 +39,19 @@ export default function SkinUploadPage() {
}
}, [skinFile]);
const router = useRouter();
const [isPublic, setIsPublic] = useState(true);
const handleUpload = async () => {
if (!skinFile) return;
setIsUploading(true);
try {
// 实际应用中这里会调用API上传皮肤
console.log('上传皮肤文件:', skinFile.name);
// 模拟上传延迟
await new Promise(resolve => setTimeout(resolve, 1500));
alert('皮肤上传成功!');
setSkinFile(null);
// 调用API上传皮肤
await uploadTexture(skinFile, TextureType.SKIN, isPublic);
// 上传成功后跳转到个人皮肤页面
router.push('/dashboard');
} catch (error) {
console.error('上传失败:', error);
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" />
<div className="space-y-2">
<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>
@@ -120,15 +124,45 @@ export default function SkinUploadPage() {
</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>
</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
onClick={handleUpload}
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>
</CardFooter>
</Card>

View 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>
);
}

View 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;

View 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
View 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;

View File

@@ -4,8 +4,8 @@ import axios from 'axios';
// 配置axios实例统一处理API请求
const apiClient = axios.create({
//将baseURL改为实际服务器地址
baseURL: process.env.NEXT_PUBLIC_API_URL || '/api',
// 使用配置的API基础URL
baseURL: process.env.NEXT_PUBLIC_API_URL || 'https://code.littlelan.cn/CarrotSkin/APIgateway',
timeout: 10000, // 设置10秒超时
headers: {
'Content-Type': 'application/json',
@@ -64,6 +64,13 @@ apiClient.interceptors.response.use(
export const serverSignOut = async () => {
console.log('serverSignOut函数执行开始');
// 调用API退出登录
try {
await apiClient.post('/api/v1/auth/logout');
} catch (error) {
console.error('API退出登录失败继续执行本地清理:', error);
}
// 使用更直接的方式清除会话并重定向
// 1. 首先清除所有可能的cookie
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 };
} catch (error) {
console.error('登录失败:', error);
@@ -170,7 +177,7 @@ export const getVerificationCode = async (email: string) => {
}
// 调用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 };
} catch (error) {
console.error('获取验证码失败:', error);
@@ -206,7 +213,7 @@ export const verifyCode = async (email: string, code: string) => {
}
// 调用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 };
} catch (error) {
console.error('验证码验证失败:', error);
@@ -257,7 +264,7 @@ export const resetPassword = async (email: string, username: string, newPassword
}
// 调用API重置密码
const response = await apiClient.post('/auth/reset-password', {
const response = await apiClient.post('/api/v1/auth/reset-password', {
email,
username,
newPassword
@@ -318,7 +325,7 @@ export const register = async (userData: {
}
// 实际环境中调用API
const response = await apiClient.post('/auth/register', userData);
const response = await apiClient.post('/api/v1/auth/register', userData);
return { success: true, ...response.data };
} catch (error) {
console.error('注册失败:', error);

View File

@@ -5,7 +5,7 @@ import axios from 'axios';
// 配置axios实例与actions.ts保持一致
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,
headers: {
'Content-Type': 'application/json',
@@ -42,116 +42,146 @@ apiClient.interceptors.response.use(
}
);
declare module "next-auth" {
interface Session {
user: {
id: string;
name: string;
email: string;
minecraftUsername?: string;
} ;
}
}
// 定义 authOptions 并导出
export const authOptions: AuthOptions = {
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
username: { label: "用户名", type: "text" },
email: { label: "邮箱", type: "email" },
password: { label: "密码", type: "password" }
},
async authorize(credentials) {
// 测试账号配置 - 从环境变量获取
const TEST_USERNAME = process.env.TEST_USERNAME || 'test';
const TEST_PASSWORD = process.env.TEST_PASSWORD || 'test';
try {
// 检查是否是测试账号 - 支持通过username或email字段登录
const usernameField = credentials?.username || credentials?.email;
if (process.env.NODE_ENV !== 'production' &&
usernameField === TEST_USERNAME &&
credentials?.password === TEST_PASSWORD) {
// 返回模拟的测试用户数据
return {
id: 'test_user_1',
name: '测试玩家',
email: 'test@test.com',
minecraftUsername: 'SteveTest'
};
}
// 验证输入
if (!usernameField || !credentials?.password) {
console.error('用户名/邮箱和密码不能为空');
return null;
}
// 正常的API登录流程 - 使用apiClient
const response = await apiClient.post('/auth/login', {
username: credentials?.username,
email: credentials?.email,
password: credentials?.password
});
// 测试账号配置 - 从环境变量获取
const TEST_USERNAME = process.env.TEST_USERNAME || 'test';
const TEST_PASSWORD = process.env.TEST_PASSWORD || 'test';
if (response.data && response.data.user) {
return {
id: response.data.user.id,
name: response.data.user.name || response.data.user.username,
email: response.data.user.email,
minecraftUsername: response.data.user.minecraftUsername
};
}
return null;
} catch (error) {
console.error('认证失败:', error);
// 区分不同类型的错误,提供更好的错误反馈
if (error instanceof axios.AxiosError) {
if (error.response?.status === 401) {
console.error('用户名或密码错误');
} else if (error.response?.status === 400) {
console.error('登录信息不完整或格式错误');
} else if (!error.response) {
console.error('无法连接到认证服务器');
}
}
return null;
}
}
})
],
pages: {
signIn: '/login',
signOut: '/login' // 退出登录后重定向到登录页面
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
// 定义authOptions配置
const authOptions: AuthOptions = {
// 配置认证提供程序
providers: [
CredentialsProvider({
// 凭证登录的名称和描述
name: 'Credentials',
credentials: {
username: {
label: '用户名',
type: 'text',
placeholder: '请输入用户名或邮箱',
},
async session({ session, token }) {
if (token && token.id) {
session.user.id = token.id as string;
}
return session;
email: {
label: '邮箱',
type: 'email',
placeholder: '请输入邮箱',
},
// 登录成功后重定向到用户主页
async redirect({ url, baseUrl }) {
// 如果已经有明确的重定向URL则使用它
if (url.startsWith(baseUrl)) {
return url;
}
// 否则默认重定向到用户主页
return `${baseUrl}/user-home`;
password: {
label: '密码',
type: 'password',
placeholder: '请输入密码',
},
verificationCode: {
label: '验证码',
type: 'text',
placeholder: '请输入验证码',
},
},
// 认证逻辑处理函数
async authorize(credentials) {
try {
// 检查是否是测试账号 - 支持通过username或email字段登录
const usernameField = credentials?.username || credentials?.email;
if (process.env.NODE_ENV !== 'production' &&
usernameField === TEST_USERNAME &&
credentials?.password === TEST_PASSWORD) {
// 返回模拟的测试用户数据
return {
id: 'test_user_1',
name: '测试玩家',
email: 'test@test.com',
minecraftUsername: 'SteveTest'
};
}
// 验证输入
if (!credentials?.password) {
throw new Error('请输入密码');
}
const usernameOrEmail = credentials.username || credentials.email;
if (!usernameOrEmail) {
throw new Error('请输入用户名或邮箱');
}
// 对于非测试账号调用实际API进行认证
const response = await apiClient.post('/api/v1/auth/login', credentials);
if (response.data && response.data.user) {
// 返回认证成功的用户信息
return response.data.user;
}
// 认证失败返回null
console.error('登录失败: 无效的凭据');
return null;
} catch (error) {
console.error('登录错误:', error);
// 转换错误对象为字符串,确保错误信息可以被正确传递
if (error instanceof Error) {
throw error;
}
if (typeof error === 'string') {
throw new Error(error);
}
// 默认错误信息
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: {
signIn: '/login',
signOut: '/login',
error: '/login',
},
// 回调函数配置
callbacks: {
// JWT回调 - 处理令牌创建和更新
async jwt({ token, user }) {
// 登录时,将用户信息存储到令牌中
if (user) {
token.id = user.id;
token.name = user.name;
token.email = user.email;
token.minecraftUsername = user.minecraftUsername;
}
return token;
},
// 会话回调 - 构建会话对象
async session({ session, token }) {
if (token && token.id) {
session.user.id = token.id as string;
}
return session;
},
// 登录成功后重定向到用户主页
async redirect({ url, baseUrl }) {
// 如果已经有明确的重定向URL则使用它
if (url.startsWith(baseUrl)) {
return url;
}
// 否则默认重定向到用户主页
return `${baseUrl}/user-home`;
}
},
secret: process.env.NEXTAUTH_SECRET,
};
// 导出 NextAuth 处理函数
// 导出authOptions以便在其他地方使用
export { authOptions };
// 创建并导出NextAuth处理器
export const nextAuthHandler = NextAuth(authOptions);
// Server Actions已移至actions.ts文件

307
src/lib/api/profiles.ts Normal file
View 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;
}
}

View File

@@ -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
View 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).*)'],
};