diff --git a/controller/subscription.go b/controller/subscription.go index 5e20dd5b6..106ed8c87 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -6,6 +6,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -135,6 +136,13 @@ func AdminCreateSubscriptionPlan(c *gin.Context) { common.ApiErrorMsg(c, "总额度不能为负数") return } + req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup) + if req.Plan.UpgradeGroup != "" { + if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok { + common.ApiErrorMsg(c, "升级分组不存在") + return + } + } req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod) if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { common.ApiErrorMsg(c, "自定义重置周期需大于0秒") @@ -183,6 +191,13 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { common.ApiErrorMsg(c, "总额度不能为负数") return } + req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup) + if req.Plan.UpgradeGroup != "" { + if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok { + common.ApiErrorMsg(c, "升级分组不存在") + return + } + } req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod) if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { common.ApiErrorMsg(c, "自定义重置周期需大于0秒") @@ -205,6 +220,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) { "creem_product_id": req.Plan.CreemProductId, "max_purchase_per_user": req.Plan.MaxPurchasePerUser, "total_amount": req.Plan.TotalAmount, + "upgrade_group": req.Plan.UpgradeGroup, "updated_at": common.GetTimestamp(), } if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil { @@ -254,10 +270,15 @@ func AdminBindSubscription(c *gin.Context) { common.ApiErrorMsg(c, "参数错误") return } - if err := model.AdminBindSubscription(req.UserId, req.PlanId, ""); err != nil { + msg, err := model.AdminBindSubscription(req.UserId, req.PlanId, "") + if err != nil { common.ApiError(c, err) return } + if msg != "" { + common.ApiSuccess(c, gin.H{"message": msg}) + return + } common.ApiSuccess(c, nil) } @@ -293,10 +314,15 @@ func AdminCreateUserSubscription(c *gin.Context) { common.ApiErrorMsg(c, "参数错误") return } - if err := model.AdminBindSubscription(userId, req.PlanId, ""); err != nil { + msg, err := model.AdminBindSubscription(userId, req.PlanId, "") + if err != nil { common.ApiError(c, err) return } + if msg != "" { + common.ApiSuccess(c, gin.H{"message": msg}) + return + } common.ApiSuccess(c, nil) } @@ -307,10 +333,15 @@ func AdminInvalidateUserSubscription(c *gin.Context) { common.ApiErrorMsg(c, "无效的订阅ID") return } - if err := model.AdminInvalidateUserSubscription(subId); err != nil { + msg, err := model.AdminInvalidateUserSubscription(subId) + if err != nil { common.ApiError(c, err) return } + if msg != "" { + common.ApiSuccess(c, gin.H{"message": msg}) + return + } common.ApiSuccess(c, nil) } @@ -321,9 +352,14 @@ func AdminDeleteUserSubscription(c *gin.Context) { common.ApiErrorMsg(c, "无效的订阅ID") return } - if err := model.AdminDeleteUserSubscription(subId); err != nil { + msg, err := model.AdminDeleteUserSubscription(subId) + if err != nil { common.ApiError(c, err) return } + if msg != "" { + common.ApiSuccess(c, gin.H{"message": msg}) + return + } common.ApiSuccess(c, nil) } diff --git a/model/subscription.go b/model/subscription.go index f42efeb2a..73730f3b4 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -165,6 +165,9 @@ type SubscriptionPlan struct { // Max purchases per user (0 = unlimited) MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"` + // Upgrade user group after purchase (empty = no change) + UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"` + // Total quota (amount in quota units, 0 = unlimited) TotalAmount int64 `json:"total_amount" gorm:"type:bigint;not null;default:0"` @@ -244,6 +247,9 @@ type UserSubscription struct { LastResetTime int64 `json:"last_reset_time" gorm:"type:bigint;default:0"` NextResetTime int64 `json:"next_reset_time" gorm:"type:bigint;default:0;index"` + UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"` + PrevUserGroup string `json:"prev_user_group" gorm:"type:varchar(64);default:''"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` UpdatedAt int64 `json:"updated_at" gorm:"bigint"` } @@ -379,6 +385,55 @@ func CountUserSubscriptionsByPlan(userId int, planId int) (int64, error) { return count, nil } +func getUserGroupByIdTx(tx *gorm.DB, userId int) (string, error) { + if userId <= 0 { + return "", errors.New("invalid userId") + } + if tx == nil { + tx = DB + } + var group string + if err := tx.Model(&User{}).Where("id = ?", userId).Select(commonGroupCol).Find(&group).Error; err != nil { + return "", err + } + return group, nil +} + +func downgradeUserGroupForSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now int64) (string, error) { + if tx == nil || sub == nil { + return "", errors.New("invalid downgrade args") + } + upgradeGroup := strings.TrimSpace(sub.UpgradeGroup) + if upgradeGroup == "" { + return "", nil + } + currentGroup, err := getUserGroupByIdTx(tx, sub.UserId) + if err != nil { + return "", err + } + if currentGroup != upgradeGroup { + return "", nil + } + var activeSub UserSubscription + activeQuery := tx.Where("user_id = ? AND status = ? AND end_time > ? AND id <> ? AND upgrade_group <> ''", + sub.UserId, "active", now, sub.Id). + Order("end_time desc, id desc"). + Limit(1). + Find(&activeSub) + if activeQuery.Error == nil && activeQuery.RowsAffected > 0 { + return "", nil + } + prevGroup := strings.TrimSpace(sub.PrevUserGroup) + if prevGroup == "" || prevGroup == currentGroup { + return "", nil + } + if err := tx.Model(&User{}).Where("id = ?", sub.UserId). + Update("group", prevGroup).Error; err != nil { + return "", err + } + return prevGroup, nil +} + func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, source string) (*UserSubscription, error) { if tx == nil { return nil, errors.New("tx is nil") @@ -412,6 +467,21 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio if nextReset > 0 { lastReset = now.Unix() } + upgradeGroup := strings.TrimSpace(plan.UpgradeGroup) + prevGroup := "" + if upgradeGroup != "" { + currentGroup, err := getUserGroupByIdTx(tx, userId) + if err != nil { + return nil, err + } + if currentGroup != upgradeGroup { + prevGroup = currentGroup + if err := tx.Model(&User{}).Where("id = ?", userId). + Update("group", upgradeGroup).Error; err != nil { + return nil, err + } + } + } sub := &UserSubscription{ UserId: userId, PlanId: plan.Id, @@ -423,6 +493,8 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio Source: source, LastResetTime: lastReset, NextResetTime: nextReset, + UpgradeGroup: upgradeGroup, + PrevUserGroup: prevGroup, CreatedAt: common.GetTimestamp(), UpdatedAt: common.GetTimestamp(), } @@ -445,6 +517,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error { var logPlanTitle string var logMoney float64 var logPaymentMethod string + var upgradeGroup string err := DB.Transaction(func(tx *gorm.DB) error { var order SubscriptionOrder if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil { @@ -463,6 +536,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error { if !plan.Enabled { // still allow completion for already purchased orders } + upgradeGroup = strings.TrimSpace(plan.UpgradeGroup) _, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order") if err != nil { return err @@ -487,6 +561,9 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error { if err != nil { return err } + if upgradeGroup != "" && logUserId > 0 { + _ = UpdateUserGroupCache(logUserId, upgradeGroup) + } if logUserId > 0 { msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod) RecordLog(logUserId, LogTypeTopup, msg) @@ -551,18 +628,26 @@ func ExpireSubscriptionOrder(tradeNo string) error { } // Admin bind (no payment). Creates a UserSubscription from a plan. -func AdminBindSubscription(userId int, planId int, sourceNote string) error { +func AdminBindSubscription(userId int, planId int, sourceNote string) (string, error) { if userId <= 0 || planId <= 0 { - return errors.New("invalid userId or planId") + return "", errors.New("invalid userId or planId") } plan, err := GetSubscriptionPlanById(planId) if err != nil { - return err + return "", err } - return DB.Transaction(func(tx *gorm.DB) error { + err = DB.Transaction(func(tx *gorm.DB) error { _, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin") return err }) + if err != nil { + return "", err + } + if strings.TrimSpace(plan.UpgradeGroup) != "" { + _ = UpdateUserGroupCache(userId, plan.UpgradeGroup) + return fmt.Sprintf("用户分组将升级到 %s", plan.UpgradeGroup), nil + } + return "", nil } // GetAllActiveUserSubscriptions returns all active subscriptions for a user. @@ -611,26 +696,89 @@ func buildSubscriptionSummaries(subs []UserSubscription) []SubscriptionSummary { } // AdminInvalidateUserSubscription marks a user subscription as cancelled and ends it immediately. -func AdminInvalidateUserSubscription(userSubscriptionId int) error { +func AdminInvalidateUserSubscription(userSubscriptionId int) (string, error) { if userSubscriptionId <= 0 { - return errors.New("invalid userSubscriptionId") + return "", errors.New("invalid userSubscriptionId") } now := common.GetTimestamp() - return DB.Model(&UserSubscription{}). - Where("id = ?", userSubscriptionId). - Updates(map[string]interface{}{ + cacheGroup := "" + downgradeGroup := "" + var userId int + err := DB.Transaction(func(tx *gorm.DB) error { + var sub UserSubscription + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil { + return err + } + userId = sub.UserId + if err := tx.Model(&sub).Updates(map[string]interface{}{ "status": "cancelled", "end_time": now, "updated_at": now, - }).Error + }).Error; err != nil { + return err + } + target, err := downgradeUserGroupForSubscriptionTx(tx, &sub, now) + if err != nil { + return err + } + if target != "" { + cacheGroup = target + downgradeGroup = target + } + return nil + }) + if err != nil { + return "", err + } + if cacheGroup != "" && userId > 0 { + _ = UpdateUserGroupCache(userId, cacheGroup) + } + if downgradeGroup != "" { + return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil + } + return "", nil } // AdminDeleteUserSubscription hard-deletes a user subscription. -func AdminDeleteUserSubscription(userSubscriptionId int) error { +func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) { if userSubscriptionId <= 0 { - return errors.New("invalid userSubscriptionId") + return "", errors.New("invalid userSubscriptionId") } - return DB.Where("id = ?", userSubscriptionId).Delete(&UserSubscription{}).Error + now := common.GetTimestamp() + cacheGroup := "" + downgradeGroup := "" + var userId int + err := DB.Transaction(func(tx *gorm.DB) error { + var sub UserSubscription + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil { + return err + } + userId = sub.UserId + target, err := downgradeUserGroupForSubscriptionTx(tx, &sub, now) + if err != nil { + return err + } + if target != "" { + cacheGroup = target + downgradeGroup = target + } + if err := tx.Where("id = ?", userSubscriptionId).Delete(&UserSubscription{}).Error; err != nil { + return err + } + return nil + }) + if err != nil { + return "", err + } + if cacheGroup != "" && userId > 0 { + _ = UpdateUserGroupCache(userId, cacheGroup) + } + if downgradeGroup != "" { + return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil + } + return "", nil } type SubscriptionPreConsumeResult struct { @@ -641,6 +789,93 @@ type SubscriptionPreConsumeResult struct { AmountUsedAfter int64 } +// ExpireDueSubscriptions marks expired subscriptions and handles group downgrade. +func ExpireDueSubscriptions(limit int) (int, error) { + if limit <= 0 { + limit = 200 + } + now := GetDBTimestamp() + var subs []UserSubscription + if err := DB.Where("status = ? AND end_time > 0 AND end_time <= ?", "active", now). + Order("end_time asc, id asc"). + Limit(limit). + Find(&subs).Error; err != nil { + return 0, err + } + if len(subs) == 0 { + return 0, nil + } + expiredCount := 0 + userIds := make(map[int]struct{}, len(subs)) + for _, sub := range subs { + if sub.UserId > 0 { + userIds[sub.UserId] = struct{}{} + } + } + for userId := range userIds { + cacheGroup := "" + err := DB.Transaction(func(tx *gorm.DB) error { + res := tx.Model(&UserSubscription{}). + Where("user_id = ? AND status = ? AND end_time > 0 AND end_time <= ?", userId, "active", now). + Updates(map[string]interface{}{ + "status": "expired", + "updated_at": common.GetTimestamp(), + }) + if res.Error != nil { + return res.Error + } + expiredCount += int(res.RowsAffected) + + // If there's an active upgraded subscription, keep current group. + var activeSub UserSubscription + activeQuery := tx.Where("user_id = ? AND status = ? AND end_time > ? AND upgrade_group <> ''", + userId, "active", now). + Order("end_time desc, id desc"). + Limit(1). + Find(&activeSub) + if activeQuery.Error == nil && activeQuery.RowsAffected > 0 { + return nil + } + + // No active upgraded subscription, downgrade to previous group if needed. + var lastExpired UserSubscription + expiredQuery := tx.Where("user_id = ? AND status = ? AND upgrade_group <> ''", + userId, "expired"). + Order("end_time desc, id desc"). + Limit(1). + Find(&lastExpired) + if expiredQuery.Error != nil || expiredQuery.RowsAffected == 0 { + return nil + } + upgradeGroup := strings.TrimSpace(lastExpired.UpgradeGroup) + prevGroup := strings.TrimSpace(lastExpired.PrevUserGroup) + if upgradeGroup == "" || prevGroup == "" { + return nil + } + currentGroup, err := getUserGroupByIdTx(tx, userId) + if err != nil { + return err + } + if currentGroup != upgradeGroup || currentGroup == prevGroup { + return nil + } + if err := tx.Model(&User{}).Where("id = ?", userId). + Update("group", prevGroup).Error; err != nil { + return err + } + cacheGroup = prevGroup + return nil + }) + if err != nil { + return expiredCount, err + } + if cacheGroup != "" { + _ = UpdateUserGroupCache(userId, cacheGroup) + } + } + return expiredCount, nil +} + // SubscriptionPreConsumeRecord stores idempotent pre-consume operations per request. type SubscriptionPreConsumeRecord struct { Id int `json:"id"` diff --git a/model/user_cache.go b/model/user_cache.go index d06acd80e..7a6af0987 100644 --- a/model/user_cache.go +++ b/model/user_cache.go @@ -204,6 +204,10 @@ func updateUserGroupCache(userId int, group string) error { return common.RedisHSetField(getUserCacheKey(userId), "Group", group) } +func UpdateUserGroupCache(userId int, group string) error { + return updateUserGroupCache(userId, group) +} + func updateUserNameCache(userId int, username string) error { if !common.RedisEnabled { return nil diff --git a/service/subscription_reset_task.go b/service/subscription_reset_task.go index 453c5e8d6..9dcd37306 100644 --- a/service/subscription_reset_task.go +++ b/service/subscription_reset_task.go @@ -52,6 +52,21 @@ func runSubscriptionQuotaResetOnce() { ctx := context.Background() totalReset := 0 + totalExpired := 0 + for { + n, err := model.ExpireDueSubscriptions(subscriptionResetBatchSize) + if err != nil { + logger.LogWarn(ctx, fmt.Sprintf("subscription expire task failed: %v", err)) + return + } + if n == 0 { + break + } + totalExpired += n + if n < subscriptionResetBatchSize { + break + } + } for { n, err := model.ResetDueSubscriptions(subscriptionResetBatchSize) if err != nil { @@ -72,7 +87,7 @@ func runSubscriptionQuotaResetOnce() { subscriptionCleanupLast.Store(time.Now().Unix()) } } - if totalReset > 0 && common.DebugEnabled { - logger.LogDebug(ctx, "subscription quota reset: reset_count=%d", totalReset) + if common.DebugEnabled && (totalReset > 0 || totalExpired > 0) { + logger.LogDebug(ctx, "subscription maintenance: reset_count=%d, expired_count=%d", totalReset, totalExpired) } } diff --git a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx index 58ca2817e..1cff521bc 100644 --- a/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx +++ b/web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx @@ -82,6 +82,8 @@ const renderPlanTitle = (text, record, t) => { {t('总额度')} {plan?.total_amount > 0 ? plan.total_amount : t('不限')} + {t('升级分组')} + {plan?.upgrade_group ? plan.upgrade_group : t('不升级')} {t('购买上限')} {plan?.max_purchase_per_user > 0 @@ -168,6 +170,15 @@ const renderTotalAmount = (text, record, t) => { ); }; +const renderUpgradeGroup = (text, record, t) => { + const group = record?.plan?.upgrade_group || ''; + return ( + + {group ? group : t('不升级')} + + ); +}; + const renderResetPeriod = (text, record, t) => { const period = record?.plan?.quota_reset_period || 'never'; const isNever = period === 'never'; @@ -291,7 +302,7 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => { }, { title: t('有效期'), - width: 80, + width: 100, render: (text, record) => renderDuration(text, record, t), }, { @@ -315,6 +326,11 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => { width: 100, render: (text, record) => renderTotalAmount(text, record, t), }, + { + title: t('升级分组'), + width: 100, + render: (text, record) => renderUpgradeGroup(text, record, t), + }, { title: t('操作'), dataIndex: 'operate', diff --git a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx index 6309359d7..2183f066a 100644 --- a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx +++ b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useRef } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { Avatar, Button, @@ -74,6 +74,8 @@ const AddEditSubscriptionModal = ({ t, }) => { const [loading, setLoading] = useState(false); + const [groupOptions, setGroupOptions] = useState([]); + const [groupLoading, setGroupLoading] = useState(false); const isMobile = useIsMobile(); const formApiRef = useRef(null); const isEdit = editingPlan?.plan?.id !== undefined; @@ -93,6 +95,7 @@ const AddEditSubscriptionModal = ({ sort_order: 0, max_purchase_per_user: 0, total_amount: 0, + upgrade_group: '', stripe_price_id: '', creem_product_id: '', }); @@ -116,11 +119,27 @@ const AddEditSubscriptionModal = ({ sort_order: Number(p.sort_order || 0), max_purchase_per_user: Number(p.max_purchase_per_user || 0), total_amount: Number(p.total_amount || 0), + upgrade_group: p.upgrade_group || '', stripe_price_id: p.stripe_price_id || '', creem_product_id: p.creem_product_id || '', }; }; + useEffect(() => { + if (!visible) return; + setGroupLoading(true); + API.get('/api/group') + .then((res) => { + if (res.data?.success) { + setGroupOptions(res.data?.data || []); + } else { + setGroupOptions([]); + } + }) + .catch(() => setGroupOptions([])) + .finally(() => setGroupLoading(false)); + }, [visible]); + const submit = async (values) => { if (!values.title || values.title.trim() === '') { showError(t('套餐标题不能为空')); @@ -143,6 +162,7 @@ const AddEditSubscriptionModal = ({ sort_order: Number(values.sort_order || 0), max_purchase_per_user: Number(values.max_purchase_per_user || 0), total_amount: Number(values.total_amount || 0), + upgrade_group: values.upgrade_group || '', }, }; if (editingPlan?.plan?.id) { @@ -257,6 +277,7 @@ const AddEditSubscriptionModal = ({ field='title' label={t('套餐标题')} placeholder={t('例如:基础套餐')} + required rules={[ { required: true, message: t('请输入套餐标题') }, ]} @@ -277,6 +298,7 @@ const AddEditSubscriptionModal = ({ + + + {t('不升级')} + {(groupOptions || []).map((g) => ( + + {g} + + ))} + + + {durationUnitOptions.map((o) => ( @@ -384,6 +425,7 @@ const AddEditSubscriptionModal = ({ { }, ); if (res.data?.success) { - showSuccess(t('新增成功')); + const msg = res.data?.data?.message; + showSuccess(msg ? msg : t('新增成功')); setSelectedPlanId(null); await loadUserSubscriptions(); onSuccess?.(); @@ -204,7 +205,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => { `/api/subscription/admin/user_subscriptions/${subId}/invalidate`, ); if (res.data?.success) { - showSuccess(t('已作废')); + const msg = res.data?.data?.message; + showSuccess(msg ? msg : t('已作废')); await loadUserSubscriptions(); onSuccess?.(); } else { @@ -229,7 +231,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => { `/api/subscription/admin/user_subscriptions/${subId}`, ); if (res.data?.success) { - showSuccess(t('已删除')); + const msg = res.data?.data?.message; + showSuccess(msg ? msg : t('已删除')); await loadUserSubscriptions(); onSuccess?.(); } else { diff --git a/web/src/components/topup/SubscriptionPlansCard.jsx b/web/src/components/topup/SubscriptionPlansCard.jsx index 248b3e696..cf30bd3d9 100644 --- a/web/src/components/topup/SubscriptionPlansCard.jsx +++ b/web/src/components/topup/SubscriptionPlansCard.jsx @@ -31,8 +31,8 @@ import { Tooltip, Typography, } from '@douyinfe/semi-ui'; -import { API, showError, showSuccess } from '../../helpers'; -import { getCurrencyConfig, stringToColor } from '../../helpers/render'; +import { API, showError, showSuccess, renderQuota } from '../../helpers'; +import { getCurrencyConfig } from '../../helpers/render'; import { Crown, RefreshCw, Sparkles } from 'lucide-react'; import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal'; @@ -232,6 +232,16 @@ const SubscriptionPlansCard = ({ return map; }, [allSubscriptions]); + const planTitleMap = useMemo(() => { + const map = new Map(); + (plans || []).forEach((p) => { + const plan = p?.plan; + if (!plan?.id) return; + map.set(plan.id, plan.title || ''); + }); + return map; + }, [plans]); + const getPlanPurchaseCount = (planId) => planPurchaseCountMap.get(planId) || 0; @@ -374,6 +384,8 @@ const SubscriptionPlansCard = ({ totalAmount > 0 ? Math.max(0, totalAmount - usedAmount) : 0; + const planTitle = + planTitleMap.get(subscription?.plan_id) || ''; const remainDays = getRemainingDays(sub); const usagePercent = getUsagePercent(sub); const now = Date.now() / 1000; @@ -387,7 +399,9 @@ const SubscriptionPlansCard = ({
- {t('订阅')} #{subscription?.id} + {planTitle + ? `${planTitle} · ${t('订阅')} #${subscription?.id}` + : `${t('订阅')} #${subscription?.id}`} {isActive ? (
{t('总额度')}:{' '} - {totalAmount > 0 - ? `${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}` - : t('不限')} + {totalAmount > 0 ? ( + + + {renderQuota(usedAmount)}/ + {renderQuota(totalAmount)} · {t('剩余')}{' '} + {renderQuota(remainAmount)} + + + ) : ( + t('不限') + )} {totalAmount > 0 && ( {t('已用')} {usagePercent}% @@ -453,18 +477,30 @@ const SubscriptionPlansCard = ({ ); const isPopular = index === 0 && plans.length > 1; const limit = Number(plan?.max_purchase_per_user || 0); - const limitLabel = - limit > 0 ? `${t('限购')} ${limit}` : t('不限购'); + const limitLabel = limit > 0 ? `${t('限购')} ${limit}` : null; const totalLabel = totalAmount > 0 - ? `${t('总额度')}: ${totalAmount}` + ? `${t('总额度')}: ${renderQuota(totalAmount)}` : `${t('总额度')}: ${t('不限')}`; - const planTags = [ - `${t('有效期')}: ${formatDuration(plan, t)}`, - `${t('重置')}: ${formatResetPeriod(plan, t)}`, - totalLabel, - limitLabel, - ]; + const upgradeLabel = plan?.upgrade_group + ? `${t('升级分组')}: ${plan.upgrade_group}` + : null; + const resetLabel = + formatResetPeriod(plan, t) === t('不重置') + ? null + : `${t('额度重置')}: ${formatResetPeriod(plan, t)}`; + const planBenefits = [ + { label: `${t('有效期')}: ${formatDuration(plan, t)}` }, + resetLabel ? { label: resetLabel } : null, + totalAmount > 0 + ? { + label: totalLabel, + tooltip: `${t('原生额度')}:${totalAmount}`, + } + : { label: totalLabel }, + limitLabel ? { label: limitLabel } : null, + upgradeLabel ? { label: upgradeLabel } : null, + ].filter(Boolean); return (
- {/* 属性标签 */} -
- {planTags.map((tag) => ( - - {tag} - - ))} + {/* 套餐权益描述 */} +
+ {planBenefits.map((item) => { + const content = ( +
+ + {item.label} +
+ ); + if (!item.tooltip) { + return ( +
+ {content} +
+ ); + } + return ( + +
+ {content} +
+
+ ); + })}