服务器购买

This commit is contained in:
Mosijiu 2025-10-09 10:44:22 +08:00
parent d807c1d7f3
commit 324862a83a
16 changed files with 2249 additions and 1 deletions

View 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
View 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": "",
})
}

View File

@ -251,6 +251,8 @@ func migrateDB() error {
&QuotaData{}, &QuotaData{},
&Task{}, &Task{},
&Setup{}, &Setup{},
&Server{},
&PurchasedServer{},
) )
if err != nil { if err != nil {
return err return err
@ -277,6 +279,8 @@ func migrateDBFast() error {
{&QuotaData{}, "QuotaData"}, {&QuotaData{}, "QuotaData"},
{&Task{}, "Task"}, {&Task{}, "Task"},
{&Setup{}, "Setup"}, {&Setup{}, "Setup"},
{&Server{}, "Server"},
{&PurchasedServer{}, "PurchasedServer"},
} }
// 动态计算migration数量,确保errChan缓冲区足够大 // 动态计算migration数量,确保errChan缓冲区足够大
errChan := make(chan error, len(migrations)) errChan := make(chan error, len(migrations))

158
model/purchased_server.go Normal file
View 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
View 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
}

View File

@ -175,5 +175,26 @@ func SetApiRouter(router *gin.Engine) {
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask) taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask) 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)
}
} }
} }

View File

@ -82,5 +82,11 @@
"singleQuote": true, "singleQuote": true,
"jsxSingleQuote": true "jsxSingleQuote": true
}, },
"proxy": "http://localhost:3000" "proxy": "http://localhost:3000",
"overrides": {
"@lobehub/icons": {
"react": "$react",
"react-dom": "$react-dom"
}
}
} }

View File

@ -28,6 +28,10 @@ import OAuth2Callback from './components/auth/OAuth2Callback.js';
import PersonalSetting from './components/settings/PersonalSetting.js'; import PersonalSetting from './components/settings/PersonalSetting.js';
import Setup from './pages/Setup/index.js'; import Setup from './pages/Setup/index.js';
import SetupCheck from './components/layout/SetupCheck.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 Home = lazy(() => import('./pages/Home'));
const Detail = lazy(() => import('./pages/Detail')); const Detail = lazy(() => import('./pages/Detail'));
@ -273,6 +277,38 @@ function App() {
</PrivateRoute> </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 <Route
path='/pricing' path='/pricing'
element={ element={

View File

@ -34,6 +34,10 @@ const routerMap = {
task: '/console/task', task: '/console/task',
playground: '/console/playground', playground: '/console/playground',
personal: '/console/personal', personal: '/console/personal',
server: '/console/server',
'purchased-server': '/console/purchased-server',
'server-shop': '/console/server-shop',
'my-servers': '/console/my-servers',
}; };
const SiderBar = ({ onNavigate = () => { } }) => { const SiderBar = ({ onNavigate = () => { } }) => {
@ -110,6 +114,16 @@ const SiderBar = ({ onNavigate = () => { } }) => {
itemKey: 'topup', itemKey: 'topup',
to: '/topup', to: '/topup',
}, },
{
text: t('服务器购买'),
itemKey: 'server-shop',
to: '/server-shop',
},
{
text: t('我的服务器'),
itemKey: 'my-servers',
to: '/my-servers',
},
{ {
text: t('个人设置'), text: t('个人设置'),
itemKey: 'personal', itemKey: 'personal',
@ -145,6 +159,18 @@ const SiderBar = ({ onNavigate = () => { } }) => {
to: '/invoice-management', to: '/invoice-management',
className: isAdmin() ? '' : 'tableHiddle', 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('系统设置'), text: t('系统设置'),
itemKey: 'setting', itemKey: 'setting',
@ -262,6 +288,10 @@ const SiderBar = ({ onNavigate = () => { } }) => {
case 'user': case 'user':
case 'personal': return sidebarIconColors.user; case 'personal': return sidebarIconColors.user;
case 'setting': return sidebarIconColors.settings; case 'setting': return sidebarIconColors.settings;
case 'server':
case 'purchased-server':
case 'server-shop':
case 'my-servers': return sidebarIconColors.server;
default: default:
// 处理聊天项 // 处理聊天项
if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message; if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message;

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

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

View File

@ -50,6 +50,7 @@ import {
User, User,
Settings, Settings,
CircleUser, CircleUser,
Server as ServerIcon,
} from 'lucide-react'; } from 'lucide-react';
// 侧边栏图标颜色映射 // 侧边栏图标颜色映射
@ -66,6 +67,7 @@ export const sidebarIconColors = {
gift: '#F43F5E', // 玫红色 gift: '#F43F5E', // 玫红色
user: '#10B981', // 绿色 user: '#10B981', // 绿色
settings: '#F97316', // 橙色 settings: '#F97316', // 橙色
server: '#8B5CF6', // 紫色
}; };
// 获取侧边栏Lucide图标组件 // 获取侧边栏Lucide图标组件
@ -165,6 +167,16 @@ export function getLucideIcon(key, selected = false) {
color={selected ? sidebarIconColors.settings : 'currentColor'} 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: default:
return ( return (
<CircleUser <CircleUser

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

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

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

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