mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 11:38:38 +00:00
✨ feat: Add subscription limits and UI tags consistency
Add per-plan purchase limits with backend enforcement and UI disable states. Expose limit configuration in admin plan editor and show limits in plan tables/cards. Refine subscription UI tags with unified badge style and streamlined “My Subscriptions” layout.
This commit is contained in:
@@ -134,6 +134,10 @@ 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
|
||||||
}
|
}
|
||||||
|
if req.Plan.MaxPurchasePerUser < 0 {
|
||||||
|
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||||
|
return
|
||||||
|
}
|
||||||
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(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秒")
|
||||||
@@ -201,6 +205,10 @@ 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
|
||||||
}
|
}
|
||||||
|
if req.Plan.MaxPurchasePerUser < 0 {
|
||||||
|
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||||
|
return
|
||||||
|
}
|
||||||
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(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秒")
|
||||||
@@ -215,18 +223,19 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
|||||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
// update plan (allow zero values updates with map)
|
// update plan (allow zero values updates with map)
|
||||||
updateMap := map[string]interface{}{
|
updateMap := map[string]interface{}{
|
||||||
"title": req.Plan.Title,
|
"title": req.Plan.Title,
|
||||||
"subtitle": req.Plan.Subtitle,
|
"subtitle": req.Plan.Subtitle,
|
||||||
"price_amount": req.Plan.PriceAmount,
|
"price_amount": req.Plan.PriceAmount,
|
||||||
"currency": req.Plan.Currency,
|
"currency": req.Plan.Currency,
|
||||||
"duration_unit": req.Plan.DurationUnit,
|
"duration_unit": req.Plan.DurationUnit,
|
||||||
"duration_value": req.Plan.DurationValue,
|
"duration_value": req.Plan.DurationValue,
|
||||||
"custom_seconds": req.Plan.CustomSeconds,
|
"custom_seconds": req.Plan.CustomSeconds,
|
||||||
"enabled": req.Plan.Enabled,
|
"enabled": req.Plan.Enabled,
|
||||||
"sort_order": req.Plan.SortOrder,
|
"sort_order": req.Plan.SortOrder,
|
||||||
"stripe_price_id": req.Plan.StripePriceId,
|
"stripe_price_id": req.Plan.StripePriceId,
|
||||||
"creem_product_id": req.Plan.CreemProductId,
|
"creem_product_id": req.Plan.CreemProductId,
|
||||||
"updated_at": common.GetTimestamp(),
|
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
||||||
|
"updated_at": common.GetTimestamp(),
|
||||||
}
|
}
|
||||||
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
|||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
user, _ := model.GetUserById(userId, false)
|
user, _ := model.GetUserById(userId, false)
|
||||||
|
|
||||||
|
if plan.MaxPurchasePerUser > 0 {
|
||||||
|
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count >= int64(plan.MaxPurchasePerUser) {
|
||||||
|
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reference := "sub-creem-ref-" + randstr.String(6)
|
reference := "sub-creem-ref-" + randstr.String(6)
|
||||||
referenceId := "sub_ref_" + common.Sha1([]byte(reference+time.Now().String()+user.Username))
|
referenceId := "sub_ref_" + common.Sha1([]byte(reference+time.Now().String()+user.Username))
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
|
if plan.MaxPurchasePerUser > 0 {
|
||||||
|
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count >= int64(plan.MaxPurchasePerUser) {
|
||||||
|
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
callBackAddress := service.GetCallbackAddress()
|
callBackAddress := service.GetCallbackAddress()
|
||||||
returnUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/return")
|
returnUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/return")
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
|||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
user, _ := model.GetUserById(userId, false)
|
user, _ := model.GetUserById(userId, false)
|
||||||
|
|
||||||
|
if plan.MaxPurchasePerUser > 0 {
|
||||||
|
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count >= int64(plan.MaxPurchasePerUser) {
|
||||||
|
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reference := fmt.Sprintf("sub-stripe-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
reference := fmt.Sprintf("sub-stripe-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||||
referenceId := "sub_ref_" + common.Sha1([]byte(reference))
|
referenceId := "sub_ref_" + common.Sha1([]byte(reference))
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,9 @@ type SubscriptionPlan struct {
|
|||||||
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
|
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
|
||||||
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
|
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
|
||||||
|
|
||||||
|
// Max purchases per user (0 = unlimited)
|
||||||
|
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`
|
||||||
|
|
||||||
// Quota reset period for plan items
|
// Quota reset period for plan items
|
||||||
QuotaResetPeriod string `json:"quota_reset_period" gorm:"type:varchar(16);default:'never'"`
|
QuotaResetPeriod string `json:"quota_reset_period" gorm:"type:varchar(16);default:'never'"`
|
||||||
QuotaResetCustomSeconds int64 `json:"quota_reset_custom_seconds" gorm:"type:bigint;default:0"`
|
QuotaResetCustomSeconds int64 `json:"quota_reset_custom_seconds" gorm:"type:bigint;default:0"`
|
||||||
@@ -438,6 +441,19 @@ func GetSubscriptionPlanItems(planId int) ([]SubscriptionPlanItem, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CountUserSubscriptionsByPlan(userId int, planId int) (int64, error) {
|
||||||
|
if userId <= 0 || planId <= 0 {
|
||||||
|
return 0, errors.New("invalid userId or planId")
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := DB.Model(&UserSubscription{}).
|
||||||
|
Where("user_id = ? AND plan_id = ?", userId, planId).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, source string) (*UserSubscription, error) {
|
func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, source string) (*UserSubscription, error) {
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
return nil, errors.New("tx is nil")
|
return nil, errors.New("tx is nil")
|
||||||
@@ -448,6 +464,17 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
|
|||||||
if userId <= 0 {
|
if userId <= 0 {
|
||||||
return nil, errors.New("invalid user id")
|
return nil, errors.New("invalid user id")
|
||||||
}
|
}
|
||||||
|
if plan.MaxPurchasePerUser > 0 {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&UserSubscription{}).
|
||||||
|
Where("user_id = ? AND plan_id = ?", userId, plan.Id).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count >= int64(plan.MaxPurchasePerUser) {
|
||||||
|
return nil, errors.New("已达到该套餐购买上限")
|
||||||
|
}
|
||||||
|
}
|
||||||
nowUnix := GetDBTimestamp()
|
nowUnix := GetDBTimestamp()
|
||||||
now := time.Unix(nowUnix, 0)
|
now := time.Unix(nowUnix, 0)
|
||||||
endUnix, err := calcPlanEndTime(now, plan)
|
endUnix, err := calcPlanEndTime(now, plan)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Popover,
|
Popover,
|
||||||
Divider,
|
Divider,
|
||||||
|
Badge,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { IconEdit, IconStop, IconPlay } from '@douyinfe/semi-icons';
|
import { IconEdit, IconStop, IconPlay } from '@douyinfe/semi-icons';
|
||||||
import { convertUSDToCurrency } from '../../../helpers/render';
|
import { convertUSDToCurrency } from '../../../helpers/render';
|
||||||
@@ -83,6 +84,12 @@ const renderPlanTitle = (text, record, t) => {
|
|||||||
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
||||||
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
|
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text type='tertiary'>{t('购买上限')}</Text>
|
||||||
|
<Text>
|
||||||
|
{plan?.max_purchase_per_user > 0
|
||||||
|
? plan.max_purchase_per_user
|
||||||
|
: t('不限')}
|
||||||
|
</Text>
|
||||||
<Text type='tertiary'>{t('有效期')}</Text>
|
<Text type='tertiary'>{t('有效期')}</Text>
|
||||||
<Text>{formatDuration(plan, t)}</Text>
|
<Text>{formatDuration(plan, t)}</Text>
|
||||||
<Text type='tertiary'>{t('重置')}</Text>
|
<Text type='tertiary'>{t('重置')}</Text>
|
||||||
@@ -123,17 +130,36 @@ const renderPrice = (text) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderPurchaseLimit = (text, record, t) => {
|
||||||
|
const limit = Number(record?.plan?.max_purchase_per_user || 0);
|
||||||
|
return (
|
||||||
|
<Text type={limit > 0 ? 'secondary' : 'tertiary'}>
|
||||||
|
{limit > 0 ? limit : t('不限')}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderDuration = (text, record, t) => {
|
const renderDuration = (text, record, t) => {
|
||||||
return <Text type='secondary'>{formatDuration(record?.plan, t)}</Text>;
|
return <Text type='secondary'>{formatDuration(record?.plan, t)}</Text>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderEnabled = (text, record, t) => {
|
const renderEnabled = (text, record, t) => {
|
||||||
return text ? (
|
return text ? (
|
||||||
<Tag color='green' shape='circle'>
|
<Tag
|
||||||
|
color='white'
|
||||||
|
shape='circle'
|
||||||
|
type='light'
|
||||||
|
prefixIcon={<Badge dot type='success' />}
|
||||||
|
>
|
||||||
{t('启用')}
|
{t('启用')}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color='grey' shape='circle'>
|
<Tag
|
||||||
|
color='white'
|
||||||
|
shape='circle'
|
||||||
|
type='light'
|
||||||
|
prefixIcon={<Badge dot type='danger' />}
|
||||||
|
>
|
||||||
{t('禁用')}
|
{t('禁用')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
@@ -300,6 +326,11 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
|
|||||||
width: 100,
|
width: 100,
|
||||||
render: (text) => renderPrice(text),
|
render: (text) => renderPrice(text),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('购买上限'),
|
||||||
|
width: 90,
|
||||||
|
render: (text, record) => renderPurchaseLimit(text, record, t),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('优先级'),
|
title: t('优先级'),
|
||||||
dataIndex: ['plan', 'sort_order'],
|
dataIndex: ['plan', 'sort_order'],
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
quota_reset_custom_seconds: 0,
|
quota_reset_custom_seconds: 0,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
|
max_purchase_per_user: 0,
|
||||||
stripe_price_id: '',
|
stripe_price_id: '',
|
||||||
creem_product_id: '',
|
creem_product_id: '',
|
||||||
});
|
});
|
||||||
@@ -122,6 +123,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0),
|
quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0),
|
||||||
enabled: p.enabled !== false,
|
enabled: p.enabled !== false,
|
||||||
sort_order: Number(p.sort_order || 0),
|
sort_order: Number(p.sort_order || 0),
|
||||||
|
max_purchase_per_user: Number(p.max_purchase_per_user || 0),
|
||||||
stripe_price_id: p.stripe_price_id || '',
|
stripe_price_id: p.stripe_price_id || '',
|
||||||
creem_product_id: p.creem_product_id || '',
|
creem_product_id: p.creem_product_id || '',
|
||||||
};
|
};
|
||||||
@@ -283,6 +285,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
? Number(values.quota_reset_custom_seconds || 0)
|
? Number(values.quota_reset_custom_seconds || 0)
|
||||||
: 0,
|
: 0,
|
||||||
sort_order: Number(values.sort_order || 0),
|
sort_order: Number(values.sort_order || 0),
|
||||||
|
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||||
},
|
},
|
||||||
items: cleanedItems,
|
items: cleanedItems,
|
||||||
};
|
};
|
||||||
@@ -485,6 +488,17 @@ const AddEditSubscriptionModal = ({
|
|||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.InputNumber
|
||||||
|
field='max_purchase_per_user'
|
||||||
|
label={t('购买上限')}
|
||||||
|
min={0}
|
||||||
|
precision={0}
|
||||||
|
extraText={t('0 表示不限')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Switch
|
<Form.Switch
|
||||||
field='enabled'
|
field='enabled'
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -27,10 +28,11 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
Space,
|
Space,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { API, showError, showSuccess } from '../../helpers';
|
import { API, showError, showSuccess } from '../../helpers';
|
||||||
import { getCurrencyConfig } from '../../helpers/render';
|
import { getCurrencyConfig, stringToColor } from '../../helpers/render';
|
||||||
import { CalendarClock, Check, Crown, RefreshCw, Sparkles } from 'lucide-react';
|
import { CalendarClock, Check, Crown, RefreshCw, Sparkles } from 'lucide-react';
|
||||||
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
||||||
|
|
||||||
@@ -220,6 +222,19 @@ const SubscriptionPlansCard = ({
|
|||||||
const hasActiveSubscription = activeSubscriptions.length > 0;
|
const hasActiveSubscription = activeSubscriptions.length > 0;
|
||||||
const hasAnySubscription = allSubscriptions.length > 0;
|
const hasAnySubscription = allSubscriptions.length > 0;
|
||||||
|
|
||||||
|
const planPurchaseCountMap = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
(allSubscriptions || []).forEach((sub) => {
|
||||||
|
const planId = sub?.subscription?.plan_id;
|
||||||
|
if (!planId) return;
|
||||||
|
map.set(planId, (map.get(planId) || 0) + 1);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allSubscriptions]);
|
||||||
|
|
||||||
|
const getPlanPurchaseCount = (planId) =>
|
||||||
|
planPurchaseCountMap.get(planId) || 0;
|
||||||
|
|
||||||
// 计算单个订阅的剩余天数
|
// 计算单个订阅的剩余天数
|
||||||
const getRemainingDays = (sub) => {
|
const getRemainingDays = (sub) => {
|
||||||
if (!sub?.subscription?.end_time) return 0;
|
if (!sub?.subscription?.end_time) return 0;
|
||||||
@@ -318,16 +333,21 @@ const SubscriptionPlansCard = ({
|
|||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Text strong>{t('我的订阅')}</Text>
|
<Text strong>{t('我的订阅')}</Text>
|
||||||
{hasActiveSubscription ? (
|
{hasActiveSubscription ? (
|
||||||
<Tag color='green' size='small' shape='circle'>
|
<Tag
|
||||||
|
color='white'
|
||||||
|
size='small'
|
||||||
|
shape='circle'
|
||||||
|
prefixIcon={<Badge dot type='success' />}
|
||||||
|
>
|
||||||
{activeSubscriptions.length} {t('个生效中')}
|
{activeSubscriptions.length} {t('个生效中')}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color='grey' size='small' shape='circle'>
|
<Tag color='white' size='small' shape='circle'>
|
||||||
{t('无生效')}
|
{t('无生效')}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
{allSubscriptions.length > activeSubscriptions.length && (
|
{allSubscriptions.length > activeSubscriptions.length && (
|
||||||
<Tag color='grey' size='small' shape='circle' type='light'>
|
<Tag color='white' size='small' shape='circle'>
|
||||||
{allSubscriptions.length - activeSubscriptions.length}{' '}
|
{allSubscriptions.length - activeSubscriptions.length}{' '}
|
||||||
{t('个已过期')}
|
{t('个已过期')}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -348,97 +368,90 @@ const SubscriptionPlansCard = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasAnySubscription ? (
|
{hasAnySubscription ? (
|
||||||
<div className='space-y-3 max-h-64 overflow-y-auto'>
|
<>
|
||||||
{allSubscriptions.map((sub, subIndex) => {
|
<Divider margin={8} />
|
||||||
const subscription = sub.subscription;
|
<div className='max-h-64 overflow-y-auto pr-1 semi-table-body'>
|
||||||
const items = sub.items || [];
|
{allSubscriptions.map((sub, subIndex) => {
|
||||||
const remainDays = getRemainingDays(sub);
|
const isLast = subIndex === allSubscriptions.length - 1;
|
||||||
const usagePercent = getUsagePercent(sub);
|
const subscription = sub.subscription;
|
||||||
const now = Date.now() / 1000;
|
const items = sub.items || [];
|
||||||
const isExpired = (subscription?.end_time || 0) < now;
|
const remainDays = getRemainingDays(sub);
|
||||||
const isActive =
|
const usagePercent = getUsagePercent(sub);
|
||||||
subscription?.status === 'active' && !isExpired;
|
const now = Date.now() / 1000;
|
||||||
|
const isExpired = (subscription?.end_time || 0) < now;
|
||||||
|
const isActive =
|
||||||
|
subscription?.status === 'active' && !isExpired;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={subscription?.id || subIndex}>
|
||||||
key={subscription?.id || subIndex}
|
{/* 订阅概要 */}
|
||||||
className={`p-2 rounded-lg ${isActive ? 'bg-green-50' : 'bg-gray-100 opacity-70'}`}
|
<div className='flex items-center justify-between text-xs mb-2'>
|
||||||
>
|
<div className='flex items-center gap-2'>
|
||||||
{/* 订阅概要 */}
|
<span className='font-medium'>
|
||||||
<div className='flex items-center justify-between text-xs mb-2'>
|
{t('订阅')} #{subscription?.id}
|
||||||
<div className='flex items-center gap-2'>
|
</span>
|
||||||
<span className='font-medium'>
|
{isActive ? (
|
||||||
{t('订阅')} #{subscription?.id}
|
|
||||||
</span>
|
|
||||||
{isActive ? (
|
|
||||||
<Tag color='green' size='small' shape='circle'>
|
|
||||||
{t('生效')}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color='grey' size='small' shape='circle'>
|
|
||||||
{t('已过期')}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isActive && (
|
|
||||||
<span className='text-gray-500'>
|
|
||||||
{t('剩余')} {remainDays} {t('天')} · {t('已用')}{' '}
|
|
||||||
{usagePercent}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className='text-xs text-gray-500 mb-2'>
|
|
||||||
{isActive ? t('至') : t('过期于')}{' '}
|
|
||||||
{new Date(
|
|
||||||
(subscription?.end_time || 0) * 1000,
|
|
||||||
).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
{/* 权益列表 */}
|
|
||||||
{items.length > 0 && (
|
|
||||||
<div className='flex flex-wrap gap-1'>
|
|
||||||
{items.slice(0, 4).map((it) => {
|
|
||||||
const used = Number(it.amount_used || 0);
|
|
||||||
const total = Number(it.amount_total || 0);
|
|
||||||
const remain = total - used;
|
|
||||||
const percent =
|
|
||||||
total > 0 ? Math.round((used / total) * 100) : 0;
|
|
||||||
const label = it.quota_type === 1 ? t('次') : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tag
|
<Tag
|
||||||
key={`${it.id}-${it.model_name}`}
|
color='white'
|
||||||
size='small'
|
size='small'
|
||||||
color={
|
|
||||||
isActive
|
|
||||||
? percent > 80
|
|
||||||
? 'red'
|
|
||||||
: 'blue'
|
|
||||||
: 'grey'
|
|
||||||
}
|
|
||||||
type='light'
|
|
||||||
shape='circle'
|
shape='circle'
|
||||||
|
prefixIcon={<Badge dot type='success' />}
|
||||||
>
|
>
|
||||||
{it.model_name}: {remain}
|
{t('生效')}
|
||||||
{label}
|
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
) : (
|
||||||
})}
|
<Tag color='white' size='small' shape='circle'>
|
||||||
{items.length > 4 && (
|
{t('已过期')}
|
||||||
<Tag
|
</Tag>
|
||||||
size='small'
|
)}
|
||||||
color='grey'
|
</div>
|
||||||
type='light'
|
{isActive && (
|
||||||
shape='circle'
|
<span className='text-gray-500'>
|
||||||
>
|
{t('剩余')} {remainDays} {t('天')} · {t('已用')}{' '}
|
||||||
+{items.length - 4}
|
{usagePercent}%
|
||||||
</Tag>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className='text-xs text-gray-500 mb-2'>
|
||||||
</div>
|
{isActive ? t('至') : t('过期于')}{' '}
|
||||||
);
|
{new Date(
|
||||||
})}
|
(subscription?.end_time || 0) * 1000,
|
||||||
</div>
|
).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{/* 权益列表 */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className='flex flex-wrap gap-1'>
|
||||||
|
{items.slice(0, 4).map((it) => {
|
||||||
|
const used = Number(it.amount_used || 0);
|
||||||
|
const total = Number(it.amount_total || 0);
|
||||||
|
const remain = total - used;
|
||||||
|
const label = it.quota_type === 1 ? t('次') : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={`${it.id}-${it.model_name}`}
|
||||||
|
size='small'
|
||||||
|
color='white'
|
||||||
|
shape='circle'
|
||||||
|
>
|
||||||
|
{it.model_name}: {remain}
|
||||||
|
{label}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{items.length > 4 && (
|
||||||
|
<Tag size='small' color='white' shape='circle'>
|
||||||
|
+{items.length - 4}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLast && <Divider margin={12} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className='text-xs text-gray-500'>
|
<div className='text-xs text-gray-500'>
|
||||||
{t('购买套餐后即可享受模型权益')}
|
{t('购买套餐后即可享受模型权益')}
|
||||||
@@ -458,6 +471,15 @@ const SubscriptionPlansCard = ({
|
|||||||
price % 1 === 0 ? 0 : 2,
|
price % 1 === 0 ? 0 : 2,
|
||||||
);
|
);
|
||||||
const isPopular = index === 0 && plans.length > 1;
|
const isPopular = index === 0 && plans.length > 1;
|
||||||
|
const limit = Number(plan?.max_purchase_per_user || 0);
|
||||||
|
const limitLabel =
|
||||||
|
limit > 0 ? `${t('限购')} ${limit}` : t('不限购');
|
||||||
|
const planTags = [
|
||||||
|
`${t('有效期')}: ${formatDuration(plan, t)}`,
|
||||||
|
`${t('重置')}: ${formatResetPeriod(plan, t)}`,
|
||||||
|
`${t('权益')}: ${planItems.length} ${t('项')}`,
|
||||||
|
limitLabel,
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -508,13 +530,20 @@ const SubscriptionPlansCard = ({
|
|||||||
{displayPrice}
|
{displayPrice}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm text-gray-500 mt-1'>
|
</div>
|
||||||
<CalendarClock size={12} className='inline mr-1' />
|
|
||||||
{formatDuration(plan, t)}
|
{/* 属性标签 */}
|
||||||
<span className='ml-2 text-xs text-gray-400'>
|
<div className='flex flex-wrap justify-center gap-2 pb-2'>
|
||||||
{t('重置')}: {formatResetPeriod(plan, t)}
|
{planTags.map((tag) => (
|
||||||
</span>
|
<Tag
|
||||||
</div>
|
key={tag}
|
||||||
|
size='small'
|
||||||
|
shape='circle'
|
||||||
|
color='white'
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider margin={12} />
|
<Divider margin={12} />
|
||||||
@@ -530,12 +559,7 @@ const SubscriptionPlansCard = ({
|
|||||||
<span className='truncate flex-1'>
|
<span className='truncate flex-1'>
|
||||||
{it.model_name}
|
{it.model_name}
|
||||||
</span>
|
</span>
|
||||||
<Tag
|
<Tag size='small' color='white' shape='circle'>
|
||||||
size='small'
|
|
||||||
color='blue'
|
|
||||||
shape='circle'
|
|
||||||
type='light'
|
|
||||||
>
|
|
||||||
{it.amount_total}
|
{it.amount_total}
|
||||||
{it.quota_type === 1 ? t('次') : ''}
|
{it.quota_type === 1 ? t('次') : ''}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -554,17 +578,33 @@ const SubscriptionPlansCard = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 购买按钮 */}
|
{/* 购买按钮 */}
|
||||||
<Button
|
{(() => {
|
||||||
theme='solid'
|
const count = getPlanPurchaseCount(p?.plan?.id);
|
||||||
type='primary'
|
const reached = limit > 0 && count >= limit;
|
||||||
block
|
const tip = reached
|
||||||
onClick={() => openBuy(p)}
|
? t('已达到购买上限') + ` (${count}/${limit})`
|
||||||
className={
|
: '';
|
||||||
isPopular ? '!bg-purple-600 hover:!bg-purple-700' : ''
|
const buttonEl = (
|
||||||
}
|
<Button
|
||||||
>
|
theme='outline'
|
||||||
{t('立即订阅')}
|
type='tertiary'
|
||||||
</Button>
|
block
|
||||||
|
disabled={reached}
|
||||||
|
onClick={() => {
|
||||||
|
if (!reached) openBuy(p);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reached ? t('已达上限') : t('立即订阅')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
return reached ? (
|
||||||
|
<Tooltip content={tip} position='top'>
|
||||||
|
{buttonEl}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
buttonEl
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -591,6 +631,14 @@ const SubscriptionPlansCard = ({
|
|||||||
enableOnlineTopUp={enableOnlineTopUp}
|
enableOnlineTopUp={enableOnlineTopUp}
|
||||||
enableStripeTopUp={enableStripeTopUp}
|
enableStripeTopUp={enableStripeTopUp}
|
||||||
enableCreemTopUp={enableCreemTopUp}
|
enableCreemTopUp={enableCreemTopUp}
|
||||||
|
purchaseLimitInfo={
|
||||||
|
selectedPlan?.plan?.id
|
||||||
|
? {
|
||||||
|
limit: Number(selectedPlan?.plan?.max_purchase_per_user || 0),
|
||||||
|
count: getPlanPurchaseCount(selectedPlan?.plan?.id),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
onPayStripe={payStripe}
|
onPayStripe={payStripe}
|
||||||
onPayCreem={payCreem}
|
onPayCreem={payCreem}
|
||||||
onPayEpay={payEpay}
|
onPayEpay={payEpay}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ const SubscriptionPurchaseModal = ({
|
|||||||
enableOnlineTopUp = false,
|
enableOnlineTopUp = false,
|
||||||
enableStripeTopUp = false,
|
enableStripeTopUp = false,
|
||||||
enableCreemTopUp = false,
|
enableCreemTopUp = false,
|
||||||
|
purchaseLimitInfo = null,
|
||||||
onPayStripe,
|
onPayStripe,
|
||||||
onPayCreem,
|
onPayCreem,
|
||||||
onPayEpay,
|
onPayEpay,
|
||||||
@@ -97,6 +98,10 @@ const SubscriptionPurchaseModal = ({
|
|||||||
const hasCreem = enableCreemTopUp && !!plan?.creem_product_id;
|
const hasCreem = enableCreemTopUp && !!plan?.creem_product_id;
|
||||||
const hasEpay = enableOnlineTopUp && epayMethods.length > 0;
|
const hasEpay = enableOnlineTopUp && epayMethods.length > 0;
|
||||||
const hasAnyPayment = hasStripe || hasCreem || hasEpay;
|
const hasAnyPayment = hasStripe || hasCreem || hasEpay;
|
||||||
|
const purchaseLimit = Number(purchaseLimitInfo?.limit || 0);
|
||||||
|
const purchaseCount = Number(purchaseLimitInfo?.count || 0);
|
||||||
|
const purchaseLimitReached =
|
||||||
|
purchaseLimit > 0 && purchaseCount >= purchaseLimit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -203,6 +208,15 @@ const SubscriptionPurchaseModal = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 支付方式 */}
|
{/* 支付方式 */}
|
||||||
|
{purchaseLimitReached && (
|
||||||
|
<Banner
|
||||||
|
type='warning'
|
||||||
|
description={`${t('已达到购买上限')} (${purchaseCount}/${purchaseLimit})`}
|
||||||
|
className='!rounded-xl'
|
||||||
|
closeIcon={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasAnyPayment ? (
|
{hasAnyPayment ? (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<Text size='small' type='tertiary'>
|
<Text size='small' type='tertiary'>
|
||||||
@@ -219,6 +233,7 @@ const SubscriptionPurchaseModal = ({
|
|||||||
icon={<SiStripe size={14} color='#635BFF' />}
|
icon={<SiStripe size={14} color='#635BFF' />}
|
||||||
onClick={onPayStripe}
|
onClick={onPayStripe}
|
||||||
loading={paying}
|
loading={paying}
|
||||||
|
disabled={purchaseLimitReached}
|
||||||
>
|
>
|
||||||
Stripe
|
Stripe
|
||||||
</Button>
|
</Button>
|
||||||
@@ -230,6 +245,7 @@ const SubscriptionPurchaseModal = ({
|
|||||||
icon={<IconCreditCard />}
|
icon={<IconCreditCard />}
|
||||||
onClick={onPayCreem}
|
onClick={onPayCreem}
|
||||||
loading={paying}
|
loading={paying}
|
||||||
|
disabled={purchaseLimitReached}
|
||||||
>
|
>
|
||||||
Creem
|
Creem
|
||||||
</Button>
|
</Button>
|
||||||
@@ -250,13 +266,14 @@ const SubscriptionPurchaseModal = ({
|
|||||||
value: m.type,
|
value: m.type,
|
||||||
label: m.name || m.type,
|
label: m.name || m.type,
|
||||||
}))}
|
}))}
|
||||||
|
disabled={purchaseLimitReached}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
theme='solid'
|
theme='solid'
|
||||||
type='primary'
|
type='primary'
|
||||||
onClick={onPayEpay}
|
onClick={onPayEpay}
|
||||||
loading={paying}
|
loading={paying}
|
||||||
disabled={!selectedEpayMethod}
|
disabled={!selectedEpayMethod || purchaseLimitReached}
|
||||||
>
|
>
|
||||||
{t('支付')}
|
{t('支付')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user