mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 07:47:28 +00:00
🔧 chore: Unify subscription plan status toggle with PATCH endpoint
Replace separate enable/disable flows with a single PATCH API that updates the enabled flag. Update frontend hooks and table actions to call the unified endpoint and keep UI behavior consistent. Introduce a minimal admin controller handler and route for the status update.
This commit is contained in:
@@ -106,6 +106,16 @@ func GetJsonString(data any) string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeBillingPreference clamps the billing preference to valid values.
|
||||||
|
func NormalizeBillingPreference(pref string) string {
|
||||||
|
switch strings.TrimSpace(pref) {
|
||||||
|
case "subscription_first", "wallet_first", "subscription_only", "wallet_only":
|
||||||
|
return strings.TrimSpace(pref)
|
||||||
|
default:
|
||||||
|
return "subscription_first"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MaskEmail masks a user email to prevent PII leakage in logs
|
// MaskEmail masks a user email to prevent PII leakage in logs
|
||||||
// Returns "***masked***" if email is empty, otherwise shows only the domain part
|
// Returns "***masked***" if email is empty, otherwise shows only the domain part
|
||||||
func MaskEmail(email string) string {
|
func MaskEmail(email string) string {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,27 +22,6 @@ type BillingPreferenceRequest struct {
|
|||||||
BillingPreference string `json:"billing_preference"`
|
BillingPreference string `json:"billing_preference"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeBillingPreference(pref string) string {
|
|
||||||
switch strings.TrimSpace(pref) {
|
|
||||||
case "subscription_first", "wallet_first", "subscription_only", "wallet_only":
|
|
||||||
return strings.TrimSpace(pref)
|
|
||||||
default:
|
|
||||||
return "subscription_first"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeQuotaResetPeriod(period string) string {
|
|
||||||
switch strings.TrimSpace(period) {
|
|
||||||
case model.SubscriptionResetDaily,
|
|
||||||
model.SubscriptionResetWeekly,
|
|
||||||
model.SubscriptionResetMonthly,
|
|
||||||
model.SubscriptionResetCustom:
|
|
||||||
return strings.TrimSpace(period)
|
|
||||||
default:
|
|
||||||
return model.SubscriptionResetNever
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- User APIs ----
|
// ---- User APIs ----
|
||||||
|
|
||||||
func GetSubscriptionPlans(c *gin.Context) {
|
func GetSubscriptionPlans(c *gin.Context) {
|
||||||
@@ -66,7 +44,7 @@ func GetSubscriptionPlans(c *gin.Context) {
|
|||||||
func GetSubscriptionSelf(c *gin.Context) {
|
func GetSubscriptionSelf(c *gin.Context) {
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
settingMap, _ := model.GetUserSetting(userId, false)
|
settingMap, _ := model.GetUserSetting(userId, false)
|
||||||
pref := normalizeBillingPreference(settingMap.BillingPreference)
|
pref := common.NormalizeBillingPreference(settingMap.BillingPreference)
|
||||||
|
|
||||||
// Get all subscriptions (including expired)
|
// Get all subscriptions (including expired)
|
||||||
allSubscriptions, err := model.GetAllUserSubscriptions(userId)
|
allSubscriptions, err := model.GetAllUserSubscriptions(userId)
|
||||||
@@ -94,7 +72,7 @@ func UpdateSubscriptionPreference(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "参数错误")
|
common.ApiErrorMsg(c, "参数错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pref := normalizeBillingPreference(req.BillingPreference)
|
pref := common.NormalizeBillingPreference(req.BillingPreference)
|
||||||
|
|
||||||
user, err := model.GetUserById(userId, true)
|
user, err := model.GetUserById(userId, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -156,7 +134,7 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
|||||||
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||||
req.Plan.DurationValue = 1
|
req.Plan.DurationValue = 1
|
||||||
}
|
}
|
||||||
req.Plan.QuotaResetPeriod = normalizeQuotaResetPeriod(req.Plan.QuotaResetPeriod)
|
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
||||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||||
return
|
return
|
||||||
@@ -223,7 +201,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
|||||||
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||||
req.Plan.DurationValue = 1
|
req.Plan.DurationValue = 1
|
||||||
}
|
}
|
||||||
req.Plan.QuotaResetPeriod = normalizeQuotaResetPeriod(req.Plan.QuotaResetPeriod)
|
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
||||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||||
return
|
return
|
||||||
@@ -282,14 +260,22 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
|||||||
common.ApiSuccess(c, nil)
|
common.ApiSuccess(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminDeleteSubscriptionPlan(c *gin.Context) {
|
type AdminUpdateSubscriptionPlanStatusRequest struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminUpdateSubscriptionPlanStatus(c *gin.Context) {
|
||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
common.ApiErrorMsg(c, "无效的ID")
|
common.ApiErrorMsg(c, "无效的ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// best practice: disable instead of hard delete to avoid breaking past orders
|
var req AdminUpdateSubscriptionPlanStatusRequest
|
||||||
if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", false).Error; err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil {
|
||||||
|
common.ApiErrorMsg(c, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", *req.Enabled).Error; err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -323,7 +309,7 @@ func AdminListUserSubscriptions(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "无效的用户ID")
|
common.ApiErrorMsg(c, "无效的用户ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
subs, err := model.AdminListUserSubscriptions(userId)
|
subs, err := model.GetAllUserSubscriptions(userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
@@ -381,10 +367,3 @@ func AdminDeleteUserSubscription(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
common.ApiSuccess(c, nil)
|
common.ApiSuccess(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helper: serialize provider payload safely ----
|
|
||||||
|
|
||||||
func jsonString(v any) string {
|
|
||||||
b, _ := json.Marshal(v)
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ func SubscriptionEpayNotify(c *gin.Context) {
|
|||||||
LockOrder(verifyInfo.ServiceTradeNo)
|
LockOrder(verifyInfo.ServiceTradeNo)
|
||||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||||
|
|
||||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, jsonString(verifyInfo)); err != nil {
|
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
|
||||||
// do not fail webhook response after signature verified
|
// do not fail webhook response after signature verified
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
|
|||||||
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
||||||
LockOrder(verifyInfo.ServiceTradeNo)
|
LockOrder(verifyInfo.ServiceTradeNo)
|
||||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||||
_ = model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, jsonString(verifyInfo))
|
_ = model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo))
|
||||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=success")
|
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=success")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
|||||||
// Try complete subscription order first
|
// Try complete subscription order first
|
||||||
LockOrder(referenceId)
|
LockOrder(referenceId)
|
||||||
defer UnlockOrder(referenceId)
|
defer UnlockOrder(referenceId)
|
||||||
if err := model.CompleteSubscriptionOrder(referenceId, jsonString(event)); err == nil {
|
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event)); err == nil {
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
return
|
return
|
||||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func sessionCompleted(event stripe.Event) {
|
|||||||
"currency": strings.ToUpper(event.GetObjectValue("currency")),
|
"currency": strings.ToUpper(event.GetObjectValue("currency")),
|
||||||
"event_type": string(event.Type),
|
"event_type": string(event.Type),
|
||||||
}
|
}
|
||||||
if err := model.CompleteSubscriptionOrder(referenceId, jsonString(payload)); err == nil {
|
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload)); err == nil {
|
||||||
return
|
return
|
||||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||||
log.Println("complete subscription order failed:", err.Error(), referenceId)
|
log.Println("complete subscription order failed:", err.Error(), referenceId)
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ func calcPlanEndTime(start time.Time, plan *SubscriptionPlan) (int64, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeResetPeriod(period string) string {
|
func NormalizeResetPeriod(period string) string {
|
||||||
switch strings.TrimSpace(period) {
|
switch strings.TrimSpace(period) {
|
||||||
case SubscriptionResetDaily, SubscriptionResetWeekly, SubscriptionResetMonthly, SubscriptionResetCustom:
|
case SubscriptionResetDaily, SubscriptionResetWeekly, SubscriptionResetMonthly, SubscriptionResetCustom:
|
||||||
return strings.TrimSpace(period)
|
return strings.TrimSpace(period)
|
||||||
@@ -357,7 +357,7 @@ func calcNextResetTime(base time.Time, plan *SubscriptionPlan, endUnix int64) in
|
|||||||
if plan == nil {
|
if plan == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
period := normalizeResetPeriod(plan.QuotaResetPeriod)
|
period := NormalizeResetPeriod(plan.QuotaResetPeriod)
|
||||||
if period == SubscriptionResetNever {
|
if period == SubscriptionResetNever {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -689,13 +689,6 @@ func buildSubscriptionSummaries(subs []UserSubscription) ([]SubscriptionSummary,
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Admin helpers for managing user subscriptions ----
|
|
||||||
|
|
||||||
// AdminListUserSubscriptions lists all subscriptions (including expired) for a user.
|
|
||||||
func AdminListUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
|
||||||
return GetAllUserSubscriptions(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminInvalidateUserSubscription marks a user subscription as cancelled and ends it immediately.
|
// AdminInvalidateUserSubscription marks a user subscription as cancelled and ends it immediately.
|
||||||
func AdminInvalidateUserSubscription(userSubscriptionId int) error {
|
func AdminInvalidateUserSubscription(userSubscriptionId int) error {
|
||||||
if userSubscriptionId <= 0 {
|
if userSubscriptionId <= 0 {
|
||||||
@@ -786,7 +779,7 @@ func maybeResetSubscriptionItemWithPlanTx(tx *gorm.DB, item *UserSubscriptionIte
|
|||||||
if item.NextResetTime > 0 && item.NextResetTime > now {
|
if item.NextResetTime > 0 && item.NextResetTime > now {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if normalizeResetPeriod(plan.QuotaResetPeriod) == SubscriptionResetNever {
|
if NormalizeResetPeriod(plan.QuotaResetPeriod) == SubscriptionResetNever {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
subscriptionAdminRoute.GET("/plans", controller.AdminListSubscriptionPlans)
|
subscriptionAdminRoute.GET("/plans", controller.AdminListSubscriptionPlans)
|
||||||
subscriptionAdminRoute.POST("/plans", controller.AdminCreateSubscriptionPlan)
|
subscriptionAdminRoute.POST("/plans", controller.AdminCreateSubscriptionPlan)
|
||||||
subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan)
|
subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan)
|
||||||
subscriptionAdminRoute.DELETE("/plans/:id", controller.AdminDeleteSubscriptionPlan)
|
subscriptionAdminRoute.PATCH("/plans/:id", controller.AdminUpdateSubscriptionPlanStatus)
|
||||||
subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription)
|
subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription)
|
||||||
|
|
||||||
// User subscription management (admin)
|
// User subscription management (admin)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/logger"
|
"github.com/QuantumNous/new-api/logger"
|
||||||
"github.com/QuantumNous/new-api/model"
|
"github.com/QuantumNous/new-api/model"
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
@@ -16,15 +17,6 @@ const (
|
|||||||
BillingSourceSubscription = "subscription"
|
BillingSourceSubscription = "subscription"
|
||||||
)
|
)
|
||||||
|
|
||||||
func normalizeBillingPreference(pref string) string {
|
|
||||||
switch pref {
|
|
||||||
case "subscription_first", "wallet_first", "subscription_only", "wallet_only":
|
|
||||||
return pref
|
|
||||||
default:
|
|
||||||
return "subscription_first"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreConsumeBilling decides whether to pre-consume from subscription or wallet based on user preference.
|
// PreConsumeBilling decides whether to pre-consume from subscription or wallet based on user preference.
|
||||||
// It also always pre-consumes token quota in quota units (same as legacy flow).
|
// It also always pre-consumes token quota in quota units (same as legacy flow).
|
||||||
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||||||
@@ -32,7 +24,7 @@ func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycom
|
|||||||
return types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
return types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||||
}
|
}
|
||||||
|
|
||||||
pref := normalizeBillingPreference(relayInfo.UserSetting.BillingPreference)
|
pref := common.NormalizeBillingPreference(relayInfo.UserSetting.BillingPreference)
|
||||||
trySubscription := func() *types.NewAPIError {
|
trySubscription := func() *types.NewAPIError {
|
||||||
quotaTypes := model.GetModelQuotaTypes(relayInfo.OriginModelName)
|
quotaTypes := model.GetModelQuotaTypes(relayInfo.OriginModelName)
|
||||||
quotaType := 0
|
quotaType := 0
|
||||||
|
|||||||
@@ -18,8 +18,19 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, Modal, Space, Tag } from '@douyinfe/semi-ui';
|
import {
|
||||||
import { convertUSDToCurrency } from '../../helpers/render';
|
Button,
|
||||||
|
Modal,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Popover,
|
||||||
|
Divider,
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import { IconEdit, IconStop, IconPlay } from '@douyinfe/semi-icons';
|
||||||
|
import { convertUSDToCurrency } from '../../../helpers/render';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
|
const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
|
||||||
|
|
||||||
@@ -38,33 +49,92 @@ function formatDuration(plan, t) {
|
|||||||
return `${plan.duration_value || 0}${unitMap[u] || u}`;
|
return `${plan.duration_value || 0}${unitMap[u] || u}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPlanTitle = (text, record) => {
|
function formatResetPeriod(plan, t) {
|
||||||
return (
|
const period = plan?.quota_reset_period || 'never';
|
||||||
<div>
|
if (period === 'daily') return t('每天');
|
||||||
<div className='font-medium'>{text}</div>
|
if (period === 'weekly') return t('每周');
|
||||||
{record?.plan?.subtitle ? (
|
if (period === 'monthly') return t('每月');
|
||||||
<div className='text-xs text-gray-500'>{record.plan.subtitle}</div>
|
if (period === 'custom') {
|
||||||
) : null}
|
const seconds = Number(plan?.quota_reset_custom_seconds || 0);
|
||||||
|
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
|
||||||
|
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
|
||||||
|
if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
|
||||||
|
return `${seconds} ${t('秒')}`;
|
||||||
|
}
|
||||||
|
return t('不重置');
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPlanTitle = (text, record, t) => {
|
||||||
|
const subtitle = record?.plan?.subtitle;
|
||||||
|
const plan = record?.plan;
|
||||||
|
const items = record?.items || [];
|
||||||
|
|
||||||
|
const popoverContent = (
|
||||||
|
<div style={{ width: 260 }}>
|
||||||
|
<Text strong>{text}</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text type='tertiary' style={{ display: 'block', marginTop: 4 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Divider margin={12} />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
|
<Text type='tertiary'>{t('价格')}</Text>
|
||||||
|
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
||||||
|
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
|
||||||
|
</Text>
|
||||||
|
<Text type='tertiary'>{t('有效期')}</Text>
|
||||||
|
<Text>{formatDuration(plan, t)}</Text>
|
||||||
|
<Text type='tertiary'>{t('重置')}</Text>
|
||||||
|
<Text>{formatResetPeriod(plan, t)}</Text>
|
||||||
|
<Text type='tertiary'>{t('模型')}</Text>
|
||||||
|
<Text>
|
||||||
|
{items.length} {t('个')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={popoverContent} position='rightTop' showArrow>
|
||||||
|
<div style={{ cursor: 'pointer', maxWidth: 180 }}>
|
||||||
|
<Text strong ellipsis={{ showTooltip: false }}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text
|
||||||
|
type='tertiary'
|
||||||
|
ellipsis={{ showTooltip: false }}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPrice = (text) => {
|
const renderPrice = (text) => {
|
||||||
return convertUSDToCurrency(Number(text || 0), 2);
|
return (
|
||||||
|
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
||||||
|
{convertUSDToCurrency(Number(text || 0), 2)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderDuration = (text, record, t) => {
|
const renderDuration = (text, record, t) => {
|
||||||
return formatDuration(record?.plan, t);
|
return <Text type='secondary'>{formatDuration(record?.plan, t)}</Text>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderEnabled = (text, record) => {
|
const renderEnabled = (text, record, t) => {
|
||||||
return text ? (
|
return text ? (
|
||||||
<Tag color='green' shape='circle'>
|
<Tag color='green' shape='circle'>
|
||||||
启用
|
{t('启用')}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color='grey' shape='circle'>
|
<Tag color='grey' shape='circle'>
|
||||||
禁用
|
{t('禁用')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -72,93 +142,203 @@ const renderEnabled = (text, record) => {
|
|||||||
const renderModels = (text, record, t) => {
|
const renderModels = (text, record, t) => {
|
||||||
const items = record?.items || [];
|
const items = record?.items || [];
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return <div className='text-xs text-gray-500'>{t('无模型')}</div>;
|
return <Text type='tertiary'>—</Text>;
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<div className='text-xs space-y-1'>
|
const popoverContent = (
|
||||||
{items.slice(0, 3).map((it, idx) => (
|
<div style={{ maxWidth: 320, maxHeight: 260, overflowY: 'auto' }}>
|
||||||
<div key={idx}>
|
<Text strong>
|
||||||
{it.model_name} ({quotaTypeLabel(it.quota_type)}: {it.amount_total})
|
{t('模型权益')} ({items.length})
|
||||||
</div>
|
</Text>
|
||||||
))}
|
<Divider margin={8} />
|
||||||
{items.length > 3 && (
|
<Space vertical align='start' spacing={6}>
|
||||||
<div className='text-gray-500'>
|
{items.map((it, idx) => (
|
||||||
...{t('共')} {items.length} {t('个模型')}
|
<div
|
||||||
</div>
|
key={idx}
|
||||||
)}
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth: 180 }}>
|
||||||
|
{it.model_name}
|
||||||
|
</Text>
|
||||||
|
<Space spacing={8}>
|
||||||
|
<Tag
|
||||||
|
color={it.quota_type === 1 ? 'amber' : 'teal'}
|
||||||
|
shape='circle'
|
||||||
|
>
|
||||||
|
{quotaTypeLabel(it.quota_type)}
|
||||||
|
</Tag>
|
||||||
|
<Text type='secondary'>{it.amount_total}</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={popoverContent} position='leftTop' showArrow>
|
||||||
|
<Tag color='blue' shape='circle' style={{ cursor: 'pointer' }}>
|
||||||
|
{items.length} {t('个模型')}
|
||||||
|
</Tag>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderOperations = (text, record, { openEdit, disablePlan, t }) => {
|
const renderResetPeriod = (text, record, t) => {
|
||||||
const handleDisable = () => {
|
const period = record?.plan?.quota_reset_period || 'never';
|
||||||
Modal.confirm({
|
const isNever = period === 'never';
|
||||||
title: t('确认禁用'),
|
return (
|
||||||
content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'),
|
<Text type={isNever ? 'tertiary' : 'secondary'}>
|
||||||
centered: true,
|
{formatResetPeriod(record?.plan, t)}
|
||||||
onOk: () => disablePlan(record?.plan?.id),
|
</Text>
|
||||||
});
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderPaymentConfig = (text, record, t) => {
|
||||||
|
const hasStripe = !!record?.plan?.stripe_price_id;
|
||||||
|
const hasCreem = !!record?.plan?.creem_product_id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space>
|
<Space spacing={4}>
|
||||||
<Button
|
{hasStripe && (
|
||||||
type='tertiary'
|
<Tag color='violet' shape='circle'>
|
||||||
size='small'
|
Stripe
|
||||||
onClick={() => {
|
</Tag>
|
||||||
openEdit(record);
|
)}
|
||||||
}}
|
{hasCreem && (
|
||||||
>
|
<Tag color='cyan' shape='circle'>
|
||||||
{t('编辑')}
|
Creem
|
||||||
</Button>
|
</Tag>
|
||||||
<Button type='danger' size='small' onClick={handleDisable}>
|
)}
|
||||||
{t('禁用')}
|
<Tag color='light-green' shape='circle'>
|
||||||
</Button>
|
{t('易支付')}
|
||||||
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSubscriptionsColumns = ({ t, openEdit, disablePlan }) => {
|
const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
|
||||||
|
const isEnabled = record?.plan?.enabled;
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (isEnabled) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('确认禁用'),
|
||||||
|
content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'),
|
||||||
|
centered: true,
|
||||||
|
onOk: () => setPlanEnabled(record, false),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('确认启用'),
|
||||||
|
content: t('启用后套餐将在用户端展示。是否继续?'),
|
||||||
|
centered: true,
|
||||||
|
onOk: () => setPlanEnabled(record, true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space spacing={8}>
|
||||||
|
<Button
|
||||||
|
theme='borderless'
|
||||||
|
type='tertiary'
|
||||||
|
size='small'
|
||||||
|
icon={<IconEdit />}
|
||||||
|
onClick={() => openEdit(record)}
|
||||||
|
>
|
||||||
|
{t('编辑')}
|
||||||
|
</Button>
|
||||||
|
{isEnabled ? (
|
||||||
|
<Button
|
||||||
|
theme='borderless'
|
||||||
|
type='danger'
|
||||||
|
size='small'
|
||||||
|
icon={<IconStop />}
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{t('禁用')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
theme='borderless'
|
||||||
|
type='primary'
|
||||||
|
size='small'
|
||||||
|
icon={<IconPlay />}
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{t('启用')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: 'ID',
|
title: 'ID',
|
||||||
dataIndex: ['plan', 'id'],
|
dataIndex: ['plan', 'id'],
|
||||||
width: 80,
|
width: 60,
|
||||||
|
render: (text) => <Text type='tertiary'>#{text}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('标题'),
|
title: t('套餐'),
|
||||||
dataIndex: ['plan', 'title'],
|
dataIndex: ['plan', 'title'],
|
||||||
render: (text, record) => renderPlanTitle(text, record),
|
width: 200,
|
||||||
|
render: (text, record) => renderPlanTitle(text, record, t),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('价格'),
|
title: t('价格'),
|
||||||
dataIndex: ['plan', 'price_amount'],
|
dataIndex: ['plan', 'price_amount'],
|
||||||
width: 140,
|
width: 100,
|
||||||
render: (text, record) => renderPrice(text, record),
|
render: (text) => renderPrice(text),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('优先级'),
|
||||||
|
dataIndex: ['plan', 'sort_order'],
|
||||||
|
width: 80,
|
||||||
|
render: (text) => <Text type='tertiary'>{Number(text || 0)}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('有效期'),
|
title: t('有效期'),
|
||||||
width: 140,
|
width: 80,
|
||||||
render: (text, record) => renderDuration(text, record, t),
|
render: (text, record) => renderDuration(text, record, t),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('重置'),
|
||||||
|
width: 80,
|
||||||
|
render: (text, record) => renderResetPeriod(text, record, t),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('状态'),
|
title: t('状态'),
|
||||||
dataIndex: ['plan', 'enabled'],
|
dataIndex: ['plan', 'enabled'],
|
||||||
width: 90,
|
width: 80,
|
||||||
render: (text, record) => renderEnabled(text, record),
|
render: (text, record) => renderEnabled(text, record, t),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('模型权益'),
|
title: t('支付渠道'),
|
||||||
width: 200,
|
width: 180,
|
||||||
|
render: (text, record) => renderPaymentConfig(text, record, t),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('模型'),
|
||||||
|
width: 100,
|
||||||
render: (text, record) => renderModels(text, record, t),
|
render: (text, record) => renderModels(text, record, t),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: t('操作'),
|
||||||
dataIndex: 'operate',
|
dataIndex: 'operate',
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
width: 180,
|
width: 160,
|
||||||
render: (text, record) =>
|
render: (text, record) =>
|
||||||
renderOperations(text, record, { openEdit, disablePlan, t }),
|
renderOperations(text, record, { openEdit, setPlanEnabled, t }),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,16 +27,16 @@ import {
|
|||||||
import { getSubscriptionsColumns } from './SubscriptionsColumnDefs';
|
import { getSubscriptionsColumns } from './SubscriptionsColumnDefs';
|
||||||
|
|
||||||
const SubscriptionsTable = (subscriptionsData) => {
|
const SubscriptionsTable = (subscriptionsData) => {
|
||||||
const { plans, loading, compactMode, openEdit, disablePlan, t } =
|
const { plans, loading, compactMode, openEdit, setPlanEnabled, t } =
|
||||||
subscriptionsData;
|
subscriptionsData;
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return getSubscriptionsColumns({
|
return getSubscriptionsColumns({
|
||||||
t,
|
t,
|
||||||
openEdit,
|
openEdit,
|
||||||
disablePlan,
|
setPlanEnabled,
|
||||||
});
|
});
|
||||||
}, [t, openEdit, disablePlan]);
|
}, [t, openEdit, setPlanEnabled]);
|
||||||
|
|
||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
return compactMode
|
return compactMode
|
||||||
|
|||||||
@@ -88,14 +88,20 @@ export const useSubscriptionsData = () => {
|
|||||||
setActivePage(1);
|
setActivePage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Disable plan
|
// Update plan enabled status (single endpoint)
|
||||||
const disablePlan = async (planId) => {
|
const setPlanEnabled = async (planRecordOrId, enabled) => {
|
||||||
|
const planId =
|
||||||
|
typeof planRecordOrId === 'number'
|
||||||
|
? planRecordOrId
|
||||||
|
: planRecordOrId?.plan?.id;
|
||||||
if (!planId) return;
|
if (!planId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await API.delete(`/api/subscription/admin/plans/${planId}`);
|
const res = await API.patch(`/api/subscription/admin/plans/${planId}`, {
|
||||||
|
enabled: !!enabled,
|
||||||
|
});
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
showSuccess(t('已禁用'));
|
showSuccess(enabled ? t('已启用') : t('已禁用'));
|
||||||
await loadPlans();
|
await loadPlans();
|
||||||
} else {
|
} else {
|
||||||
showError(res.data?.message || t('操作失败'));
|
showError(res.data?.message || t('操作失败'));
|
||||||
@@ -163,7 +169,7 @@ export const useSubscriptionsData = () => {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
loadPlans,
|
loadPlans,
|
||||||
disablePlan,
|
setPlanEnabled,
|
||||||
refresh,
|
refresh,
|
||||||
closeEdit,
|
closeEdit,
|
||||||
openCreate,
|
openCreate,
|
||||||
|
|||||||
Reference in New Issue
Block a user