🔧 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:
t0ng7u
2026-01-31 14:27:01 +08:00
parent 28c5feb570
commit 2297af731c
11 changed files with 293 additions and 133 deletions

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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
} }

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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
} }

View File

@@ -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)

View File

@@ -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

View File

@@ -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 }),
}, },
]; ];
}; };

View File

@@ -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

View File

@@ -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,