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 ( - {t('已过期')} + {t('Expired')} ); } @@ -68,23 +61,50 @@ const renderStatus = (status, record, t) => { return ( - {t('未知状态')} + {t('Unknown')} + + ); +}; + +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 ( + + {t(typeConfig.text)} + + ); +}; + +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 ( + + {`${t('Plan')}: ${label}`} + + ); + } + return ( + + {renderQuota(parseInt(record.quota))} ); }; -/** - * 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) =>
{renderRedeemType(text, t)}
, + }, + { + 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 ( -
- - {renderQuota(parseInt(text))} - -
- ); + render: (_, record) => { + return
{renderRedeemTarget(record, t)}
; }, }, { - title: t('创建时间'), + title: t('Created at'), dataIndex: 'created_time', render: (text) => { return
{renderTimestamp(text)}
; }, }, { - title: t('过期时间'), + title: t('Expires at'), dataIndex: 'expired_time', render: (text) => { - return
{text === 0 ? t('永不过期') : renderTimestamp(text)}
; + return
{text === 0 ? t('Never') : renderTimestamp(text)}
; }, }, { - title: t('兑换人ID'), + title: t('Redeemed by'), dataIndex: 'used_user_id', render: (text) => { - return
{text === 0 ? t('无') : text}
; + return
{text === 0 ? t('N/A') : text}
; }, }, { @@ -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' > . 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: (
-

{t('兑换码创建成功,是否下载兑换码?')}

-

{t('兑换码将以文本文件的形式下载,文件名为兑换码的名称。')}

+

{t('Download the generated redemption codes?')}

+

{t('Codes will be downloaded as a text file named after this redemption.')}

), onOk: () => { @@ -168,15 +221,15 @@ const EditRedemptionModal = (props) => { {isEdit ? ( - {t('更新')} + {t('Edit')} ) : ( - {t('新建')} + {t('New')} )} - {isEdit ? t('更新兑换码信息') : t('创建新的兑换码')} + {isEdit ? t('Update redemption') : t('Create redemption')} } @@ -192,7 +245,7 @@ const EditRedemptionModal = (props) => { icon={} loading={loading} > - {t('提交')} + {t('Submit')} @@ -214,135 +267,188 @@ const EditRedemptionModal = (props) => { getFormApi={(api) => (formApiRef.current = api)} onSubmit={submit} > - {({ values }) => ( -
- - {/* Header: Basic Info */} -
- - - -
- - {t('基本信息')} - -
- {t('设置兑换码的基本信息')} -
-
-
- - - - - - - - - -
- - - {/* Header: Quota Settings */} -
- - - -
- - {t('额度设置')} - -
- {t('设置兑换码的额度和数量')} + {({ values }) => { + const redeemType = + values.redeem_type || REDEMPTION_REDEEM_TYPE.QUOTA; + return ( +
+ +
+ + + +
+ + {t('Basic info')} + +
+ {t('Set the redemption name, type, and expiration')} +
-
- - - { - 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 - /> - - {!isEdit && ( + + + + - { - const num = parseInt(v, 10); - return num > 0 - ? Promise.resolve() - : Promise.reject(t('生成数量必须大于0')); - }, - }, - ]} + + + {t('Quota')} + + + {t('Subscription')} + + + + + - )} - - -
- )} + {redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION && ( + + + {(subscriptionPlans || []).map((plan) => ( + + {plan.title} + + ))} + + + )} + + + + +
+ + + +
+ + {t('Redeem settings')} + +
+ {redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION + ? t('Choose a plan and generation count') + : t('Set quota amount and generation count')} +
+
+
+ + + {redeemType === REDEMPTION_REDEEM_TYPE.QUOTA && ( + + { + 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 + /> + + )} + {!isEdit && ( + + { + const num = parseInt(v, 10); + return num > 0 + ? Promise.resolve() + : Promise.reject(t('Count must be greater than 0')); + }, + }, + ]} + style={{ width: '100%' }} + showClear + /> + + )} + +
+
+ ); + }} 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]: {