mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 08:16:43 +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 {
|
||||
req.Plan.DurationValue = 1
|
||||
}
|
||||
if req.Plan.MaxPurchasePerUser < 0 {
|
||||
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秒")
|
||||
@@ -201,6 +205,10 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||
req.Plan.DurationValue = 1
|
||||
}
|
||||
if req.Plan.MaxPurchasePerUser < 0 {
|
||||
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秒")
|
||||
@@ -215,18 +223,19 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// update plan (allow zero values updates with map)
|
||||
updateMap := map[string]interface{}{
|
||||
"title": req.Plan.Title,
|
||||
"subtitle": req.Plan.Subtitle,
|
||||
"price_amount": req.Plan.PriceAmount,
|
||||
"currency": req.Plan.Currency,
|
||||
"duration_unit": req.Plan.DurationUnit,
|
||||
"duration_value": req.Plan.DurationValue,
|
||||
"custom_seconds": req.Plan.CustomSeconds,
|
||||
"enabled": req.Plan.Enabled,
|
||||
"sort_order": req.Plan.SortOrder,
|
||||
"stripe_price_id": req.Plan.StripePriceId,
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"updated_at": common.GetTimestamp(),
|
||||
"title": req.Plan.Title,
|
||||
"subtitle": req.Plan.Subtitle,
|
||||
"price_amount": req.Plan.PriceAmount,
|
||||
"currency": req.Plan.Currency,
|
||||
"duration_unit": req.Plan.DurationUnit,
|
||||
"duration_value": req.Plan.DurationValue,
|
||||
"custom_seconds": req.Plan.CustomSeconds,
|
||||
"enabled": req.Plan.Enabled,
|
||||
"sort_order": req.Plan.SortOrder,
|
||||
"stripe_price_id": req.Plan.StripePriceId,
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"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 {
|
||||
return err
|
||||
|
||||
@@ -56,6 +56,18 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
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)
|
||||
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")
|
||||
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()
|
||||
returnUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/return")
|
||||
|
||||
@@ -53,6 +53,18 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
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))
|
||||
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:''"`
|
||||
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
|
||||
QuotaResetPeriod string `json:"quota_reset_period" gorm:"type:varchar(16);default:'never'"`
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("tx is nil")
|
||||
@@ -448,6 +464,17 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
|
||||
if userId <= 0 {
|
||||
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()
|
||||
now := time.Unix(nowUnix, 0)
|
||||
endUnix, err := calcPlanEndTime(now, plan)
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Typography,
|
||||
Popover,
|
||||
Divider,
|
||||
Badge,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconEdit, IconStop, IconPlay } from '@douyinfe/semi-icons';
|
||||
import { convertUSDToCurrency } from '../../../helpers/render';
|
||||
@@ -83,6 +84,12 @@ const renderPlanTitle = (text, record, t) => {
|
||||
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
||||
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
|
||||
</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>{formatDuration(plan, 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) => {
|
||||
return <Text type='secondary'>{formatDuration(record?.plan, t)}</Text>;
|
||||
};
|
||||
|
||||
const renderEnabled = (text, record, t) => {
|
||||
return text ? (
|
||||
<Tag color='green' shape='circle'>
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
type='light'
|
||||
prefixIcon={<Badge dot type='success' />}
|
||||
>
|
||||
{t('启用')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='grey' shape='circle'>
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
type='light'
|
||||
prefixIcon={<Badge dot type='danger' />}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -300,6 +326,11 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
|
||||
width: 100,
|
||||
render: (text) => renderPrice(text),
|
||||
},
|
||||
{
|
||||
title: t('购买上限'),
|
||||
width: 90,
|
||||
render: (text, record) => renderPurchaseLimit(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('优先级'),
|
||||
dataIndex: ['plan', 'sort_order'],
|
||||
|
||||
@@ -94,6 +94,7 @@ const AddEditSubscriptionModal = ({
|
||||
quota_reset_custom_seconds: 0,
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
max_purchase_per_user: 0,
|
||||
stripe_price_id: '',
|
||||
creem_product_id: '',
|
||||
});
|
||||
@@ -122,6 +123,7 @@ const AddEditSubscriptionModal = ({
|
||||
quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0),
|
||||
enabled: p.enabled !== false,
|
||||
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 || '',
|
||||
creem_product_id: p.creem_product_id || '',
|
||||
};
|
||||
@@ -283,6 +285,7 @@ const AddEditSubscriptionModal = ({
|
||||
? Number(values.quota_reset_custom_seconds || 0)
|
||||
: 0,
|
||||
sort_order: Number(values.sort_order || 0),
|
||||
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||
},
|
||||
items: cleanedItems,
|
||||
};
|
||||
@@ -485,6 +488,17 @@ const AddEditSubscriptionModal = ({
|
||||
/>
|
||||
</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}>
|
||||
<Form.Switch
|
||||
field='enabled'
|
||||
|
||||
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
@@ -27,10 +28,11 @@ import {
|
||||
Skeleton,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
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 SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
||||
|
||||
@@ -220,6 +222,19 @@ const SubscriptionPlansCard = ({
|
||||
const hasActiveSubscription = activeSubscriptions.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) => {
|
||||
if (!sub?.subscription?.end_time) return 0;
|
||||
@@ -318,16 +333,21 @@ const SubscriptionPlansCard = ({
|
||||
<div className='flex items-center gap-2'>
|
||||
<Text strong>{t('我的订阅')}</Text>
|
||||
{hasActiveSubscription ? (
|
||||
<Tag color='green' size='small' shape='circle'>
|
||||
<Tag
|
||||
color='white'
|
||||
size='small'
|
||||
shape='circle'
|
||||
prefixIcon={<Badge dot type='success' />}
|
||||
>
|
||||
{activeSubscriptions.length} {t('个生效中')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='grey' size='small' shape='circle'>
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{t('无生效')}
|
||||
</Tag>
|
||||
)}
|
||||
{allSubscriptions.length > activeSubscriptions.length && (
|
||||
<Tag color='grey' size='small' shape='circle' type='light'>
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{allSubscriptions.length - activeSubscriptions.length}{' '}
|
||||
{t('个已过期')}
|
||||
</Tag>
|
||||
@@ -348,97 +368,90 @@ const SubscriptionPlansCard = ({
|
||||
</div>
|
||||
|
||||
{hasAnySubscription ? (
|
||||
<div className='space-y-3 max-h-64 overflow-y-auto'>
|
||||
{allSubscriptions.map((sub, subIndex) => {
|
||||
const subscription = sub.subscription;
|
||||
const items = sub.items || [];
|
||||
const remainDays = getRemainingDays(sub);
|
||||
const usagePercent = getUsagePercent(sub);
|
||||
const now = Date.now() / 1000;
|
||||
const isExpired = (subscription?.end_time || 0) < now;
|
||||
const isActive =
|
||||
subscription?.status === 'active' && !isExpired;
|
||||
<>
|
||||
<Divider margin={8} />
|
||||
<div className='max-h-64 overflow-y-auto pr-1 semi-table-body'>
|
||||
{allSubscriptions.map((sub, subIndex) => {
|
||||
const isLast = subIndex === allSubscriptions.length - 1;
|
||||
const subscription = sub.subscription;
|
||||
const items = sub.items || [];
|
||||
const remainDays = getRemainingDays(sub);
|
||||
const usagePercent = getUsagePercent(sub);
|
||||
const now = Date.now() / 1000;
|
||||
const isExpired = (subscription?.end_time || 0) < now;
|
||||
const isActive =
|
||||
subscription?.status === 'active' && !isExpired;
|
||||
|
||||
return (
|
||||
<div
|
||||
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'>
|
||||
{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 (
|
||||
return (
|
||||
<div key={subscription?.id || subIndex}>
|
||||
{/* 订阅概要 */}
|
||||
<div className='flex items-center justify-between text-xs mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium'>
|
||||
{t('订阅')} #{subscription?.id}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Tag
|
||||
key={`${it.id}-${it.model_name}`}
|
||||
color='white'
|
||||
size='small'
|
||||
color={
|
||||
isActive
|
||||
? percent > 80
|
||||
? 'red'
|
||||
: 'blue'
|
||||
: 'grey'
|
||||
}
|
||||
type='light'
|
||||
shape='circle'
|
||||
prefixIcon={<Badge dot type='success' />}
|
||||
>
|
||||
{it.model_name}: {remain}
|
||||
{label}
|
||||
{t('生效')}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{items.length > 4 && (
|
||||
<Tag
|
||||
size='small'
|
||||
color='grey'
|
||||
type='light'
|
||||
shape='circle'
|
||||
>
|
||||
+{items.length - 4}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{t('已过期')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className='text-gray-500'>
|
||||
{t('剩余')} {remainDays} {t('天')} · {t('已用')}{' '}
|
||||
{usagePercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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 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'>
|
||||
{t('购买套餐后即可享受模型权益')}
|
||||
@@ -458,6 +471,15 @@ const SubscriptionPlansCard = ({
|
||||
price % 1 === 0 ? 0 : 2,
|
||||
);
|
||||
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 (
|
||||
<Card
|
||||
@@ -508,13 +530,20 @@ const SubscriptionPlansCard = ({
|
||||
{displayPrice}
|
||||
</span>
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 mt-1'>
|
||||
<CalendarClock size={12} className='inline mr-1' />
|
||||
{formatDuration(plan, t)}
|
||||
<span className='ml-2 text-xs text-gray-400'>
|
||||
{t('重置')}: {formatResetPeriod(plan, t)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 属性标签 */}
|
||||
<div className='flex flex-wrap justify-center gap-2 pb-2'>
|
||||
{planTags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
size='small'
|
||||
shape='circle'
|
||||
color='white'
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider margin={12} />
|
||||
@@ -530,12 +559,7 @@ const SubscriptionPlansCard = ({
|
||||
<span className='truncate flex-1'>
|
||||
{it.model_name}
|
||||
</span>
|
||||
<Tag
|
||||
size='small'
|
||||
color='blue'
|
||||
shape='circle'
|
||||
type='light'
|
||||
>
|
||||
<Tag size='small' color='white' shape='circle'>
|
||||
{it.amount_total}
|
||||
{it.quota_type === 1 ? t('次') : ''}
|
||||
</Tag>
|
||||
@@ -554,17 +578,33 @@ const SubscriptionPlansCard = ({
|
||||
</div>
|
||||
|
||||
{/* 购买按钮 */}
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
block
|
||||
onClick={() => openBuy(p)}
|
||||
className={
|
||||
isPopular ? '!bg-purple-600 hover:!bg-purple-700' : ''
|
||||
}
|
||||
>
|
||||
{t('立即订阅')}
|
||||
</Button>
|
||||
{(() => {
|
||||
const count = getPlanPurchaseCount(p?.plan?.id);
|
||||
const reached = limit > 0 && count >= limit;
|
||||
const tip = reached
|
||||
? t('已达到购买上限') + ` (${count}/${limit})`
|
||||
: '';
|
||||
const buttonEl = (
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
block
|
||||
disabled={reached}
|
||||
onClick={() => {
|
||||
if (!reached) openBuy(p);
|
||||
}}
|
||||
>
|
||||
{reached ? t('已达上限') : t('立即订阅')}
|
||||
</Button>
|
||||
);
|
||||
return reached ? (
|
||||
<Tooltip content={tip} position='top'>
|
||||
{buttonEl}
|
||||
</Tooltip>
|
||||
) : (
|
||||
buttonEl
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -591,6 +631,14 @@ const SubscriptionPlansCard = ({
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
purchaseLimitInfo={
|
||||
selectedPlan?.plan?.id
|
||||
? {
|
||||
limit: Number(selectedPlan?.plan?.max_purchase_per_user || 0),
|
||||
count: getPlanPurchaseCount(selectedPlan?.plan?.id),
|
||||
}
|
||||
: null
|
||||
}
|
||||
onPayStripe={payStripe}
|
||||
onPayCreem={payCreem}
|
||||
onPayEpay={payEpay}
|
||||
|
||||
@@ -83,6 +83,7 @@ const SubscriptionPurchaseModal = ({
|
||||
enableOnlineTopUp = false,
|
||||
enableStripeTopUp = false,
|
||||
enableCreemTopUp = false,
|
||||
purchaseLimitInfo = null,
|
||||
onPayStripe,
|
||||
onPayCreem,
|
||||
onPayEpay,
|
||||
@@ -97,6 +98,10 @@ const SubscriptionPurchaseModal = ({
|
||||
const hasCreem = enableCreemTopUp && !!plan?.creem_product_id;
|
||||
const hasEpay = enableOnlineTopUp && epayMethods.length > 0;
|
||||
const hasAnyPayment = hasStripe || hasCreem || hasEpay;
|
||||
const purchaseLimit = Number(purchaseLimitInfo?.limit || 0);
|
||||
const purchaseCount = Number(purchaseLimitInfo?.count || 0);
|
||||
const purchaseLimitReached =
|
||||
purchaseLimit > 0 && purchaseCount >= purchaseLimit;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -203,6 +208,15 @@ const SubscriptionPurchaseModal = ({
|
||||
)}
|
||||
|
||||
{/* 支付方式 */}
|
||||
{purchaseLimitReached && (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={`${t('已达到购买上限')} (${purchaseCount}/${purchaseLimit})`}
|
||||
className='!rounded-xl'
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasAnyPayment ? (
|
||||
<div className='space-y-3'>
|
||||
<Text size='small' type='tertiary'>
|
||||
@@ -219,6 +233,7 @@ const SubscriptionPurchaseModal = ({
|
||||
icon={<SiStripe size={14} color='#635BFF' />}
|
||||
onClick={onPayStripe}
|
||||
loading={paying}
|
||||
disabled={purchaseLimitReached}
|
||||
>
|
||||
Stripe
|
||||
</Button>
|
||||
@@ -230,6 +245,7 @@ const SubscriptionPurchaseModal = ({
|
||||
icon={<IconCreditCard />}
|
||||
onClick={onPayCreem}
|
||||
loading={paying}
|
||||
disabled={purchaseLimitReached}
|
||||
>
|
||||
Creem
|
||||
</Button>
|
||||
@@ -250,13 +266,14 @@ const SubscriptionPurchaseModal = ({
|
||||
value: m.type,
|
||||
label: m.name || m.type,
|
||||
}))}
|
||||
disabled={purchaseLimitReached}
|
||||
/>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={onPayEpay}
|
||||
loading={paying}
|
||||
disabled={!selectedEpayMethod}
|
||||
disabled={!selectedEpayMethod || purchaseLimitReached}
|
||||
>
|
||||
{t('支付')}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user