feat(subscription): add quota reset periods and admin configuration

- Add reset period fields on subscription plans and user items
- Apply automatic quota resets during pre-consume based on plan schedule
- Expose reset-period configuration in the admin plan editor
- Display reset cadence in subscription cards and purchase modal
- Validate custom reset seconds on plan create/update
This commit is contained in:
t0ng7u
2026-01-31 00:06:13 +08:00
parent ecf50b754a
commit 5707ee3492
5 changed files with 256 additions and 1 deletions

View File

@@ -43,7 +43,7 @@ import {
IconPlusCircle,
IconSave,
} from '@douyinfe/semi-icons';
import { Trash2, Clock, Boxes } from 'lucide-react';
import { Trash2, Clock, Boxes, RefreshCw } from 'lucide-react';
import { API, showError, showSuccess } from '../../../../helpers';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
@@ -57,6 +57,14 @@ const durationUnitOptions = [
{ value: 'custom', label: '自定义(秒)' },
];
const resetPeriodOptions = [
{ value: 'never', label: '不重置' },
{ value: 'daily', label: '每天' },
{ value: 'weekly', label: '每周' },
{ value: 'monthly', label: '每月' },
{ value: 'custom', label: '自定义(秒)' },
];
const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
const AddEditSubscriptionModal = ({
@@ -82,6 +90,8 @@ const AddEditSubscriptionModal = ({
duration_unit: 'month',
duration_value: 1,
custom_seconds: 0,
quota_reset_period: 'never',
quota_reset_custom_seconds: 0,
enabled: true,
sort_order: 0,
stripe_price_id: '',
@@ -108,6 +118,8 @@ const AddEditSubscriptionModal = ({
duration_unit: p.duration_unit || 'month',
duration_value: Number(p.duration_value || 1),
custom_seconds: Number(p.custom_seconds || 0),
quota_reset_period: p.quota_reset_period || 'never',
quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0),
enabled: p.enabled !== false,
sort_order: Number(p.sort_order || 0),
stripe_price_id: p.stripe_price_id || '',
@@ -264,6 +276,11 @@ const AddEditSubscriptionModal = ({
price_amount: Number(values.price_amount || 0),
duration_value: Number(values.duration_value || 0),
custom_seconds: Number(values.custom_seconds || 0),
quota_reset_period: values.quota_reset_period || 'never',
quota_reset_custom_seconds:
values.quota_reset_period === 'custom'
? Number(values.quota_reset_custom_seconds || 0)
: 0,
sort_order: Number(values.sort_order || 0),
},
items: cleanedItems,
@@ -539,6 +556,63 @@ const AddEditSubscriptionModal = ({
</Row>
</Card>
{/* 额度重置 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='orange'
className='mr-2 shadow-md'
>
<RefreshCw size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('额度重置')}
</Text>
<div className='text-xs text-gray-600'>
{t('支持周期性重置套餐权益额度')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={12}>
<Form.Select
field='quota_reset_period'
label={t('重置周期')}
>
{resetPeriodOptions.map((o) => (
<Select.Option key={o.value} value={o.value}>
{o.label}
</Select.Option>
))}
</Form.Select>
</Col>
<Col span={12}>
{values.quota_reset_period === 'custom' ? (
<Form.InputNumber
field='quota_reset_custom_seconds'
label={t('自定义秒数')}
min={60}
precision={0}
rules={[{ required: true, message: t('请输入秒数') }]}
style={{ width: '100%' }}
/>
) : (
<Form.InputNumber
field='quota_reset_custom_seconds'
label={t('自定义秒数')}
min={0}
precision={0}
style={{ width: '100%' }}
disabled
/>
)}
</Col>
</Row>
</Card>
{/* 第三方支付配置 */}
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-2'>

View File

@@ -55,6 +55,22 @@ function formatDuration(plan, t) {
return `${value} ${unitLabels[unit] || unit}`;
}
function formatResetPeriod(plan, t) {
const period = plan?.quota_reset_period || 'never';
if (period === 'never') return t('不重置');
if (period === 'daily') return t('每天');
if (period === 'weekly') return t('每周');
if (period === 'monthly') return t('每月');
if (period === 'custom') {
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('不重置');
}
// 过滤易支付方式
function getEpayMethods(payMethods = []) {
return (payMethods || []).filter(
@@ -497,6 +513,9 @@ const SubscriptionPlansCard = ({
<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>

View File

@@ -54,6 +54,22 @@ function formatDuration(plan, t) {
return `${value} ${unitLabels[unit] || unit}`;
}
function formatResetPeriod(plan, t) {
const period = plan?.quota_reset_period || 'never';
if (period === 'never') return t('不重置');
if (period === 'daily') return t('每天');
if (period === 'weekly') return t('每周');
if (period === 'monthly') return t('每月');
if (period === 'custom') {
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('不重置');
}
// 获取货币符号
function getCurrencySymbol(currency) {
const symbols = { USD: '$', EUR: '€', CNY: '¥', GBP: '£', JPY: '¥' };
@@ -129,6 +145,14 @@ const SubscriptionPurchaseModal = ({
</Text>
</div>
</div>
<div className='flex justify-between items-center'>
<Text strong className='text-slate-700 dark:text-slate-200'>
{t('重置周期')}
</Text>
<Text className='text-slate-900 dark:text-slate-100'>
{formatResetPeriod(plan, t)}
</Text>
</div>
<div className='flex justify-between items-center'>
<Text strong className='text-slate-700 dark:text-slate-200'>
{t('包含权益')}