服务器购买
This commit is contained in:
parent
d807c1d7f3
commit
324862a83a
219
SERVER_FEATURE_IMPLEMENTATION.md
Normal file
219
SERVER_FEATURE_IMPLEMENTATION.md
Normal file
@ -0,0 +1,219 @@
|
||||
# 服务器售卖功能实现说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
本次更新为项目添加了完整的服务器售卖功能,包括管理员端和用户端的所有必要功能。
|
||||
|
||||
## 实现的功能
|
||||
|
||||
### 1. 后端实现
|
||||
|
||||
#### 1.1 数据库模型
|
||||
|
||||
**Server 模型** (`model/server.go`)
|
||||
- 服务器配置表,用于管理可售卖的服务器
|
||||
- 字段:
|
||||
- `id`: 主键
|
||||
- `title`: 服务器标题
|
||||
- `config`: 配置信息(文本)
|
||||
- `price`: 价格(元)
|
||||
- `duration`: 有效时长(天)
|
||||
- `status`: 状态(1-启用,2-禁用)
|
||||
- `description`: 服务器描述
|
||||
- `stock`: 库存数量(-1表示无限)
|
||||
- `created_time`: 创建时间
|
||||
- `updated_time`: 更新时间
|
||||
|
||||
**PurchasedServer 模型** (`model/purchased_server.go`)
|
||||
- 已购服务器表,记录用户购买的服务器
|
||||
- 字段:
|
||||
- `id`: 主键
|
||||
- `user_id`: 用户ID
|
||||
- `server_id`: 服务器配置ID
|
||||
- `server_title`: 服务器标题(冗余字段)
|
||||
- `server_config`: 服务器配置(冗余字段)
|
||||
- `price`: 购买价格
|
||||
- `duration`: 购买时长(天)
|
||||
- `purchase_time`: 购买时间
|
||||
- `expire_time`: 到期时间
|
||||
- `status`: 状态(1-正常,2-已过期)
|
||||
- `trade_no`: 交易单号
|
||||
|
||||
#### 1.2 控制器 (`controller/server.go`)
|
||||
|
||||
**管理员功能:**
|
||||
- `GetAllServers`: 获取所有服务器配置(分页)
|
||||
- `SearchServers`: 搜索服务器配置
|
||||
- `GetServer`: 获取单个服务器配置
|
||||
- `AddServer`: 添加服务器配置
|
||||
- `UpdateServer`: 更新服务器配置
|
||||
- `DeleteServer`: 删除服务器配置
|
||||
- `GetAllPurchasedServers`: 获取所有已购服务器(分页,含用户名)
|
||||
- `SearchPurchasedServers`: 搜索已购服务器
|
||||
- `DeletePurchasedServer`: 删除已购服务器记录
|
||||
|
||||
**用户功能:**
|
||||
- `GetEnabledServers`: 获取启用的服务器列表
|
||||
- `PurchaseServer`: 购买服务器(扣除用户额度)
|
||||
- `GetUserPurchasedServers`: 获取用户已购服务器列表
|
||||
|
||||
#### 1.3 API 路由 (`router/api-router.go`)
|
||||
|
||||
```
|
||||
/api/server/
|
||||
- GET / # 获取所有服务器(管理员)
|
||||
- GET /search # 搜索服务器(管理员)
|
||||
- GET /:id # 获取单个服务器(管理员)
|
||||
- POST / # 添加服务器(管理员)
|
||||
- PUT / # 更新服务器(管理员)
|
||||
- DELETE /:id # 删除服务器(管理员)
|
||||
- GET /enabled # 获取启用的服务器(用户)
|
||||
- POST /purchase # 购买服务器(用户)
|
||||
- GET /purchased/self # 获取用户已购服务器(用户)
|
||||
- GET /purchased # 获取所有已购服务器(管理员)
|
||||
- GET /purchased/search # 搜索已购服务器(管理员)
|
||||
- DELETE /purchased/:id # 删除已购服务器(管理员)
|
||||
```
|
||||
|
||||
### 2. 前端实现
|
||||
|
||||
#### 2.1 管理员页面
|
||||
|
||||
**服务器管理页面** (`web/src/pages/Server/index.js`)
|
||||
- 组件:`ServersTable` (`web/src/components/table/ServersTable.js`)
|
||||
- 功能:
|
||||
- 查看所有服务器配置
|
||||
- 搜索服务器
|
||||
- 添加新服务器
|
||||
- 编辑服务器配置
|
||||
- 删除服务器
|
||||
- 显示服务器状态、价格、库存等信息
|
||||
|
||||
**已售服务器列表** (`web/src/pages/PurchasedServer/index.js`)
|
||||
- 组件:`PurchasedServersTable` (`web/src/components/table/PurchasedServersTable.js`)
|
||||
- 功能:
|
||||
- 查看所有已售服务器
|
||||
- 搜索已购服务器(按用户ID或服务器标题)
|
||||
- 查看服务器配置
|
||||
- 显示用户名、购买时间、到期时间等
|
||||
- 删除已购服务器记录
|
||||
|
||||
#### 2.2 用户页面
|
||||
|
||||
**服务器购买页面** (`web/src/pages/ServerShop/index.js`)
|
||||
- 功能:
|
||||
- 展示所有可购买的服务器(卡片式布局)
|
||||
- 显示服务器标题、价格、有效期、库存
|
||||
- 显示服务器配置预览
|
||||
- 购买确认对话框
|
||||
- 购买成功后显示完整配置信息
|
||||
- 自动扣除用户额度
|
||||
|
||||
**我的服务器页面** (`web/src/pages/MyServers/index.js`)
|
||||
- 功能:
|
||||
- 查看用户已购买的所有服务器
|
||||
- 显示购买时间、到期时间、剩余天数
|
||||
- 状态标识(正常/已过期)
|
||||
- 点击查看完整服务器配置
|
||||
- 到期提醒(7天内显示橙色标签)
|
||||
|
||||
#### 2.3 路由和导航
|
||||
|
||||
**路由配置** (`web/src/App.js`)
|
||||
- `/console/server`: 服务器管理(管理员)
|
||||
- `/console/purchased-server`: 已售服务器列表(管理员)
|
||||
- `/console/server-shop`: 服务器购买(用户)
|
||||
- `/console/my-servers`: 我的服务器(用户)
|
||||
|
||||
**侧边栏导航** (`web/src/components/layout/SiderBar.js`)
|
||||
- 管理员菜单:
|
||||
- 服务器管理
|
||||
- 已售服务器
|
||||
- 个人中心菜单:
|
||||
- 服务器购买
|
||||
- 我的服务器
|
||||
|
||||
**图标配置** (`web/src/helpers/render.js`)
|
||||
- 使用 Lucide React 的 Server 图标
|
||||
- 紫色主题色 (#8B5CF6)
|
||||
|
||||
## 业务逻辑
|
||||
|
||||
### 购买流程
|
||||
|
||||
1. 用户在"服务器购买"页面浏览可用服务器
|
||||
2. 点击"立即购买"按钮
|
||||
3. 系统显示确认对话框,包含价格和有效期信息
|
||||
4. 用户确认后:
|
||||
- 检查服务器状态和库存
|
||||
- 计算需要扣除的额度(价格 × QuotaPerUnit)
|
||||
- 检查用户额度是否足够
|
||||
- 扣除用户额度
|
||||
- 创建已购服务器记录
|
||||
- 更新库存(如果不是无限库存)
|
||||
- 记录操作日志
|
||||
5. 购买成功后显示服务器配置信息
|
||||
|
||||
### 额度计算
|
||||
|
||||
- 服务器价格以人民币(元)为单位
|
||||
- 购买时将价格转换为系统额度:`额度 = 价格 × QuotaPerUnit`
|
||||
- 使用 `decimal` 包进行精确的浮点数计算
|
||||
|
||||
### 数据冗余
|
||||
|
||||
- 已购服务器表中保存了服务器标题和配置的副本
|
||||
- 即使原服务器配置被删除或修改,用户已购买的服务器信息仍然保留
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
已在 `model/main.go` 中添加了自动迁移:
|
||||
- `Server` 表
|
||||
- `PurchasedServer` 表
|
||||
|
||||
系统启动时会自动创建这两张表。
|
||||
|
||||
## 安全性
|
||||
|
||||
- 所有管理员接口都使用 `middleware.AdminAuth()` 保护
|
||||
- 所有用户接口都使用 `middleware.UserAuth()` 保护
|
||||
- 购买时进行额度检查和库存检查
|
||||
- 使用事务保证数据一致性(在额度扣除失败时回滚)
|
||||
|
||||
## UI/UX 特性
|
||||
|
||||
- 响应式设计,支持不同屏幕尺寸
|
||||
- 使用 Semi Design 组件库,保持与项目整体风格一致
|
||||
- 卡片式布局展示服务器,直观美观
|
||||
- 实时显示剩余天数和过期状态
|
||||
- 搜索和分页功能,方便管理大量数据
|
||||
- 友好的确认对话框和成功提示
|
||||
|
||||
## 扩展性
|
||||
|
||||
该实现具有良好的扩展性,可以轻松添加:
|
||||
- 服务器分类
|
||||
- 优惠券/折扣功能
|
||||
- 自动续费
|
||||
- 服务器到期提醒(邮件/站内信)
|
||||
- 更多的服务器配置字段
|
||||
- 批量操作功能
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. 测试管理员创建、编辑、删除服务器配置
|
||||
2. 测试用户购买服务器(正常流程)
|
||||
3. 测试额度不足时的购买失败
|
||||
4. 测试库存为0时无法购买
|
||||
5. 测试已禁用服务器无法购买
|
||||
6. 测试服务器配置的查看功能
|
||||
7. 测试搜索和分页功能
|
||||
8. 测试过期服务器的状态显示
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保 `common.QuotaPerUnit` 配置正确
|
||||
2. 购买时会记录操作日志,可在日志页面查看
|
||||
3. 服务器配置可以包含任意文本,建议使用 JSON 或 YAML 格式
|
||||
4. 库存设置为 -1 表示无限库存
|
||||
5. 到期时间自动计算:购买时间 + 时长(天)× 86400 秒
|
||||
468
controller/server.go
Normal file
468
controller/server.go
Normal file
@ -0,0 +1,468 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// GetAllServers 获取所有服务器配置(管理员)
|
||||
func GetAllServers(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if pageSize <= 0 {
|
||||
pageSize = common.ItemsPerPage
|
||||
}
|
||||
|
||||
pageInfo := &common.PageInfo{
|
||||
Page: p,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
|
||||
servers, total, err := model.GetAllServers(pageInfo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": servers,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchServers 搜索服务器配置(管理员)
|
||||
func SearchServers(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if pageSize <= 0 {
|
||||
pageSize = common.ItemsPerPage
|
||||
}
|
||||
|
||||
servers, total, err := model.SearchServers(keyword, p*pageSize, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": servers,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GetServer 获取单个服务器配置(管理员)
|
||||
func GetServer(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
server, err := model.GetServerById(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": server,
|
||||
})
|
||||
}
|
||||
|
||||
// AddServer 添加服务器配置(管理员)
|
||||
func AddServer(c *gin.Context) {
|
||||
server := model.Server{}
|
||||
err := c.ShouldBindJSON(&server)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
err = server.Insert()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": server,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateServer 更新服务器配置(管理员)
|
||||
func UpdateServer(c *gin.Context) {
|
||||
server := model.Server{}
|
||||
err := c.ShouldBindJSON(&server)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
err = server.Update()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": server,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteServer 删除服务器配置(管理员)
|
||||
func DeleteServer(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
err = model.DeleteServerById(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
}
|
||||
|
||||
// GetEnabledServers 获取启用的服务器列表(用户)
|
||||
func GetEnabledServers(c *gin.Context) {
|
||||
servers, err := model.GetEnabledServers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": servers,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllPurchasedServers 获取所有已购服务器(管理员)
|
||||
func GetAllPurchasedServers(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if pageSize <= 0 {
|
||||
pageSize = common.ItemsPerPage
|
||||
}
|
||||
|
||||
pageInfo := &common.PageInfo{
|
||||
Page: p,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
|
||||
servers, total, err := model.GetAllPurchasedServers(pageInfo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户名
|
||||
type PurchasedServerWithUsername struct {
|
||||
model.PurchasedServer
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
serversWithUsername := make([]PurchasedServerWithUsername, len(servers))
|
||||
for i, server := range servers {
|
||||
username, _ := model.GetUsernameById(server.UserId, false)
|
||||
serversWithUsername[i] = PurchasedServerWithUsername{
|
||||
PurchasedServer: *server,
|
||||
Username: username,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": serversWithUsername,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchPurchasedServers 搜索已购服务器(管理员)
|
||||
func SearchPurchasedServers(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if pageSize <= 0 {
|
||||
pageSize = common.ItemsPerPage
|
||||
}
|
||||
|
||||
servers, total, err := model.SearchPurchasedServers(keyword, p*pageSize, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户名
|
||||
type PurchasedServerWithUsername struct {
|
||||
model.PurchasedServer
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
serversWithUsername := make([]PurchasedServerWithUsername, len(servers))
|
||||
for i, server := range servers {
|
||||
username, _ := model.GetUsernameById(server.UserId, false)
|
||||
serversWithUsername[i] = PurchasedServerWithUsername{
|
||||
PurchasedServer: *server,
|
||||
Username: username,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": serversWithUsername,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserPurchasedServers 获取用户已购服务器列表
|
||||
func GetUserPurchasedServers(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if pageSize <= 0 {
|
||||
pageSize = common.ItemsPerPage
|
||||
}
|
||||
|
||||
pageInfo := &common.PageInfo{
|
||||
Page: p,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
|
||||
servers, total, err := model.GetUserPurchasedServers(userId, pageInfo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": servers,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
type PurchaseServerRequest struct {
|
||||
ServerId int `json:"server_id" binding:"required"`
|
||||
TradeNo string `json:"trade_no"`
|
||||
}
|
||||
|
||||
// PurchaseServer 购买服务器
|
||||
func PurchaseServer(c *gin.Context) {
|
||||
var req PurchaseServerRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
|
||||
// 获取服务器配置
|
||||
server, err := model.GetServerById(req.ServerId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "服务器配置不存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if server.Status != 1 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "服务器已下架",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查库存
|
||||
if server.Stock == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "服务器库存不足",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 计算需要扣除的额度
|
||||
dPrice := decimal.NewFromFloat(server.Price)
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
quotaNeeded := int(dPrice.Mul(dQuotaPerUnit).IntPart())
|
||||
|
||||
// 检查用户额度
|
||||
userQuota, err := model.GetUserQuota(userId, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "获取用户额度失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if userQuota < quotaNeeded {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("额度不足,需要 %s,当前额度 %s", common.LogQuota(quotaNeeded), common.LogQuota(userQuota)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 扣除用户额度
|
||||
err = model.DecreaseUserQuota(userId, quotaNeeded)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "扣除额度失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建已购服务器记录
|
||||
now := time.Now().Unix()
|
||||
expireTime := now + int64(server.Duration*24*3600)
|
||||
|
||||
tradeNo := req.TradeNo
|
||||
if tradeNo == "" {
|
||||
tradeNo = fmt.Sprintf("SRV%dNO%s", userId, fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()))
|
||||
}
|
||||
|
||||
purchasedServer := &model.PurchasedServer{
|
||||
UserId: userId,
|
||||
ServerId: server.Id,
|
||||
ServerTitle: server.Title,
|
||||
ServerConfig: server.Config,
|
||||
Price: server.Price,
|
||||
Duration: server.Duration,
|
||||
PurchaseTime: now,
|
||||
ExpireTime: expireTime,
|
||||
Status: 1,
|
||||
TradeNo: tradeNo,
|
||||
}
|
||||
|
||||
err = purchasedServer.Insert()
|
||||
if err != nil {
|
||||
// 回滚额度
|
||||
_ = model.IncreaseUserQuota(userId, quotaNeeded, true)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "创建订单失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新库存
|
||||
if server.Stock > 0 {
|
||||
server.Stock--
|
||||
_ = server.Update()
|
||||
}
|
||||
|
||||
// 记录日志
|
||||
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("购买服务器 %s,消耗额度 %s,有效期 %d 天", server.Title, common.LogQuota(quotaNeeded), server.Duration))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "购买成功",
|
||||
"data": purchasedServer,
|
||||
})
|
||||
}
|
||||
|
||||
// DeletePurchasedServer 删除已购服务器记录(管理员)
|
||||
func DeletePurchasedServer(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
err = model.DeletePurchasedServerById(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
}
|
||||
@ -251,6 +251,8 @@ func migrateDB() error {
|
||||
&QuotaData{},
|
||||
&Task{},
|
||||
&Setup{},
|
||||
&Server{},
|
||||
&PurchasedServer{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -277,6 +279,8 @@ func migrateDBFast() error {
|
||||
{&QuotaData{}, "QuotaData"},
|
||||
{&Task{}, "Task"},
|
||||
{&Setup{}, "Setup"},
|
||||
{&Server{}, "Server"},
|
||||
{&PurchasedServer{}, "PurchasedServer"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
errChan := make(chan error, len(migrations))
|
||||
|
||||
158
model/purchased_server.go
Normal file
158
model/purchased_server.go
Normal file
@ -0,0 +1,158 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"one-api/common"
|
||||
)
|
||||
|
||||
// PurchasedServer 已购服务器表
|
||||
type PurchasedServer struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id" gorm:"index"` // 用户ID
|
||||
ServerId int `json:"server_id" gorm:"index"` // 服务器配置ID
|
||||
ServerTitle string `json:"server_title" gorm:"type:varchar(255)"` // 服务器标题(冗余字段,防止配置被删除)
|
||||
ServerConfig string `json:"server_config" gorm:"type:text"` // 服务器配置(冗余字段)
|
||||
Price float64 `json:"price"` // 购买价格
|
||||
Duration int `json:"duration"` // 购买时长(天)
|
||||
PurchaseTime int64 `json:"purchase_time" gorm:"bigint;index"` // 购买时间
|
||||
ExpireTime int64 `json:"expire_time" gorm:"bigint;index"` // 到期时间
|
||||
Status int `json:"status" gorm:"default:1"` // 状态:1-正常,2-已过期
|
||||
TradeNo string `json:"trade_no" gorm:"type:varchar(255);index"` // 交易单号
|
||||
}
|
||||
|
||||
func (ps *PurchasedServer) Insert() error {
|
||||
return DB.Create(ps).Error
|
||||
}
|
||||
|
||||
func (ps *PurchasedServer) Update() error {
|
||||
return DB.Save(ps).Error
|
||||
}
|
||||
|
||||
func GetPurchasedServerById(id int) (*PurchasedServer, error) {
|
||||
if id == 0 {
|
||||
return nil, errors.New("id 为空")
|
||||
}
|
||||
ps := PurchasedServer{Id: id}
|
||||
err := DB.First(&ps, "id = ?", id).Error
|
||||
return &ps, err
|
||||
}
|
||||
|
||||
func GetPurchasedServerByTradeNo(tradeNo string) (*PurchasedServer, error) {
|
||||
var ps PurchasedServer
|
||||
err := DB.Where("trade_no = ?", tradeNo).First(&ps).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ps, nil
|
||||
}
|
||||
|
||||
func GetAllPurchasedServers(pageInfo *common.PageInfo) (servers []*PurchasedServer, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, 0, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
err = tx.Model(&PurchasedServer{}).Count(&total).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = tx.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&servers).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return servers, total, nil
|
||||
}
|
||||
|
||||
func GetUserPurchasedServers(userId int, pageInfo *common.PageInfo) (servers []*PurchasedServer, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, 0, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
err = tx.Model(&PurchasedServer{}).Where("user_id = ?", userId).Count(&total).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = tx.Where("user_id = ?", userId).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&servers).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return servers, total, nil
|
||||
}
|
||||
|
||||
func SearchPurchasedServers(keyword string, startIdx int, num int) (servers []*PurchasedServer, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, 0, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
query := tx.Model(&PurchasedServer{})
|
||||
if keyword != "" {
|
||||
// 支持按用户ID、服务器标题搜索
|
||||
query = query.Where("server_title LIKE ? OR user_id = ?", "%"+keyword+"%", keyword)
|
||||
}
|
||||
|
||||
err = query.Count(&total).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&servers).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return servers, total, nil
|
||||
}
|
||||
|
||||
func DeletePurchasedServerById(id int) error {
|
||||
if id == 0 {
|
||||
return errors.New("id 为空")
|
||||
}
|
||||
return DB.Delete(&PurchasedServer{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// UpdateExpiredServers 更新过期服务器状态
|
||||
func UpdateExpiredServers() error {
|
||||
now := common.GetTimestamp()
|
||||
return DB.Model(&PurchasedServer{}).
|
||||
Where("expire_time < ? AND status = ?", now, 1).
|
||||
Update("status", 2).Error
|
||||
}
|
||||
121
model/server.go
Normal file
121
model/server.go
Normal file
@ -0,0 +1,121 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"one-api/common"
|
||||
)
|
||||
|
||||
// Server 服务器配置表
|
||||
type Server struct {
|
||||
Id int `json:"id"`
|
||||
Title string `json:"title" gorm:"type:varchar(255);not null"` // 服务器标题
|
||||
Config string `json:"config" gorm:"type:text"` // 配置信息
|
||||
Price float64 `json:"price" gorm:"not null"` // 价格(单位:元)
|
||||
Duration int `json:"duration" gorm:"not null"` // 时长(单位:天)
|
||||
Status int `json:"status" gorm:"default:1"` // 状态:1-启用,2-禁用
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
Description string `json:"description" gorm:"type:text"` // 服务器描述
|
||||
Stock int `json:"stock" gorm:"default:-1"` // 库存数量,-1表示无限
|
||||
}
|
||||
|
||||
func (server *Server) Insert() error {
|
||||
server.CreatedTime = common.GetTimestamp()
|
||||
server.UpdatedTime = common.GetTimestamp()
|
||||
return DB.Create(server).Error
|
||||
}
|
||||
|
||||
func (server *Server) Update() error {
|
||||
server.UpdatedTime = common.GetTimestamp()
|
||||
return DB.Save(server).Error
|
||||
}
|
||||
|
||||
func GetServerById(id int) (*Server, error) {
|
||||
if id == 0 {
|
||||
return nil, errors.New("id 为空")
|
||||
}
|
||||
server := Server{Id: id}
|
||||
err := DB.First(&server, "id = ?", id).Error
|
||||
return &server, err
|
||||
}
|
||||
|
||||
func GetAllServers(pageInfo *common.PageInfo) (servers []*Server, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, 0, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
err = tx.Model(&Server{}).Count(&total).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = tx.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&servers).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return servers, total, nil
|
||||
}
|
||||
|
||||
func GetEnabledServers() ([]*Server, error) {
|
||||
var servers []*Server
|
||||
err := DB.Where("status = ?", 1).Order("id asc").Find(&servers).Error
|
||||
return servers, err
|
||||
}
|
||||
|
||||
func DeleteServerById(id int) error {
|
||||
if id == 0 {
|
||||
return errors.New("id 为空")
|
||||
}
|
||||
return DB.Delete(&Server{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func SearchServers(keyword string, startIdx int, num int) ([]*Server, int64, error) {
|
||||
var servers []*Server
|
||||
var total int64
|
||||
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, 0, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
query := tx.Model(&Server{})
|
||||
if keyword != "" {
|
||||
query = query.Where("title LIKE ? OR description LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
err := query.Count(&total).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&servers).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return servers, total, nil
|
||||
}
|
||||
@ -175,5 +175,26 @@ func SetApiRouter(router *gin.Engine) {
|
||||
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
|
||||
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
|
||||
}
|
||||
|
||||
serverRoute := apiRouter.Group("/server")
|
||||
{
|
||||
// 管理员路由
|
||||
serverRoute.GET("/", middleware.AdminAuth(), controller.GetAllServers)
|
||||
serverRoute.GET("/search", middleware.AdminAuth(), controller.SearchServers)
|
||||
serverRoute.GET("/:id", middleware.AdminAuth(), controller.GetServer)
|
||||
serverRoute.POST("/", middleware.AdminAuth(), controller.AddServer)
|
||||
serverRoute.PUT("/", middleware.AdminAuth(), controller.UpdateServer)
|
||||
serverRoute.DELETE("/:id", middleware.AdminAuth(), controller.DeleteServer)
|
||||
|
||||
// 用户路由
|
||||
serverRoute.GET("/enabled", middleware.UserAuth(), controller.GetEnabledServers)
|
||||
serverRoute.POST("/purchase", middleware.UserAuth(), controller.PurchaseServer)
|
||||
serverRoute.GET("/purchased/self", middleware.UserAuth(), controller.GetUserPurchasedServers)
|
||||
|
||||
// 已购服务器管理(管理员)
|
||||
serverRoute.GET("/purchased", middleware.AdminAuth(), controller.GetAllPurchasedServers)
|
||||
serverRoute.GET("/purchased/search", middleware.AdminAuth(), controller.SearchPurchasedServers)
|
||||
serverRoute.DELETE("/purchased/:id", middleware.AdminAuth(), controller.DeletePurchasedServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,5 +82,11 @@
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true
|
||||
},
|
||||
"proxy": "http://localhost:3000"
|
||||
"proxy": "http://localhost:3000",
|
||||
"overrides": {
|
||||
"@lobehub/icons": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,10 @@ import OAuth2Callback from './components/auth/OAuth2Callback.js';
|
||||
import PersonalSetting from './components/settings/PersonalSetting.js';
|
||||
import Setup from './pages/Setup/index.js';
|
||||
import SetupCheck from './components/layout/SetupCheck.js';
|
||||
import Server from './pages/Server';
|
||||
import PurchasedServer from './pages/PurchasedServer';
|
||||
import ServerShop from './pages/ServerShop';
|
||||
import MyServers from './pages/MyServers';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Detail = lazy(() => import('./pages/Detail'));
|
||||
@ -273,6 +277,38 @@ function App() {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/server'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Server />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/purchased-server'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<PurchasedServer />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/server-shop'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ServerShop />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/my-servers'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<MyServers />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/pricing'
|
||||
element={
|
||||
|
||||
@ -34,6 +34,10 @@ const routerMap = {
|
||||
task: '/console/task',
|
||||
playground: '/console/playground',
|
||||
personal: '/console/personal',
|
||||
server: '/console/server',
|
||||
'purchased-server': '/console/purchased-server',
|
||||
'server-shop': '/console/server-shop',
|
||||
'my-servers': '/console/my-servers',
|
||||
};
|
||||
|
||||
const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
@ -110,6 +114,16 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
itemKey: 'topup',
|
||||
to: '/topup',
|
||||
},
|
||||
{
|
||||
text: t('服务器购买'),
|
||||
itemKey: 'server-shop',
|
||||
to: '/server-shop',
|
||||
},
|
||||
{
|
||||
text: t('我的服务器'),
|
||||
itemKey: 'my-servers',
|
||||
to: '/my-servers',
|
||||
},
|
||||
{
|
||||
text: t('个人设置'),
|
||||
itemKey: 'personal',
|
||||
@ -145,6 +159,18 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
to: '/invoice-management',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('服务器管理'),
|
||||
itemKey: 'server',
|
||||
to: '/server',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('已售服务器'),
|
||||
itemKey: 'purchased-server',
|
||||
to: '/purchased-server',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('系统设置'),
|
||||
itemKey: 'setting',
|
||||
@ -262,6 +288,10 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
case 'user':
|
||||
case 'personal': return sidebarIconColors.user;
|
||||
case 'setting': return sidebarIconColors.settings;
|
||||
case 'server':
|
||||
case 'purchased-server':
|
||||
case 'server-shop':
|
||||
case 'my-servers': return sidebarIconColors.server;
|
||||
default:
|
||||
// 处理聊天项
|
||||
if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message;
|
||||
|
||||
301
web/src/components/table/PurchasedServersTable.js
Normal file
301
web/src/components/table/PurchasedServersTable.js
Normal file
@ -0,0 +1,301 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../../helpers';
|
||||
|
||||
import { Server } from 'lucide-react';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch, IconMore } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
|
||||
const PurchasedServersTable = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isCompactMode } = useTableCompactMode();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('ID'),
|
||||
dataIndex: 'id',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
title: t('用户名'),
|
||||
dataIndex: 'username',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: t('用户ID'),
|
||||
dataIndex: 'user_id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: t('服务器标题'),
|
||||
dataIndex: 'server_title',
|
||||
},
|
||||
{
|
||||
title: t('价格'),
|
||||
dataIndex: 'price',
|
||||
render: (text) => {
|
||||
return <Tag color='green'>¥{text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('购买时间'),
|
||||
dataIndex: 'purchase_time',
|
||||
render: (text) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('到期时间'),
|
||||
dataIndex: 'expire_time',
|
||||
render: (text) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const isExpired = text < now;
|
||||
return (
|
||||
<div>
|
||||
{renderTimestamp(text)}
|
||||
{isExpired && <Tag color='red' style={{ marginLeft: 8 }}>{t('已过期')}</Tag>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (text) => {
|
||||
return text === 1 ? (
|
||||
<Tag color='green'>{t('正常')}</Tag>
|
||||
) : (
|
||||
<Tag color='red'>{t('已过期')}</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => {
|
||||
const moreMenuItems = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('查看配置'),
|
||||
onClick: () => {
|
||||
Modal.info({
|
||||
title: t('服务器配置'),
|
||||
content: (
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{record.server_config}
|
||||
</div>
|
||||
),
|
||||
style: { width: 600 },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('删除'),
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除此记录?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => {
|
||||
(async () => {
|
||||
await deletePurchasedServer(record.id);
|
||||
await refresh();
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Dropdown
|
||||
menu={moreMenuItems}
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
>
|
||||
<Button type='tertiary' size='small' icon={<IconMore />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [servers, setServers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const loadServers = async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/server/purchased?p=${page - 1}&page_size=${ITEMS_PER_PAGE}`);
|
||||
const { success, message, data, total } = res.data;
|
||||
if (success) {
|
||||
if (page === 1) {
|
||||
setServers(data);
|
||||
} else {
|
||||
let newServers = [...servers];
|
||||
newServers.splice((page - 1) * ITEMS_PER_PAGE, data.length, ...data);
|
||||
setServers(newServers);
|
||||
}
|
||||
setTotal(total);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const searchServers = async (page = 1) => {
|
||||
if (searchKeyword === '') {
|
||||
await loadServers(page);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/server/purchased/search?keyword=${searchKeyword}&p=${page - 1}&page_size=${ITEMS_PER_PAGE}`
|
||||
);
|
||||
const { success, message, data, total } = res.data;
|
||||
if (success) {
|
||||
setServers(data);
|
||||
setTotal(total);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const refresh = async (page = activePage) => {
|
||||
if (searchKeyword === '') {
|
||||
await loadServers(page);
|
||||
} else {
|
||||
await searchServers(page);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadServers().then();
|
||||
}, []);
|
||||
|
||||
const deletePurchasedServer = async (id) => {
|
||||
try {
|
||||
const res = await API.delete(`/api/server/purchased/${id}`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('删除成功'));
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
refresh(page).then();
|
||||
};
|
||||
|
||||
const handleKeywordChange = (value) => {
|
||||
setSearchKeyword(value);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setActivePage(1);
|
||||
searchServers(1).then();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space vertical align='start'>
|
||||
<Space>
|
||||
<Server size={20} />
|
||||
<Text>{t('已售服务器列表')}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
headerExtraContent={
|
||||
<Space>
|
||||
<Input
|
||||
placeholder={t('搜索用户ID或服务器标题')}
|
||||
value={searchKeyword}
|
||||
onChange={handleKeywordChange}
|
||||
onEnterPress={handleSearch}
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
<Button onClick={handleSearch} loading={searching}>
|
||||
{t('搜索')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={servers}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: ITEMS_PER_PAGE,
|
||||
total: total,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
loading={loading}
|
||||
size={isCompactMode ? 'small' : 'default'}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description={t('暂无数据')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchasedServersTable;
|
||||
411
web/src/components/table/ServersTable.js
Normal file
411
web/src/components/table/ServersTable.js
Normal file
@ -0,0 +1,411 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../../helpers';
|
||||
|
||||
import { Server } from 'lucide-react';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Input,
|
||||
InputNumber,
|
||||
TextArea,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch, IconMore } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
|
||||
const ServersTable = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isCompactMode } = useTableCompactMode();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('ID'),
|
||||
dataIndex: 'id',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
title: t('标题'),
|
||||
dataIndex: 'title',
|
||||
},
|
||||
{
|
||||
title: t('价格'),
|
||||
dataIndex: 'price',
|
||||
render: (text) => {
|
||||
return <Tag color='green'>¥{text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('时长(天)'),
|
||||
dataIndex: 'duration',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: t('库存'),
|
||||
dataIndex: 'stock',
|
||||
width: 80,
|
||||
render: (text) => {
|
||||
if (text === -1) {
|
||||
return <Tag color='blue'>{t('无限')}</Tag>;
|
||||
}
|
||||
return <Tag color={text > 0 ? 'green' : 'red'}>{text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (text) => {
|
||||
return text === 1 ? (
|
||||
<Tag color='green'>{t('启用')}</Tag>
|
||||
) : (
|
||||
<Tag color='red'>{t('禁用')}</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_time',
|
||||
render: (text) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (text, record) => {
|
||||
const moreMenuItems = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('删除'),
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除此服务器配置?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => {
|
||||
(async () => {
|
||||
await deleteServer(record.id);
|
||||
await refresh();
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Button type='primary' size='small' onClick={() => editServer(record)}>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={moreMenuItems}
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
>
|
||||
<Button type='tertiary' size='small' icon={<IconMore />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [servers, setServers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [editingServer, setEditingServer] = useState(null);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
|
||||
const loadServers = async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/server/?p=${page - 1}&page_size=${ITEMS_PER_PAGE}`);
|
||||
const { success, message, data, total } = res.data;
|
||||
if (success) {
|
||||
if (page === 1) {
|
||||
setServers(data);
|
||||
} else {
|
||||
let newServers = [...servers];
|
||||
newServers.splice((page - 1) * ITEMS_PER_PAGE, data.length, ...data);
|
||||
setServers(newServers);
|
||||
}
|
||||
setTotal(total);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const searchServers = async (page = 1) => {
|
||||
if (searchKeyword === '') {
|
||||
await loadServers(page);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/server/search?keyword=${searchKeyword}&p=${page - 1}&page_size=${ITEMS_PER_PAGE}`
|
||||
);
|
||||
const { success, message, data, total } = res.data;
|
||||
if (success) {
|
||||
setServers(data);
|
||||
setTotal(total);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const refresh = async (page = activePage) => {
|
||||
if (searchKeyword === '') {
|
||||
await loadServers(page);
|
||||
} else {
|
||||
await searchServers(page);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadServers().then();
|
||||
}, []);
|
||||
|
||||
const deleteServer = async (id) => {
|
||||
try {
|
||||
const res = await API.delete(`/api/server/${id}`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('删除成功'));
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const editServer = (server) => {
|
||||
setEditingServer(server);
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
const addServer = () => {
|
||||
setEditingServer({
|
||||
title: '',
|
||||
config: '',
|
||||
price: 0,
|
||||
duration: 30,
|
||||
status: 1,
|
||||
description: '',
|
||||
stock: -1,
|
||||
});
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
const handleEditClose = () => {
|
||||
setShowEdit(false);
|
||||
setEditingServer(null);
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (values) => {
|
||||
try {
|
||||
let res;
|
||||
if (editingServer.id) {
|
||||
res = await API.put('/api/server/', { ...values, id: editingServer.id });
|
||||
} else {
|
||||
res = await API.post('/api/server/', values);
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(editingServer.id ? t('更新成功') : t('创建成功'));
|
||||
setShowEdit(false);
|
||||
setEditingServer(null);
|
||||
await refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
refresh(page).then();
|
||||
};
|
||||
|
||||
const handleKeywordChange = (value) => {
|
||||
setSearchKeyword(value);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setActivePage(1);
|
||||
searchServers(1).then();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<Space vertical align='start'>
|
||||
<Space>
|
||||
<Server size={20} />
|
||||
<Text>{t('服务器管理')}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
headerExtraContent={
|
||||
<Space>
|
||||
<Input
|
||||
placeholder={t('搜索服务器标题')}
|
||||
value={searchKeyword}
|
||||
onChange={handleKeywordChange}
|
||||
onEnterPress={handleSearch}
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
<Button onClick={handleSearch} loading={searching}>
|
||||
{t('搜索')}
|
||||
</Button>
|
||||
<Button theme='solid' onClick={addServer}>
|
||||
{t('添加服务器')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={servers}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: ITEMS_PER_PAGE,
|
||||
total: total,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
loading={loading}
|
||||
size={isCompactMode ? 'small' : 'default'}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description={t('暂无数据')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingServer?.id ? t('编辑服务器') : t('添加服务器')}
|
||||
visible={showEdit}
|
||||
onCancel={handleEditClose}
|
||||
footer={null}
|
||||
style={{ width: 600 }}
|
||||
>
|
||||
{editingServer && (
|
||||
<Form
|
||||
initValues={editingServer}
|
||||
onSubmit={handleEditSubmit}
|
||||
labelPosition='left'
|
||||
labelAlign='right'
|
||||
labelWidth={100}
|
||||
>
|
||||
<Form.Input
|
||||
field='title'
|
||||
label={t('标题')}
|
||||
placeholder={t('请输入服务器标题')}
|
||||
rules={[{ required: true, message: t('标题不能为空') }]}
|
||||
/>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入服务器描述')}
|
||||
rows={3}
|
||||
/>
|
||||
<Form.TextArea
|
||||
field='config'
|
||||
label={t('配置信息')}
|
||||
placeholder={t('请输入服务器配置信息')}
|
||||
rows={5}
|
||||
rules={[{ required: true, message: t('配置信息不能为空') }]}
|
||||
/>
|
||||
<Form.InputNumber
|
||||
field='price'
|
||||
label={t('价格(元)')}
|
||||
placeholder={t('请输入价格')}
|
||||
min={0}
|
||||
step={0.01}
|
||||
rules={[{ required: true, message: t('价格不能为空') }]}
|
||||
/>
|
||||
<Form.InputNumber
|
||||
field='duration'
|
||||
label={t('时长(天)')}
|
||||
placeholder={t('请输入有效时长')}
|
||||
min={1}
|
||||
rules={[{ required: true, message: t('时长不能为空') }]}
|
||||
/>
|
||||
<Form.InputNumber
|
||||
field='stock'
|
||||
label={t('库存')}
|
||||
placeholder={t('请输入库存数量,-1表示无限')}
|
||||
min={-1}
|
||||
/>
|
||||
<Form.Select
|
||||
field='status'
|
||||
label={t('状态')}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select.Option value={1}>{t('启用')}</Select.Option>
|
||||
<Select.Option value={2}>{t('禁用')}</Select.Option>
|
||||
</Form.Select>
|
||||
<Space style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={handleEditClose}>{t('取消')}</Button>
|
||||
<Button theme='solid' htmlType='submit'>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServersTable;
|
||||
@ -50,6 +50,7 @@ import {
|
||||
User,
|
||||
Settings,
|
||||
CircleUser,
|
||||
Server as ServerIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
// 侧边栏图标颜色映射
|
||||
@ -66,6 +67,7 @@ export const sidebarIconColors = {
|
||||
gift: '#F43F5E', // 玫红色
|
||||
user: '#10B981', // 绿色
|
||||
settings: '#F97316', // 橙色
|
||||
server: '#8B5CF6', // 紫色
|
||||
};
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
@ -165,6 +167,16 @@ export function getLucideIcon(key, selected = false) {
|
||||
color={selected ? sidebarIconColors.settings : 'currentColor'}
|
||||
/>
|
||||
);
|
||||
case 'server':
|
||||
case 'purchased-server':
|
||||
case 'server-shop':
|
||||
case 'my-servers':
|
||||
return (
|
||||
<ServerIcon
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.server : 'currentColor'}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<CircleUser
|
||||
|
||||
229
web/src/pages/MyServers/index.js
Normal file
229
web/src/pages/MyServers/index.js
Normal file
@ -0,0 +1,229 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
timestamp2string,
|
||||
} from '../../helpers';
|
||||
|
||||
import { Server } from 'lucide-react';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
|
||||
const MyServers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isCompactMode } = useTableCompactMode();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('ID'),
|
||||
dataIndex: 'id',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
title: t('服务器标题'),
|
||||
dataIndex: 'server_title',
|
||||
},
|
||||
{
|
||||
title: t('价格'),
|
||||
dataIndex: 'price',
|
||||
render: (text) => {
|
||||
return <Tag color='green'>¥{text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('购买时间'),
|
||||
dataIndex: 'purchase_time',
|
||||
render: (text) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('到期时间'),
|
||||
dataIndex: 'expire_time',
|
||||
render: (text) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const isExpired = text < now;
|
||||
const daysLeft = Math.ceil((text - now) / 86400);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderTimestamp(text)}
|
||||
{isExpired ? (
|
||||
<Tag color='red' style={{ marginLeft: 8 }}>{t('已过期')}</Tag>
|
||||
) : daysLeft <= 7 ? (
|
||||
<Tag color='orange' style={{ marginLeft: 8 }}>
|
||||
{t('剩余')} {daysLeft} {t('天')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' style={{ marginLeft: 8 }}>
|
||||
{t('剩余')} {daysLeft} {t('天')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (text) => {
|
||||
return text === 1 ? (
|
||||
<Tag color='green'>{t('正常')}</Tag>
|
||||
) : (
|
||||
<Tag color='red'>{t('已过期')}</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<Button
|
||||
type='primary'
|
||||
size='small'
|
||||
onClick={() => showConfig(record)}
|
||||
>
|
||||
{t('查看配置')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [servers, setServers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const loadServers = async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/server/purchased/self?p=${page - 1}&page_size=${ITEMS_PER_PAGE}`);
|
||||
const { success, message, data, total } = res.data;
|
||||
if (success) {
|
||||
if (page === 1) {
|
||||
setServers(data || []);
|
||||
} else {
|
||||
let newServers = [...servers];
|
||||
newServers.splice((page - 1) * ITEMS_PER_PAGE, data.length, ...data);
|
||||
setServers(newServers);
|
||||
}
|
||||
setTotal(total);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadServers();
|
||||
}, []);
|
||||
|
||||
const showConfig = (server) => {
|
||||
Modal.info({
|
||||
title: (
|
||||
<Space>
|
||||
<Server size={20} />
|
||||
<span>{server.server_title} - {t('配置信息')}</span>
|
||||
</Space>
|
||||
),
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Tag color='blue'>{t('购买时间')}: {timestamp2string(server.purchase_time)}</Tag>
|
||||
<Tag color='orange'>{t('到期时间')}: {timestamp2string(server.expire_time)}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '15px',
|
||||
borderRadius: '4px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
maxHeight: '400px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{server.server_config}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
style: { width: 700 },
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
loadServers(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
<Card
|
||||
title={
|
||||
<Space vertical align='start'>
|
||||
<Space>
|
||||
<Server size={20} />
|
||||
<Text>{t('我的服务器')}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={servers}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: ITEMS_PER_PAGE,
|
||||
total: total,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
loading={loading}
|
||||
size={isCompactMode ? 'small' : 'default'}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description={t('您还没有购买任何服务器')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyServers;
|
||||
12
web/src/pages/PurchasedServer/index.js
Normal file
12
web/src/pages/PurchasedServer/index.js
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import PurchasedServersTable from '../../components/table/PurchasedServersTable';
|
||||
|
||||
const PurchasedServer = () => {
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
<PurchasedServersTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchasedServer;
|
||||
12
web/src/pages/Server/index.js
Normal file
12
web/src/pages/Server/index.js
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import ServersTable from '../../components/table/ServersTable';
|
||||
|
||||
const Server = () => {
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
<ServersTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Server;
|
||||
208
web/src/pages/ServerShop/index.js
Normal file
208
web/src/pages/ServerShop/index.js
Normal file
@ -0,0 +1,208 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, showError, showSuccess, renderQuota } from '../../helpers';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Tag,
|
||||
Modal,
|
||||
Spin,
|
||||
Empty,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Server, ShoppingCart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
const ServerShop = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
|
||||
const loadServers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/server/enabled');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setServers(data || []);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadServers();
|
||||
}, []);
|
||||
|
||||
const handlePurchase = (server) => {
|
||||
Modal.confirm({
|
||||
title: t('确认购买'),
|
||||
content: (
|
||||
<div>
|
||||
<p>{t('服务器')}: {server.title}</p>
|
||||
<p>{t('价格')}: ¥{server.price}</p>
|
||||
<p>{t('有效期')}: {server.duration} {t('天')}</p>
|
||||
<p style={{ color: 'red', marginTop: 10 }}>
|
||||
{t('购买后将从您的账户额度中扣除相应金额')}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
setPurchasing(true);
|
||||
try {
|
||||
const res = await API.post('/api/server/purchase', {
|
||||
server_id: server.id,
|
||||
});
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('购买成功'));
|
||||
Modal.success({
|
||||
title: t('购买成功'),
|
||||
content: (
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
<p><strong>{t('服务器配置')}:</strong></p>
|
||||
<div style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
marginTop: '10px'
|
||||
}}>
|
||||
{data.server_config}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
style: { width: 600 },
|
||||
});
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
setPurchasing(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-[64px] px-2">
|
||||
<Card>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<ShoppingCart size={24} />
|
||||
<Title heading={3}>{t('服务器购买')}</Title>
|
||||
</Space>
|
||||
<Paragraph>{t('选择您需要的服务器配置进行购买')}</Paragraph>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{servers.length === 0 ? (
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description={t('暂无可购买的服务器')}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
{servers.map((server) => (
|
||||
<Col span={8} key={server.id}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Server size={20} />
|
||||
<Text strong>{server.title}</Text>
|
||||
</Space>
|
||||
}
|
||||
headerExtraContent={
|
||||
<Tag color='green' size='large'>
|
||||
¥{server.price}
|
||||
</Tag>
|
||||
}
|
||||
bodyStyle={{ minHeight: 200 }}
|
||||
>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
{server.description && (
|
||||
<div>
|
||||
<Text type='secondary'>{server.description}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space>
|
||||
<Tag color='blue'>{t('有效期')}: {server.duration} {t('天')}</Tag>
|
||||
{server.stock === -1 ? (
|
||||
<Tag color='green'>{t('库存充足')}</Tag>
|
||||
) : (
|
||||
<Tag color={server.stock > 0 ? 'orange' : 'red'}>
|
||||
{t('剩余')}: {server.stock}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{server.config && (
|
||||
<div style={{ marginTop: 16, width: '100%' }}>
|
||||
<Text strong>{t('配置预览')}:</Text>
|
||||
<div
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
marginTop: '8px',
|
||||
maxHeight: '100px',
|
||||
overflow: 'auto',
|
||||
fontSize: '12px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{server.config.length > 200
|
||||
? server.config.substring(0, 200) + '...'
|
||||
: server.config}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
theme='solid'
|
||||
block
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => handlePurchase(server)}
|
||||
loading={purchasing}
|
||||
disabled={server.stock === 0}
|
||||
>
|
||||
{server.stock === 0 ? t('已售罄') : t('立即购买')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerShop;
|
||||
Loading…
x
Reference in New Issue
Block a user