mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-18 19:17:26 +00:00
✨ feat: Add subscription upgrade group with auto downgrade
This commit is contained in:
@@ -82,6 +82,8 @@ const renderPlanTitle = (text, record, t) => {
|
||||
</Text>
|
||||
<Text type='tertiary'>{t('总额度')}</Text>
|
||||
<Text>{plan?.total_amount > 0 ? plan.total_amount : t('不限')}</Text>
|
||||
<Text type='tertiary'>{t('升级分组')}</Text>
|
||||
<Text>{plan?.upgrade_group ? plan.upgrade_group : t('不升级')}</Text>
|
||||
<Text type='tertiary'>{t('购买上限')}</Text>
|
||||
<Text>
|
||||
{plan?.max_purchase_per_user > 0
|
||||
@@ -168,6 +170,15 @@ const renderTotalAmount = (text, record, t) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderUpgradeGroup = (text, record, t) => {
|
||||
const group = record?.plan?.upgrade_group || '';
|
||||
return (
|
||||
<Text type={group ? 'secondary' : 'tertiary'}>
|
||||
{group ? group : t('不升级')}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderResetPeriod = (text, record, t) => {
|
||||
const period = record?.plan?.quota_reset_period || 'never';
|
||||
const isNever = period === 'never';
|
||||
@@ -291,7 +302,7 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
|
||||
},
|
||||
{
|
||||
title: t('有效期'),
|
||||
width: 80,
|
||||
width: 100,
|
||||
render: (text, record) => renderDuration(text, record, t),
|
||||
},
|
||||
{
|
||||
@@ -315,6 +326,11 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
|
||||
width: 100,
|
||||
render: (text, record) => renderTotalAmount(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('升级分组'),
|
||||
width: 100,
|
||||
render: (text, record) => renderUpgradeGroup(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
dataIndex: 'operate',
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -74,6 +74,8 @@ const AddEditSubscriptionModal = ({
|
||||
t,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [groupLoading, setGroupLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = editingPlan?.plan?.id !== undefined;
|
||||
@@ -93,6 +95,7 @@ const AddEditSubscriptionModal = ({
|
||||
sort_order: 0,
|
||||
max_purchase_per_user: 0,
|
||||
total_amount: 0,
|
||||
upgrade_group: '',
|
||||
stripe_price_id: '',
|
||||
creem_product_id: '',
|
||||
});
|
||||
@@ -116,11 +119,27 @@ const AddEditSubscriptionModal = ({
|
||||
sort_order: Number(p.sort_order || 0),
|
||||
max_purchase_per_user: Number(p.max_purchase_per_user || 0),
|
||||
total_amount: Number(p.total_amount || 0),
|
||||
upgrade_group: p.upgrade_group || '',
|
||||
stripe_price_id: p.stripe_price_id || '',
|
||||
creem_product_id: p.creem_product_id || '',
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setGroupLoading(true);
|
||||
API.get('/api/group')
|
||||
.then((res) => {
|
||||
if (res.data?.success) {
|
||||
setGroupOptions(res.data?.data || []);
|
||||
} else {
|
||||
setGroupOptions([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setGroupOptions([]))
|
||||
.finally(() => setGroupLoading(false));
|
||||
}, [visible]);
|
||||
|
||||
const submit = async (values) => {
|
||||
if (!values.title || values.title.trim() === '') {
|
||||
showError(t('套餐标题不能为空'));
|
||||
@@ -143,6 +162,7 @@ const AddEditSubscriptionModal = ({
|
||||
sort_order: Number(values.sort_order || 0),
|
||||
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||
total_amount: Number(values.total_amount || 0),
|
||||
upgrade_group: values.upgrade_group || '',
|
||||
},
|
||||
};
|
||||
if (editingPlan?.plan?.id) {
|
||||
@@ -257,6 +277,7 @@ const AddEditSubscriptionModal = ({
|
||||
field='title'
|
||||
label={t('套餐标题')}
|
||||
placeholder={t('例如:基础套餐')}
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: t('请输入套餐标题') },
|
||||
]}
|
||||
@@ -277,6 +298,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.InputNumber
|
||||
field='price_amount'
|
||||
label={t('实付金额')}
|
||||
required
|
||||
min={0}
|
||||
precision={2}
|
||||
rules={[{ required: true, message: t('请输入金额') }]}
|
||||
@@ -288,6 +310,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.AutoComplete
|
||||
field='total_amount'
|
||||
label={t('总额度')}
|
||||
required
|
||||
type='number'
|
||||
rules={[{ required: true, message: t('请输入总额度') }]}
|
||||
extraText={`${t('0 表示不限')} · ${renderQuotaWithPrompt(
|
||||
@@ -305,6 +328,23 @@ const AddEditSubscriptionModal = ({
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='upgrade_group'
|
||||
label={t('升级分组')}
|
||||
showClear
|
||||
loading={groupLoading}
|
||||
placeholder={t('不升级')}
|
||||
>
|
||||
<Select.Option value=''>{t('不升级')}</Select.Option>
|
||||
{(groupOptions || []).map((g) => (
|
||||
<Select.Option key={g} value={g}>
|
||||
{g}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field='currency'
|
||||
@@ -369,6 +409,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.Select
|
||||
field='duration_unit'
|
||||
label={t('有效期单位')}
|
||||
required
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
{durationUnitOptions.map((o) => (
|
||||
@@ -384,6 +425,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.InputNumber
|
||||
field='custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
required
|
||||
min={0}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
@@ -393,6 +435,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.InputNumber
|
||||
field='duration_value'
|
||||
label={t('有效期数值')}
|
||||
required
|
||||
min={1}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入数值') }]}
|
||||
@@ -441,6 +484,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.InputNumber
|
||||
field='quota_reset_custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
required
|
||||
min={60}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
|
||||
@@ -179,7 +179,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
},
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('新增成功'));
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('新增成功'));
|
||||
setSelectedPlanId(null);
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
@@ -204,7 +205,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
`/api/subscription/admin/user_subscriptions/${subId}/invalidate`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('已作废'));
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('已作废'));
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
@@ -229,7 +231,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
`/api/subscription/admin/user_subscriptions/${subId}`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('已删除'));
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('已删除'));
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { getCurrencyConfig, stringToColor } from '../../helpers/render';
|
||||
import { API, showError, showSuccess, renderQuota } from '../../helpers';
|
||||
import { getCurrencyConfig } from '../../helpers/render';
|
||||
import { Crown, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
||||
|
||||
@@ -232,6 +232,16 @@ const SubscriptionPlansCard = ({
|
||||
return map;
|
||||
}, [allSubscriptions]);
|
||||
|
||||
const planTitleMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
(plans || []).forEach((p) => {
|
||||
const plan = p?.plan;
|
||||
if (!plan?.id) return;
|
||||
map.set(plan.id, plan.title || '');
|
||||
});
|
||||
return map;
|
||||
}, [plans]);
|
||||
|
||||
const getPlanPurchaseCount = (planId) =>
|
||||
planPurchaseCountMap.get(planId) || 0;
|
||||
|
||||
@@ -374,6 +384,8 @@ const SubscriptionPlansCard = ({
|
||||
totalAmount > 0
|
||||
? Math.max(0, totalAmount - usedAmount)
|
||||
: 0;
|
||||
const planTitle =
|
||||
planTitleMap.get(subscription?.plan_id) || '';
|
||||
const remainDays = getRemainingDays(sub);
|
||||
const usagePercent = getUsagePercent(sub);
|
||||
const now = Date.now() / 1000;
|
||||
@@ -387,7 +399,9 @@ const SubscriptionPlansCard = ({
|
||||
<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}
|
||||
{planTitle
|
||||
? `${planTitle} · ${t('订阅')} #${subscription?.id}`
|
||||
: `${t('订阅')} #${subscription?.id}`}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Tag
|
||||
@@ -418,9 +432,19 @@ const SubscriptionPlansCard = ({
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 mb-2'>
|
||||
{t('总额度')}:{' '}
|
||||
{totalAmount > 0
|
||||
? `${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`
|
||||
: t('不限')}
|
||||
{totalAmount > 0 ? (
|
||||
<Tooltip
|
||||
content={`${t('原生额度')}:${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`}
|
||||
>
|
||||
<span>
|
||||
{renderQuota(usedAmount)}/
|
||||
{renderQuota(totalAmount)} · {t('剩余')}{' '}
|
||||
{renderQuota(remainAmount)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
t('不限')
|
||||
)}
|
||||
{totalAmount > 0 && (
|
||||
<span className='ml-2'>
|
||||
{t('已用')} {usagePercent}%
|
||||
@@ -453,18 +477,30 @@ const SubscriptionPlansCard = ({
|
||||
);
|
||||
const isPopular = index === 0 && plans.length > 1;
|
||||
const limit = Number(plan?.max_purchase_per_user || 0);
|
||||
const limitLabel =
|
||||
limit > 0 ? `${t('限购')} ${limit}` : t('不限购');
|
||||
const limitLabel = limit > 0 ? `${t('限购')} ${limit}` : null;
|
||||
const totalLabel =
|
||||
totalAmount > 0
|
||||
? `${t('总额度')}: ${totalAmount}`
|
||||
? `${t('总额度')}: ${renderQuota(totalAmount)}`
|
||||
: `${t('总额度')}: ${t('不限')}`;
|
||||
const planTags = [
|
||||
`${t('有效期')}: ${formatDuration(plan, t)}`,
|
||||
`${t('重置')}: ${formatResetPeriod(plan, t)}`,
|
||||
totalLabel,
|
||||
limitLabel,
|
||||
];
|
||||
const upgradeLabel = plan?.upgrade_group
|
||||
? `${t('升级分组')}: ${plan.upgrade_group}`
|
||||
: null;
|
||||
const resetLabel =
|
||||
formatResetPeriod(plan, t) === t('不重置')
|
||||
? null
|
||||
: `${t('额度重置')}: ${formatResetPeriod(plan, t)}`;
|
||||
const planBenefits = [
|
||||
{ label: `${t('有效期')}: ${formatDuration(plan, t)}` },
|
||||
resetLabel ? { label: resetLabel } : null,
|
||||
totalAmount > 0
|
||||
? {
|
||||
label: totalLabel,
|
||||
tooltip: `${t('原生额度')}:${totalAmount}`,
|
||||
}
|
||||
: { label: totalLabel },
|
||||
limitLabel ? { label: limitLabel } : null,
|
||||
upgradeLabel ? { label: upgradeLabel } : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -517,18 +553,33 @@ const SubscriptionPlansCard = ({
|
||||
</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 className='flex flex-col items-center gap-1 pb-2'>
|
||||
{planBenefits.map((item) => {
|
||||
const content = (
|
||||
<div className='flex items-center gap-2 text-xs text-gray-500'>
|
||||
<Badge dot type='tertiary' />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
if (!item.tooltip) {
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className='w-full flex justify-center'
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip key={item.label} content={item.tooltip}>
|
||||
<div className='w-full flex justify-center'>
|
||||
{content}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Divider margin={12} />
|
||||
|
||||
Reference in New Issue
Block a user