1434 lines
97 KiB
Diff
1434 lines
97 KiB
Diff
diff --git a/controller/redemption.go b/controller/redemption.go
|
|
index 76c35bc3..a5a17670 100644
|
|
--- a/controller/redemption.go
|
|
+++ b/controller/redemption.go
|
|
@@ -3,6 +3,7 @@ package controller
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
+ "strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
@@ -19,10 +20,10 @@ func GetAllRedemptions(c *gin.Context) {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
+ enrichRedemptions(redemptions)
|
|
pageInfo.SetTotal(int(total))
|
|
pageInfo.SetItems(redemptions)
|
|
common.ApiSuccess(c, pageInfo)
|
|
- return
|
|
}
|
|
|
|
func SearchRedemptions(c *gin.Context) {
|
|
@@ -33,10 +34,10 @@ func SearchRedemptions(c *gin.Context) {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
+ enrichRedemptions(redemptions)
|
|
pageInfo.SetTotal(int(total))
|
|
pageInfo.SetItems(redemptions)
|
|
common.ApiSuccess(c, pageInfo)
|
|
- return
|
|
}
|
|
|
|
func GetRedemption(c *gin.Context) {
|
|
@@ -50,12 +51,12 @@ func GetRedemption(c *gin.Context) {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
+ enrichRedemption(redemption)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": redemption,
|
|
})
|
|
- return
|
|
}
|
|
|
|
func AddRedemption(c *gin.Context) {
|
|
@@ -65,8 +66,7 @@ func AddRedemption(c *gin.Context) {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
- if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
|
|
- common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
|
|
+ if !validateRedemptionPayload(c, &redemption, true) {
|
|
return
|
|
}
|
|
if redemption.Count <= 0 {
|
|
@@ -77,10 +77,6 @@ func AddRedemption(c *gin.Context) {
|
|
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
|
|
return
|
|
}
|
|
- if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
|
- c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
|
- return
|
|
- }
|
|
var keys []string
|
|
for i := 0; i < redemption.Count; i++ {
|
|
key := common.GetUUID()
|
|
@@ -90,6 +86,9 @@ func AddRedemption(c *gin.Context) {
|
|
Key: key,
|
|
CreatedTime: common.GetTimestamp(),
|
|
Quota: redemption.Quota,
|
|
+ RedeemType: redemption.RedeemType,
|
|
+ PlanId: redemption.PlanId,
|
|
+ SourceNote: redemption.SourceNote,
|
|
ExpiredTime: redemption.ExpiredTime,
|
|
}
|
|
err = cleanRedemption.Insert()
|
|
@@ -109,7 +108,6 @@ func AddRedemption(c *gin.Context) {
|
|
"message": "",
|
|
"data": keys,
|
|
})
|
|
- return
|
|
}
|
|
|
|
func DeleteRedemption(c *gin.Context) {
|
|
@@ -123,7 +121,6 @@ func DeleteRedemption(c *gin.Context) {
|
|
"success": true,
|
|
"message": "",
|
|
})
|
|
- return
|
|
}
|
|
|
|
func UpdateRedemption(c *gin.Context) {
|
|
@@ -140,13 +137,14 @@ func UpdateRedemption(c *gin.Context) {
|
|
return
|
|
}
|
|
if statusOnly == "" {
|
|
- if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
|
- c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
|
+ if !validateRedemptionPayload(c, &redemption, true) {
|
|
return
|
|
}
|
|
- // If you add more fields, please also update redemption.Update()
|
|
cleanRedemption.Name = redemption.Name
|
|
cleanRedemption.Quota = redemption.Quota
|
|
+ cleanRedemption.RedeemType = redemption.RedeemType
|
|
+ cleanRedemption.PlanId = redemption.PlanId
|
|
+ cleanRedemption.SourceNote = redemption.SourceNote
|
|
cleanRedemption.ExpiredTime = redemption.ExpiredTime
|
|
}
|
|
if statusOnly != "" {
|
|
@@ -157,12 +155,12 @@ func UpdateRedemption(c *gin.Context) {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
+ enrichRedemption(cleanRedemption)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": cleanRedemption,
|
|
})
|
|
- return
|
|
}
|
|
|
|
func DeleteInvalidRedemption(c *gin.Context) {
|
|
@@ -176,7 +174,6 @@ func DeleteInvalidRedemption(c *gin.Context) {
|
|
"message": "",
|
|
"data": rows,
|
|
})
|
|
- return
|
|
}
|
|
|
|
func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
|
|
@@ -185,3 +182,78 @@ func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
|
|
}
|
|
return true, ""
|
|
}
|
|
+
|
|
+func validateRedemptionPayload(c *gin.Context, redemption *model.Redemption, requireName bool) bool {
|
|
+ if redemption == nil {
|
|
+ common.ApiErrorMsg(c, "invalid redemption payload")
|
|
+ return false
|
|
+ }
|
|
+ redemption.Name = strings.TrimSpace(redemption.Name)
|
|
+ redemption.SourceNote = strings.TrimSpace(redemption.SourceNote)
|
|
+ redemption.RedeemType = model.NormalizeRedemptionType(redemption.RedeemType)
|
|
+ if redemption.RedeemType == "" {
|
|
+ common.ApiErrorMsg(c, "invalid redeem type")
|
|
+ return false
|
|
+ }
|
|
+ if requireName && (utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20) {
|
|
+ common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
|
|
+ return false
|
|
+ }
|
|
+ if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
|
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
|
+ return false
|
|
+ }
|
|
+ switch redemption.RedeemType {
|
|
+ case model.RedemptionTypeQuota:
|
|
+ if redemption.Quota <= 0 {
|
|
+ common.ApiErrorMsg(c, "quota must be greater than 0")
|
|
+ return false
|
|
+ }
|
|
+ redemption.PlanId = 0
|
|
+ case model.RedemptionTypeSubscription:
|
|
+ if redemption.PlanId <= 0 {
|
|
+ common.ApiErrorMsg(c, "plan_id is required")
|
|
+ return false
|
|
+ }
|
|
+ plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
|
|
+ if err != nil || plan == nil {
|
|
+ common.ApiErrorMsg(c, "subscription plan not found")
|
|
+ return false
|
|
+ }
|
|
+ redemption.Quota = 0
|
|
+ }
|
|
+ return true
|
|
+}
|
|
+
|
|
+func enrichRedemptions(redemptions []*model.Redemption) {
|
|
+ if len(redemptions) == 0 {
|
|
+ return
|
|
+ }
|
|
+ planTitles := make(map[int]string)
|
|
+ for _, redemption := range redemptions {
|
|
+ if redemption == nil || model.NormalizeRedemptionType(redemption.RedeemType) != model.RedemptionTypeSubscription || redemption.PlanId <= 0 {
|
|
+ continue
|
|
+ }
|
|
+ if _, ok := planTitles[redemption.PlanId]; ok {
|
|
+ redemption.PlanTitle = planTitles[redemption.PlanId]
|
|
+ continue
|
|
+ }
|
|
+ plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
|
|
+ if err != nil || plan == nil {
|
|
+ continue
|
|
+ }
|
|
+ planTitles[redemption.PlanId] = plan.Title
|
|
+ redemption.PlanTitle = plan.Title
|
|
+ }
|
|
+}
|
|
+
|
|
+func enrichRedemption(redemption *model.Redemption) {
|
|
+ if redemption == nil || model.NormalizeRedemptionType(redemption.RedeemType) != model.RedemptionTypeSubscription || redemption.PlanId <= 0 {
|
|
+ return
|
|
+ }
|
|
+ plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
|
|
+ if err != nil || plan == nil {
|
|
+ return
|
|
+ }
|
|
+ redemption.PlanTitle = plan.Title
|
|
+}
|
|
diff --git a/controller/user.go b/controller/user.go
|
|
index 8229d0d2..e2a37f3c 100644
|
|
--- a/controller/user.go
|
|
+++ b/controller/user.go
|
|
@@ -1025,7 +1025,7 @@ func TopUp(c *gin.Context) {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
- quota, err := model.Redeem(req.Key, id)
|
|
+ result, err := model.Redeem(req.Key, id)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrRedeemFailed) {
|
|
common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
|
|
@@ -1037,7 +1037,7 @@ func TopUp(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
- "data": quota,
|
|
+ "data": result,
|
|
})
|
|
}
|
|
|
|
diff --git a/model/main.go b/model/main.go
|
|
index f37cb667..4a4bc6f7 100644
|
|
--- a/model/main.go
|
|
+++ b/model/main.go
|
|
@@ -284,6 +284,9 @@ func migrateDB() error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
+ if err := migrateRedemptionRedeemType(); err != nil {
|
|
+ return err
|
|
+ }
|
|
if common.UsingSQLite {
|
|
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
|
|
return err
|
|
@@ -352,6 +355,9 @@ func migrateDBFast() error {
|
|
return err
|
|
}
|
|
}
|
|
+ if err := migrateRedemptionRedeemType(); err != nil {
|
|
+ return err
|
|
+ }
|
|
if common.UsingSQLite {
|
|
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
|
|
return err
|
|
@@ -365,6 +371,18 @@ func migrateDBFast() error {
|
|
return nil
|
|
}
|
|
|
|
+func migrateRedemptionRedeemType() error {
|
|
+ if !DB.Migrator().HasTable(&Redemption{}) {
|
|
+ return nil
|
|
+ }
|
|
+ if !DB.Migrator().HasColumn(&Redemption{}, "redeem_type") {
|
|
+ return nil
|
|
+ }
|
|
+ return DB.Model(&Redemption{}).
|
|
+ Where("redeem_type = '' OR redeem_type IS NULL").
|
|
+ Update("redeem_type", RedemptionTypeQuota).Error
|
|
+}
|
|
+
|
|
func migrateLOGDB() error {
|
|
var err error
|
|
if err = LOG_DB.AutoMigrate(&Log{}); err != nil {
|
|
diff --git a/model/redemption.go b/model/redemption.go
|
|
index 378976a3..495b7cda 100644
|
|
--- a/model/redemption.go
|
|
+++ b/model/redemption.go
|
|
@@ -4,6 +4,7 @@ import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
+ "strings"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
@@ -14,6 +15,19 @@ import (
|
|
// ErrRedeemFailed is returned when redemption fails due to database error
|
|
var ErrRedeemFailed = errors.New("redeem.failed")
|
|
|
|
+const (
|
|
+ RedemptionTypeQuota = "quota"
|
|
+ RedemptionTypeSubscription = "subscription"
|
|
+)
|
|
+
|
|
+type RedeemResult struct {
|
|
+ RedeemType string `json:"redeem_type"`
|
|
+ Quota int `json:"quota,omitempty"`
|
|
+ PlanId int `json:"plan_id,omitempty"`
|
|
+ PlanTitle string `json:"plan_title,omitempty"`
|
|
+ SubscriptionId int `json:"subscription_id,omitempty"`
|
|
+}
|
|
+
|
|
type Redemption struct {
|
|
Id int `json:"id"`
|
|
UserId int `json:"user_id"`
|
|
@@ -21,16 +35,30 @@ type Redemption struct {
|
|
Status int `json:"status" gorm:"default:1"`
|
|
Name string `json:"name" gorm:"index"`
|
|
Quota int `json:"quota" gorm:"default:100"`
|
|
+ RedeemType string `json:"redeem_type" gorm:"type:varchar(32);not null;default:'quota'"`
|
|
+ PlanId int `json:"plan_id" gorm:"default:0;index"`
|
|
+ SourceNote string `json:"source_note" gorm:"type:varchar(255);default:''"`
|
|
+ PlanTitle string `json:"plan_title" gorm:"-:all"`
|
|
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
|
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
|
|
Count int `json:"count" gorm:"-:all"` // only for api request
|
|
UsedUserId int `json:"used_user_id"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
|
- ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期
|
|
+ ExpiredTime int64 `json:"expired_time" gorm:"bigint"`
|
|
+}
|
|
+
|
|
+func NormalizeRedemptionType(raw string) string {
|
|
+ switch strings.TrimSpace(strings.ToLower(raw)) {
|
|
+ case "", RedemptionTypeQuota:
|
|
+ return RedemptionTypeQuota
|
|
+ case RedemptionTypeSubscription:
|
|
+ return RedemptionTypeSubscription
|
|
+ default:
|
|
+ return ""
|
|
+ }
|
|
}
|
|
|
|
func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
|
|
- // 开始事务
|
|
tx := DB.Begin()
|
|
if tx.Error != nil {
|
|
return nil, 0, tx.Error
|
|
@@ -41,21 +69,18 @@ func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total
|
|
}
|
|
}()
|
|
|
|
- // 获取总数
|
|
err = tx.Model(&Redemption{}).Count(&total).Error
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, 0, err
|
|
}
|
|
|
|
- // 获取分页数据
|
|
err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, 0, err
|
|
}
|
|
|
|
- // 提交事务
|
|
if err = tx.Commit().Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
@@ -74,24 +99,20 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re
|
|
}
|
|
}()
|
|
|
|
- // Build query based on keyword type
|
|
query := tx.Model(&Redemption{})
|
|
|
|
- // Only try to convert to ID if the string represents a valid integer
|
|
if id, err := strconv.Atoi(keyword); err == nil {
|
|
query = query.Where("id = ? OR name LIKE ?", id, keyword+"%")
|
|
} else {
|
|
query = query.Where("name LIKE ?", keyword+"%")
|
|
}
|
|
|
|
- // Get total count
|
|
err = query.Count(&total).Error
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, 0, err
|
|
}
|
|
|
|
- // Get paginated data
|
|
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
|
|
if err != nil {
|
|
tx.Rollback()
|
|
@@ -107,22 +128,25 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re
|
|
|
|
func GetRedemptionById(id int) (*Redemption, error) {
|
|
if id == 0 {
|
|
- return nil, errors.New("id 为空!")
|
|
+ return nil, errors.New("id is empty")
|
|
}
|
|
redemption := Redemption{Id: id}
|
|
- var err error = nil
|
|
- err = DB.First(&redemption, "id = ?", id).Error
|
|
+ err := DB.First(&redemption, "id = ?", id).Error
|
|
return &redemption, err
|
|
}
|
|
|
|
-func Redeem(key string, userId int) (quota int, err error) {
|
|
+func Redeem(key string, userId int) (result *RedeemResult, err error) {
|
|
if key == "" {
|
|
- return 0, errors.New("未提供兑换码")
|
|
+ return nil, errors.New("redemption code is required")
|
|
}
|
|
if userId == 0 {
|
|
- return 0, errors.New("无效的 user id")
|
|
+ return nil, errors.New("invalid user id")
|
|
}
|
|
+
|
|
redemption := &Redemption{}
|
|
+ result = &RedeemResult{}
|
|
+ logMessage := ""
|
|
+ upgradeGroup := ""
|
|
|
|
keyCol := "`key`"
|
|
if common.UsingPostgreSQL {
|
|
@@ -132,18 +156,50 @@ func Redeem(key string, userId int) (quota int, err error) {
|
|
err = DB.Transaction(func(tx *gorm.DB) error {
|
|
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error
|
|
if err != nil {
|
|
- return errors.New("无效的兑换码")
|
|
+ return errors.New("invalid redemption code")
|
|
}
|
|
if redemption.Status != common.RedemptionCodeStatusEnabled {
|
|
- return errors.New("该兑换码已被使用")
|
|
+ return errors.New("redemption code is unavailable")
|
|
}
|
|
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
|
|
- return errors.New("该兑换码已过期")
|
|
+ return errors.New("redemption code is expired")
|
|
}
|
|
- err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
|
|
- if err != nil {
|
|
- return err
|
|
+
|
|
+ redeemType := NormalizeRedemptionType(redemption.RedeemType)
|
|
+ if redeemType == "" {
|
|
+ return errors.New("invalid redemption type")
|
|
+ }
|
|
+
|
|
+ switch redeemType {
|
|
+ case RedemptionTypeQuota:
|
|
+ err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
|
|
+ if err != nil {
|
|
+ return err
|
|
+ }
|
|
+ result.RedeemType = RedemptionTypeQuota
|
|
+ result.Quota = redemption.Quota
|
|
+ logMessage = fmt.Sprintf("Redeemed quota %s via redemption code ID %d", logger.LogQuota(redemption.Quota), redemption.Id)
|
|
+ case RedemptionTypeSubscription:
|
|
+ if redemption.PlanId <= 0 {
|
|
+ return errors.New("invalid subscription plan id")
|
|
+ }
|
|
+ plan, err := getSubscriptionPlanByIdTx(tx, redemption.PlanId)
|
|
+ if err != nil {
|
|
+ return err
|
|
+ }
|
|
+ sub, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "redeem")
|
|
+ if err != nil {
|
|
+ return err
|
|
+ }
|
|
+ result.RedeemType = RedemptionTypeSubscription
|
|
+ result.PlanId = plan.Id
|
|
+ result.PlanTitle = plan.Title
|
|
+ result.SubscriptionId = sub.Id
|
|
+ upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
|
|
+ logMessage = fmt.Sprintf("Redeemed subscription plan %s via redemption code ID %d", plan.Title, redemption.Id)
|
|
}
|
|
+
|
|
+ redemption.RedeemType = redeemType
|
|
redemption.RedeemedTime = common.GetTimestamp()
|
|
redemption.Status = common.RedemptionCodeStatusUsed
|
|
redemption.UsedUserId = userId
|
|
@@ -152,39 +208,42 @@ func Redeem(key string, userId int) (quota int, err error) {
|
|
})
|
|
if err != nil {
|
|
common.SysError("redemption failed: " + err.Error())
|
|
- return 0, ErrRedeemFailed
|
|
+ return nil, ErrRedeemFailed
|
|
}
|
|
- RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
|
|
- return redemption.Quota, nil
|
|
+
|
|
+ if upgradeGroup != "" {
|
|
+ _ = UpdateUserGroupCache(userId, upgradeGroup)
|
|
+ }
|
|
+ if logMessage != "" {
|
|
+ RecordLog(userId, LogTypeTopup, logMessage)
|
|
+ }
|
|
+ return result, nil
|
|
}
|
|
|
|
func (redemption *Redemption) Insert() error {
|
|
- var err error
|
|
- err = DB.Create(redemption).Error
|
|
- return err
|
|
+ redemption.RedeemType = NormalizeRedemptionType(redemption.RedeemType)
|
|
+ if redemption.RedeemType == "" {
|
|
+ redemption.RedeemType = RedemptionTypeQuota
|
|
+ }
|
|
+ return DB.Create(redemption).Error
|
|
}
|
|
|
|
func (redemption *Redemption) SelectUpdate() error {
|
|
- // This can update zero values
|
|
return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error
|
|
}
|
|
|
|
// Update Make sure your token's fields is completed, because this will update non-zero values
|
|
func (redemption *Redemption) Update() error {
|
|
- var err error
|
|
- err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
|
|
- return err
|
|
+ return DB.Model(redemption).Select("name", "status", "quota", "redeem_type", "plan_id", "source_note", "redeemed_time", "expired_time").Updates(redemption).Error
|
|
}
|
|
|
|
func (redemption *Redemption) Delete() error {
|
|
- var err error
|
|
- err = DB.Delete(redemption).Error
|
|
- return err
|
|
+ return DB.Delete(redemption).Error
|
|
}
|
|
|
|
func DeleteRedemptionById(id int) (err error) {
|
|
if id == 0 {
|
|
- return errors.New("id 为空!")
|
|
+ return errors.New("id is empty")
|
|
}
|
|
redemption := Redemption{Id: id}
|
|
err = DB.Where(redemption).First(&redemption).Error
|
|
diff --git a/web/src/components/table/redemptions/RedemptionsColumnDefs.jsx b/web/src/components/table/redemptions/RedemptionsColumnDefs.jsx
|
|
index efe1114c..6dcb32fb 100644
|
|
--- a/web/src/components/table/redemptions/RedemptionsColumnDefs.jsx
|
|
+++ b/web/src/components/table/redemptions/RedemptionsColumnDefs.jsx
|
|
@@ -25,11 +25,10 @@ import {
|
|
REDEMPTION_STATUS,
|
|
REDEMPTION_STATUS_MAP,
|
|
REDEMPTION_ACTIONS,
|
|
+ REDEMPTION_REDEEM_TYPE,
|
|
+ REDEMPTION_REDEEM_TYPE_MAP,
|
|
} from '../../../constants/redemption.constants';
|
|
|
|
-/**
|
|
- * Check if redemption code is expired
|
|
- */
|
|
export const isExpired = (record) => {
|
|
return (
|
|
record.status === REDEMPTION_STATUS.UNUSED &&
|
|
@@ -38,21 +37,15 @@ export const isExpired = (record) => {
|
|
);
|
|
};
|
|
|
|
-/**
|
|
- * Render timestamp
|
|
- */
|
|
const renderTimestamp = (timestamp) => {
|
|
return <>{timestamp2string(timestamp)}</>;
|
|
};
|
|
|
|
-/**
|
|
- * Render redemption code status
|
|
- */
|
|
const renderStatus = (status, record, t) => {
|
|
if (isExpired(record)) {
|
|
return (
|
|
<Tag color='orange' shape='circle'>
|
|
- {t('已过期')}
|
|
+ {t('Expired')}
|
|
</Tag>
|
|
);
|
|
}
|
|
@@ -68,23 +61,50 @@ const renderStatus = (status, record, t) => {
|
|
|
|
return (
|
|
<Tag color='black' shape='circle'>
|
|
- {t('未知状态')}
|
|
+ {t('Unknown')}
|
|
+ </Tag>
|
|
+ );
|
|
+};
|
|
+
|
|
+const renderRedeemType = (redeemType, t) => {
|
|
+ const normalizedType =
|
|
+ redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
|
|
+ ? REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
|
|
+ : REDEMPTION_REDEEM_TYPE.QUOTA;
|
|
+ const typeConfig = REDEMPTION_REDEEM_TYPE_MAP[normalizedType];
|
|
+ return (
|
|
+ <Tag color={typeConfig.color} shape='circle'>
|
|
+ {t(typeConfig.text)}
|
|
+ </Tag>
|
|
+ );
|
|
+};
|
|
+
|
|
+const renderRedeemTarget = (record, t) => {
|
|
+ const redeemType =
|
|
+ record.redeem_type === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
|
|
+ ? REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
|
|
+ : REDEMPTION_REDEEM_TYPE.QUOTA;
|
|
+ if (redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION) {
|
|
+ const label = record.plan_title || `ID ${record.plan_id || '-'}`;
|
|
+ return (
|
|
+ <Tag color='light-green' shape='circle'>
|
|
+ {`${t('Plan')}: ${label}`}
|
|
+ </Tag>
|
|
+ );
|
|
+ }
|
|
+ return (
|
|
+ <Tag color='grey' shape='circle'>
|
|
+ {renderQuota(parseInt(record.quota))}
|
|
</Tag>
|
|
);
|
|
};
|
|
|
|
-/**
|
|
- * Get redemption code table column definitions
|
|
- */
|
|
export const getRedemptionsColumns = ({
|
|
t,
|
|
manageRedemption,
|
|
copyText,
|
|
setEditingRedemption,
|
|
setShowEdit,
|
|
- refresh,
|
|
- redemptions,
|
|
- activePage,
|
|
showDeleteRedemptionModal,
|
|
}) => {
|
|
return [
|
|
@@ -93,11 +113,16 @@ export const getRedemptionsColumns = ({
|
|
dataIndex: 'id',
|
|
},
|
|
{
|
|
- title: t('名称'),
|
|
+ title: t('Name'),
|
|
dataIndex: 'name',
|
|
},
|
|
{
|
|
- title: t('状态'),
|
|
+ title: t('Type'),
|
|
+ dataIndex: 'redeem_type',
|
|
+ render: (text) => <div>{renderRedeemType(text, t)}</div>,
|
|
+ },
|
|
+ {
|
|
+ title: t('Status'),
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
render: (text, record) => {
|
|
@@ -105,37 +130,31 @@ export const getRedemptionsColumns = ({
|
|
},
|
|
},
|
|
{
|
|
- title: t('额度'),
|
|
+ title: t('Redeem target'),
|
|
dataIndex: 'quota',
|
|
- render: (text) => {
|
|
- return (
|
|
- <div>
|
|
- <Tag color='grey' shape='circle'>
|
|
- {renderQuota(parseInt(text))}
|
|
- </Tag>
|
|
- </div>
|
|
- );
|
|
+ render: (_, record) => {
|
|
+ return <div>{renderRedeemTarget(record, t)}</div>;
|
|
},
|
|
},
|
|
{
|
|
- title: t('创建时间'),
|
|
+ title: t('Created at'),
|
|
dataIndex: 'created_time',
|
|
render: (text) => {
|
|
return <div>{renderTimestamp(text)}</div>;
|
|
},
|
|
},
|
|
{
|
|
- title: t('过期时间'),
|
|
+ title: t('Expires at'),
|
|
dataIndex: 'expired_time',
|
|
render: (text) => {
|
|
- return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>;
|
|
+ return <div>{text === 0 ? t('Never') : renderTimestamp(text)}</div>;
|
|
},
|
|
},
|
|
{
|
|
- title: t('兑换人ID'),
|
|
+ title: t('Redeemed by'),
|
|
dataIndex: 'used_user_id',
|
|
render: (text) => {
|
|
- return <div>{text === 0 ? t('无') : text}</div>;
|
|
+ return <div>{text === 0 ? t('N/A') : text}</div>;
|
|
},
|
|
},
|
|
{
|
|
@@ -144,11 +163,10 @@ export const getRedemptionsColumns = ({
|
|
fixed: 'right',
|
|
width: 205,
|
|
render: (text, record) => {
|
|
- // Create dropdown menu items for more operations
|
|
const moreMenuItems = [
|
|
{
|
|
node: 'item',
|
|
- name: t('删除'),
|
|
+ name: t('Delete'),
|
|
type: 'danger',
|
|
onClick: () => {
|
|
showDeleteRedemptionModal(record);
|
|
@@ -159,7 +177,7 @@ export const getRedemptionsColumns = ({
|
|
if (record.status === REDEMPTION_STATUS.UNUSED && !isExpired(record)) {
|
|
moreMenuItems.push({
|
|
node: 'item',
|
|
- name: t('禁用'),
|
|
+ name: t('Disable'),
|
|
type: 'warning',
|
|
onClick: () => {
|
|
manageRedemption(record.id, REDEMPTION_ACTIONS.DISABLE, record);
|
|
@@ -168,7 +186,7 @@ export const getRedemptionsColumns = ({
|
|
} else if (!isExpired(record)) {
|
|
moreMenuItems.push({
|
|
node: 'item',
|
|
- name: t('启用'),
|
|
+ name: t('Enable'),
|
|
type: 'secondary',
|
|
onClick: () => {
|
|
manageRedemption(record.id, REDEMPTION_ACTIONS.ENABLE, record);
|
|
@@ -185,7 +203,7 @@ export const getRedemptionsColumns = ({
|
|
position='top'
|
|
>
|
|
<Button type='tertiary' size='small'>
|
|
- {t('查看')}
|
|
+ {t('View')}
|
|
</Button>
|
|
</Popover>
|
|
<Button
|
|
@@ -194,7 +212,7 @@ export const getRedemptionsColumns = ({
|
|
await copyText(record.key);
|
|
}}
|
|
>
|
|
- {t('复制')}
|
|
+ {t('Copy')}
|
|
</Button>
|
|
<Button
|
|
type='tertiary'
|
|
@@ -205,7 +223,7 @@ export const getRedemptionsColumns = ({
|
|
}}
|
|
disabled={record.status !== REDEMPTION_STATUS.UNUSED}
|
|
>
|
|
- {t('编辑')}
|
|
+ {t('Edit')}
|
|
</Button>
|
|
<Dropdown
|
|
trigger='click'
|
|
diff --git a/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx
|
|
index bcde7260..94f5c8d4 100644
|
|
--- a/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx
|
|
+++ b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx
|
|
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
|
|
-import React, { useEffect, useState, useRef } from 'react';
|
|
+import React, { useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
API,
|
|
@@ -41,6 +41,7 @@ import {
|
|
Avatar,
|
|
Row,
|
|
Col,
|
|
+ Select,
|
|
} from '@douyinfe/semi-ui';
|
|
import {
|
|
IconCreditCard,
|
|
@@ -48,6 +49,7 @@ import {
|
|
IconClose,
|
|
IconGift,
|
|
} from '@douyinfe/semi-icons';
|
|
+import { REDEMPTION_REDEEM_TYPE } from '../../../../constants/redemption.constants';
|
|
|
|
const { Text, Title } = Typography;
|
|
|
|
@@ -55,6 +57,8 @@ const EditRedemptionModal = (props) => {
|
|
const { t } = useTranslation();
|
|
const isEdit = props.editingRedemption.id !== undefined;
|
|
const [loading, setLoading] = useState(isEdit);
|
|
+ const [plansLoading, setPlansLoading] = useState(false);
|
|
+ const [subscriptionPlans, setSubscriptionPlans] = useState([]);
|
|
const isMobile = useIsMobile();
|
|
const formApiRef = useRef(null);
|
|
|
|
@@ -63,48 +67,92 @@ const EditRedemptionModal = (props) => {
|
|
quota: 100000,
|
|
count: 1,
|
|
expired_time: null,
|
|
+ redeem_type: REDEMPTION_REDEEM_TYPE.QUOTA,
|
|
+ plan_id: undefined,
|
|
});
|
|
|
|
const handleCancel = () => {
|
|
props.handleClose();
|
|
};
|
|
|
|
+ const loadSubscriptionPlans = async () => {
|
|
+ setPlansLoading(true);
|
|
+ try {
|
|
+ const res = await API.get('/api/subscription/admin/plans');
|
|
+ if (res.data?.success) {
|
|
+ const plans = (res.data?.data || [])
|
|
+ .map((item) => item?.plan)
|
|
+ .filter(Boolean);
|
|
+ setSubscriptionPlans(plans);
|
|
+ } else {
|
|
+ showError(res.data?.message || t('Failed to load subscription plans'));
|
|
+ }
|
|
+ } catch (error) {
|
|
+ showError(error.message);
|
|
+ }
|
|
+ setPlansLoading(false);
|
|
+ };
|
|
+
|
|
const loadRedemption = async () => {
|
|
setLoading(true);
|
|
- let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
|
|
+ const res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
|
|
const { success, message, data } = res.data;
|
|
if (success) {
|
|
- if (data.expired_time === 0) {
|
|
- data.expired_time = null;
|
|
+ const nextValues = {
|
|
+ ...getInitValues(),
|
|
+ ...data,
|
|
+ };
|
|
+ if (nextValues.expired_time === 0) {
|
|
+ nextValues.expired_time = null;
|
|
} else {
|
|
- data.expired_time = new Date(data.expired_time * 1000);
|
|
+ nextValues.expired_time = new Date(nextValues.expired_time * 1000);
|
|
}
|
|
- formApiRef.current?.setValues({ ...getInitValues(), ...data });
|
|
+ if (!nextValues.plan_id) {
|
|
+ nextValues.plan_id = undefined;
|
|
+ }
|
|
+ nextValues.redeem_type =
|
|
+ nextValues.redeem_type || REDEMPTION_REDEEM_TYPE.QUOTA;
|
|
+ formApiRef.current?.setValues(nextValues);
|
|
} else {
|
|
showError(message);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
+ useEffect(() => {
|
|
+ if (props.visiable) {
|
|
+ loadSubscriptionPlans();
|
|
+ }
|
|
+ }, [props.visiable]);
|
|
+
|
|
useEffect(() => {
|
|
if (formApiRef.current) {
|
|
if (isEdit) {
|
|
loadRedemption();
|
|
} else {
|
|
formApiRef.current.setValues(getInitValues());
|
|
+ setLoading(false);
|
|
}
|
|
}
|
|
}, [props.editingRedemption.id]);
|
|
|
|
const submit = async (values) => {
|
|
let name = values.name;
|
|
- if (!isEdit && (!name || name === '')) {
|
|
- name = renderQuota(values.quota);
|
|
+ if (!name || name === '') {
|
|
+ if (values.redeem_type === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION) {
|
|
+ const selectedPlan = subscriptionPlans.find(
|
|
+ (plan) => plan.id === Number(values.plan_id),
|
|
+ );
|
|
+ name = selectedPlan?.title || t('Subscription redemption');
|
|
+ } else {
|
|
+ name = renderQuota(values.quota);
|
|
+ }
|
|
}
|
|
setLoading(true);
|
|
- let localInputs = { ...values };
|
|
- localInputs.count = parseInt(localInputs.count) || 0;
|
|
- localInputs.quota = parseInt(localInputs.quota) || 0;
|
|
+ const localInputs = { ...values };
|
|
+ localInputs.count = parseInt(localInputs.count, 10) || 0;
|
|
+ localInputs.quota = parseInt(localInputs.quota, 10) || 0;
|
|
+ localInputs.plan_id = parseInt(localInputs.plan_id, 10) || 0;
|
|
localInputs.name = name;
|
|
if (!localInputs.expired_time) {
|
|
localInputs.expired_time = 0;
|
|
@@ -113,11 +161,16 @@ const EditRedemptionModal = (props) => {
|
|
localInputs.expired_time.getTime() / 1000,
|
|
);
|
|
}
|
|
+ if (localInputs.redeem_type === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION) {
|
|
+ localInputs.quota = 0;
|
|
+ } else {
|
|
+ localInputs.plan_id = 0;
|
|
+ }
|
|
let res;
|
|
if (isEdit) {
|
|
res = await API.put(`/api/redemption/`, {
|
|
...localInputs,
|
|
- id: parseInt(props.editingRedemption.id),
|
|
+ id: parseInt(props.editingRedemption.id, 10),
|
|
});
|
|
} else {
|
|
res = await API.post(`/api/redemption/`, {
|
|
@@ -127,11 +180,11 @@ const EditRedemptionModal = (props) => {
|
|
const { success, message, data } = res.data;
|
|
if (success) {
|
|
if (isEdit) {
|
|
- showSuccess(t('兑换码更新成功!'));
|
|
+ showSuccess(t('Redemption updated'));
|
|
props.refresh();
|
|
props.handleClose();
|
|
} else {
|
|
- showSuccess(t('兑换码创建成功!'));
|
|
+ showSuccess(t('Redemption created'));
|
|
props.refresh();
|
|
formApiRef.current?.setValues(getInitValues());
|
|
props.handleClose();
|
|
@@ -145,11 +198,11 @@ const EditRedemptionModal = (props) => {
|
|
text += data[i] + '\n';
|
|
}
|
|
Modal.confirm({
|
|
- title: t('兑换码创建成功'),
|
|
+ title: t('Redemption created'),
|
|
content: (
|
|
<div>
|
|
- <p>{t('兑换码创建成功,是否下载兑换码?')}</p>
|
|
- <p>{t('兑换码将以文本文件的形式下载,文件名为兑换码的名称。')}</p>
|
|
+ <p>{t('Download the generated redemption codes?')}</p>
|
|
+ <p>{t('Codes will be downloaded as a text file named after this redemption.')}</p>
|
|
</div>
|
|
),
|
|
onOk: () => {
|
|
@@ -168,15 +221,15 @@ const EditRedemptionModal = (props) => {
|
|
<Space>
|
|
{isEdit ? (
|
|
<Tag color='blue' shape='circle'>
|
|
- {t('更新')}
|
|
+ {t('Edit')}
|
|
</Tag>
|
|
) : (
|
|
<Tag color='green' shape='circle'>
|
|
- {t('新建')}
|
|
+ {t('New')}
|
|
</Tag>
|
|
)}
|
|
<Title heading={4} className='m-0'>
|
|
- {isEdit ? t('更新兑换码信息') : t('创建新的兑换码')}
|
|
+ {isEdit ? t('Update redemption') : t('Create redemption')}
|
|
</Title>
|
|
</Space>
|
|
}
|
|
@@ -192,7 +245,7 @@ const EditRedemptionModal = (props) => {
|
|
icon={<IconSave />}
|
|
loading={loading}
|
|
>
|
|
- {t('提交')}
|
|
+ {t('Submit')}
|
|
</Button>
|
|
<Button
|
|
theme='light'
|
|
@@ -200,7 +253,7 @@ const EditRedemptionModal = (props) => {
|
|
onClick={handleCancel}
|
|
icon={<IconClose />}
|
|
>
|
|
- {t('取消')}
|
|
+ {t('Cancel')}
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
@@ -214,135 +267,188 @@ const EditRedemptionModal = (props) => {
|
|
getFormApi={(api) => (formApiRef.current = api)}
|
|
onSubmit={submit}
|
|
>
|
|
- {({ values }) => (
|
|
- <div className='p-2'>
|
|
- <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
|
- {/* Header: Basic Info */}
|
|
- <div className='flex items-center mb-2'>
|
|
- <Avatar
|
|
- size='small'
|
|
- color='blue'
|
|
- className='mr-2 shadow-md'
|
|
- >
|
|
- <IconGift size={16} />
|
|
- </Avatar>
|
|
- <div>
|
|
- <Text className='text-lg font-medium'>
|
|
- {t('基本信息')}
|
|
- </Text>
|
|
- <div className='text-xs text-gray-600'>
|
|
- {t('设置兑换码的基本信息')}
|
|
- </div>
|
|
- </div>
|
|
- </div>
|
|
-
|
|
- <Row gutter={12}>
|
|
- <Col span={24}>
|
|
- <Form.Input
|
|
- field='name'
|
|
- label={t('名称')}
|
|
- placeholder={t('请输入名称')}
|
|
- style={{ width: '100%' }}
|
|
- rules={
|
|
- !isEdit
|
|
- ? []
|
|
- : [{ required: true, message: t('请输入名称') }]
|
|
- }
|
|
- showClear
|
|
- />
|
|
- </Col>
|
|
- <Col span={24}>
|
|
- <Form.DatePicker
|
|
- field='expired_time'
|
|
- label={t('过期时间')}
|
|
- type='dateTime'
|
|
- placeholder={t('选择过期时间(可选,留空为永久)')}
|
|
- style={{ width: '100%' }}
|
|
- showClear
|
|
- />
|
|
- </Col>
|
|
- </Row>
|
|
- </Card>
|
|
-
|
|
- <Card className='!rounded-2xl shadow-sm border-0'>
|
|
- {/* Header: Quota Settings */}
|
|
- <div className='flex items-center mb-2'>
|
|
- <Avatar
|
|
- size='small'
|
|
- color='green'
|
|
- className='mr-2 shadow-md'
|
|
- >
|
|
- <IconCreditCard size={16} />
|
|
- </Avatar>
|
|
- <div>
|
|
- <Text className='text-lg font-medium'>
|
|
- {t('额度设置')}
|
|
- </Text>
|
|
- <div className='text-xs text-gray-600'>
|
|
- {t('设置兑换码的额度和数量')}
|
|
+ {({ values }) => {
|
|
+ const redeemType =
|
|
+ values.redeem_type || REDEMPTION_REDEEM_TYPE.QUOTA;
|
|
+ return (
|
|
+ <div className='p-2'>
|
|
+ <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
|
+ <div className='flex items-center mb-2'>
|
|
+ <Avatar
|
|
+ size='small'
|
|
+ color='blue'
|
|
+ className='mr-2 shadow-md'
|
|
+ >
|
|
+ <IconGift size={16} />
|
|
+ </Avatar>
|
|
+ <div>
|
|
+ <Text className='text-lg font-medium'>
|
|
+ {t('Basic info')}
|
|
+ </Text>
|
|
+ <div className='text-xs text-gray-600'>
|
|
+ {t('Set the redemption name, type, and expiration')}
|
|
+ </div>
|
|
</div>
|
|
</div>
|
|
- </div>
|
|
|
|
- <Row gutter={12}>
|
|
- <Col span={12}>
|
|
- <Form.AutoComplete
|
|
- field='quota'
|
|
- label={t('额度')}
|
|
- placeholder={t('请输入额度')}
|
|
- style={{ width: '100%' }}
|
|
- type='number'
|
|
- rules={[
|
|
- { required: true, message: t('请输入额度') },
|
|
- {
|
|
- validator: (rule, v) => {
|
|
- const num = parseInt(v, 10);
|
|
- return num > 0
|
|
- ? Promise.resolve()
|
|
- : Promise.reject(t('额度必须大于0'));
|
|
- },
|
|
- },
|
|
- ]}
|
|
- extraText={renderQuotaWithPrompt(
|
|
- Number(values.quota) || 0,
|
|
- )}
|
|
- data={[
|
|
- { value: 500000, label: '1$' },
|
|
- { value: 5000000, label: '10$' },
|
|
- { value: 25000000, label: '50$' },
|
|
- { value: 50000000, label: '100$' },
|
|
- { value: 250000000, label: '500$' },
|
|
- { value: 500000000, label: '1000$' },
|
|
- ]}
|
|
- showClear
|
|
- />
|
|
- </Col>
|
|
- {!isEdit && (
|
|
+ <Row gutter={12}>
|
|
+ <Col span={24}>
|
|
+ <Form.Input
|
|
+ field='name'
|
|
+ label={t('Name')}
|
|
+ placeholder={t('Enter a name')}
|
|
+ style={{ width: '100%' }}
|
|
+ rules={
|
|
+ !isEdit
|
|
+ ? []
|
|
+ : [{ required: true, message: t('Please enter a name') }]
|
|
+ }
|
|
+ showClear
|
|
+ />
|
|
+ </Col>
|
|
<Col span={12}>
|
|
- <Form.InputNumber
|
|
- field='count'
|
|
- label={t('生成数量')}
|
|
- min={1}
|
|
- rules={[
|
|
- { required: true, message: t('请输入生成数量') },
|
|
- {
|
|
- validator: (rule, v) => {
|
|
- const num = parseInt(v, 10);
|
|
- return num > 0
|
|
- ? Promise.resolve()
|
|
- : Promise.reject(t('生成数量必须大于0'));
|
|
- },
|
|
- },
|
|
- ]}
|
|
+ <Form.Select
|
|
+ field='redeem_type'
|
|
+ label={t('Redeem type')}
|
|
+ style={{ width: '100%' }}
|
|
+ >
|
|
+ <Select.Option
|
|
+ value={REDEMPTION_REDEEM_TYPE.QUOTA}
|
|
+ >
|
|
+ {t('Quota')}
|
|
+ </Select.Option>
|
|
+ <Select.Option
|
|
+ value={REDEMPTION_REDEEM_TYPE.SUBSCRIPTION}
|
|
+ >
|
|
+ {t('Subscription')}
|
|
+ </Select.Option>
|
|
+ </Form.Select>
|
|
+ </Col>
|
|
+ <Col span={12}>
|
|
+ <Form.DatePicker
|
|
+ field='expired_time'
|
|
+ label={t('Expires at')}
|
|
+ type='dateTime'
|
|
+ placeholder={t('Leave empty for no expiration')}
|
|
style={{ width: '100%' }}
|
|
showClear
|
|
/>
|
|
</Col>
|
|
- )}
|
|
- </Row>
|
|
- </Card>
|
|
- </div>
|
|
- )}
|
|
+ {redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION && (
|
|
+ <Col span={24}>
|
|
+ <Form.Select
|
|
+ field='plan_id'
|
|
+ label={t('Subscription plan')}
|
|
+ loading={plansLoading}
|
|
+ placeholder={t('Select a subscription plan')}
|
|
+ style={{ width: '100%' }}
|
|
+ rules={[
|
|
+ {
|
|
+ required: true,
|
|
+ message: t('Please select a subscription plan'),
|
|
+ },
|
|
+ ]}
|
|
+ >
|
|
+ {(subscriptionPlans || []).map((plan) => (
|
|
+ <Select.Option key={plan.id} value={plan.id}>
|
|
+ {plan.title}
|
|
+ </Select.Option>
|
|
+ ))}
|
|
+ </Form.Select>
|
|
+ </Col>
|
|
+ )}
|
|
+ </Row>
|
|
+ </Card>
|
|
+
|
|
+ <Card className='!rounded-2xl shadow-sm border-0'>
|
|
+ <div className='flex items-center mb-2'>
|
|
+ <Avatar
|
|
+ size='small'
|
|
+ color='green'
|
|
+ className='mr-2 shadow-md'
|
|
+ >
|
|
+ <IconCreditCard size={16} />
|
|
+ </Avatar>
|
|
+ <div>
|
|
+ <Text className='text-lg font-medium'>
|
|
+ {t('Redeem settings')}
|
|
+ </Text>
|
|
+ <div className='text-xs text-gray-600'>
|
|
+ {redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
|
|
+ ? t('Choose a plan and generation count')
|
|
+ : t('Set quota amount and generation count')}
|
|
+ </div>
|
|
+ </div>
|
|
+ </div>
|
|
+
|
|
+ <Row gutter={12}>
|
|
+ {redeemType === REDEMPTION_REDEEM_TYPE.QUOTA && (
|
|
+ <Col span={!isEdit ? 12 : 24}>
|
|
+ <Form.AutoComplete
|
|
+ field='quota'
|
|
+ label={t('Quota')}
|
|
+ placeholder={t('Enter quota')}
|
|
+ style={{ width: '100%' }}
|
|
+ type='number'
|
|
+ rules={[
|
|
+ { required: true, message: t('Please enter quota') },
|
|
+ {
|
|
+ validator: (rule, v) => {
|
|
+ const num = parseInt(v, 10);
|
|
+ return num > 0
|
|
+ ? Promise.resolve()
|
|
+ : Promise.reject(t('Quota must be greater than 0'));
|
|
+ },
|
|
+ },
|
|
+ ]}
|
|
+ extraText={renderQuotaWithPrompt(
|
|
+ Number(values.quota) || 0,
|
|
+ )}
|
|
+ data={[
|
|
+ { value: 500000, label: '1$' },
|
|
+ { value: 5000000, label: '10$' },
|
|
+ { value: 25000000, label: '50$' },
|
|
+ { value: 50000000, label: '100$' },
|
|
+ { value: 250000000, label: '500$' },
|
|
+ { value: 500000000, label: '1000$' },
|
|
+ ]}
|
|
+ showClear
|
|
+ />
|
|
+ </Col>
|
|
+ )}
|
|
+ {!isEdit && (
|
|
+ <Col
|
|
+ span={
|
|
+ redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
|
|
+ ? 24
|
|
+ : 12
|
|
+ }
|
|
+ >
|
|
+ <Form.InputNumber
|
|
+ field='count'
|
|
+ label={t('Count')}
|
|
+ min={1}
|
|
+ rules={[
|
|
+ { required: true, message: t('Please enter count') },
|
|
+ {
|
|
+ validator: (rule, v) => {
|
|
+ const num = parseInt(v, 10);
|
|
+ return num > 0
|
|
+ ? Promise.resolve()
|
|
+ : Promise.reject(t('Count must be greater than 0'));
|
|
+ },
|
|
+ },
|
|
+ ]}
|
|
+ style={{ width: '100%' }}
|
|
+ showClear
|
|
+ />
|
|
+ </Col>
|
|
+ )}
|
|
+ </Row>
|
|
+ </Card>
|
|
+ </div>
|
|
+ );
|
|
+ }}
|
|
</Form>
|
|
</Spin>
|
|
</SideSheet>
|
|
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx
|
|
index 0348e3c8..ed9bc79b 100644
|
|
--- a/web/src/components/topup/index.jsx
|
|
+++ b/web/src/components/topup/index.jsx
|
|
@@ -112,7 +112,7 @@ const TopUp = () => {
|
|
discount: {},
|
|
});
|
|
|
|
- const topUp = async () => {
|
|
+ const legacyTopUp = async () => {
|
|
if (redemptionCode === '') {
|
|
showInfo(t('请输入兑换码!'));
|
|
return;
|
|
@@ -148,6 +148,96 @@ const TopUp = () => {
|
|
}
|
|
};
|
|
|
|
+ const topUpLegacy2 = async () => {
|
|
+ if (redemptionCode === '') {
|
|
+ showInfo(t('请输入兑换码!'));
|
|
+ return;
|
|
+ }
|
|
+ setIsSubmitting(true);
|
|
+ try {
|
|
+ const res = await API.post('/api/user/topup', {
|
|
+ key: redemptionCode,
|
|
+ });
|
|
+ const { success, message, data } = res.data;
|
|
+ if (success) {
|
|
+ showSuccess(t('兑换成功!'));
|
|
+ if (data?.redeem_type === 'subscription') {
|
|
+ Modal.success({
|
|
+ title: t('兑换成功'),
|
|
+ content: `${t('成功兑换订阅套餐:')}${data.plan_title || data.plan_id}`,
|
|
+ centered: true,
|
|
+ });
|
|
+ await getSubscriptionSelf();
|
|
+ } else {
|
|
+ Modal.success({
|
|
+ title: t('兑换成功'),
|
|
+ content: `${t('成功兑换额度:')}${renderQuota(data?.quota || 0)}`,
|
|
+ centered: true,
|
|
+ });
|
|
+ if (userState.user) {
|
|
+ const updatedUser = {
|
|
+ ...userState.user,
|
|
+ quota: userState.user.quota + (data?.quota || 0),
|
|
+ };
|
|
+ userDispatch({ type: 'login', payload: updatedUser });
|
|
+ }
|
|
+ }
|
|
+ setRedemptionCode('');
|
|
+ } else {
|
|
+ showError(message);
|
|
+ }
|
|
+ } catch (err) {
|
|
+ showError(t('请求失败'));
|
|
+ } finally {
|
|
+ setIsSubmitting(false);
|
|
+ }
|
|
+ };
|
|
+
|
|
+ const topUp = async () => {
|
|
+ if (redemptionCode === '') {
|
|
+ showInfo(t('Please enter a redemption code'));
|
|
+ return;
|
|
+ }
|
|
+ setIsSubmitting(true);
|
|
+ try {
|
|
+ const res = await API.post('/api/user/topup', {
|
|
+ key: redemptionCode,
|
|
+ });
|
|
+ const { success, message, data } = res.data;
|
|
+ if (success) {
|
|
+ showSuccess(t('Redemption successful'));
|
|
+ if (data?.redeem_type === 'subscription') {
|
|
+ Modal.success({
|
|
+ title: t('Redemption successful'),
|
|
+ content: `${t('Subscription plan redeemed: ')}${data.plan_title || data.plan_id}`,
|
|
+ centered: true,
|
|
+ });
|
|
+ await getSubscriptionSelf();
|
|
+ } else {
|
|
+ Modal.success({
|
|
+ title: t('Redemption successful'),
|
|
+ content: `${t('Quota redeemed: ')}${renderQuota(data?.quota || 0)}`,
|
|
+ centered: true,
|
|
+ });
|
|
+ if (userState.user) {
|
|
+ const updatedUser = {
|
|
+ ...userState.user,
|
|
+ quota: userState.user.quota + (data?.quota || 0),
|
|
+ };
|
|
+ userDispatch({ type: 'login', payload: updatedUser });
|
|
+ }
|
|
+ }
|
|
+ setRedemptionCode('');
|
|
+ } else {
|
|
+ showError(message);
|
|
+ }
|
|
+ } catch (err) {
|
|
+ showError(t('Request failed'));
|
|
+ } finally {
|
|
+ setIsSubmitting(false);
|
|
+ }
|
|
+ };
|
|
+
|
|
const openTopUpLink = () => {
|
|
if (!topUpLink) {
|
|
showError(t('超级管理员未设置充值链接!'));
|
|
diff --git a/web/src/constants/redemption.constants.js b/web/src/constants/redemption.constants.js
|
|
index baba96ee..a453896f 100644
|
|
--- a/web/src/constants/redemption.constants.js
|
|
+++ b/web/src/constants/redemption.constants.js
|
|
@@ -23,6 +23,22 @@ export const REDEMPTION_STATUS = {
|
|
USED: 3, // Used
|
|
};
|
|
|
|
+export const REDEMPTION_REDEEM_TYPE = {
|
|
+ QUOTA: 'quota',
|
|
+ SUBSCRIPTION: 'subscription',
|
|
+};
|
|
+
|
|
+export const REDEMPTION_REDEEM_TYPE_MAP = {
|
|
+ [REDEMPTION_REDEEM_TYPE.QUOTA]: {
|
|
+ color: 'blue',
|
|
+ text: 'Quota',
|
|
+ },
|
|
+ [REDEMPTION_REDEEM_TYPE.SUBSCRIPTION]: {
|
|
+ color: 'green',
|
|
+ text: 'Subscription',
|
|
+ },
|
|
+};
|
|
+
|
|
// Redemption code status display mapping
|
|
export const REDEMPTION_STATUS_MAP = {
|
|
[REDEMPTION_STATUS.UNUSED]: {
|