mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 19:38:37 +00:00
✨ feat: add subscription billing system with admin management and user purchase flow
Implement a new subscription-based billing model alongside existing metered/per-request billing: Backend: - Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.) - Implement CRUD APIs for subscription plan management (admin only) - Add user subscription queries with support for multiple active/expired subscriptions - Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases - Implement pre-consume and post-consume billing logic for subscription quota tracking - Add billing preference settings (subscription_first, wallet_first, etc.) - Enhance usage logs with subscription deduction details Frontend - Admin: - Add subscription management page with table view and drawer-based edit form - Match UI/UX style with existing admin pages (redemption codes, users) - Support enabling/disabling plans, configuring payment IDs, and model quotas - Add user subscription binding modal in user management Frontend - Wallet: - Add subscription plans card with current subscription status display - Show all subscriptions (active and expired) with remaining days/usage percentage - Display purchasable plans with pricing cards following SaaS best practices - Extract purchase modal to separate component matching payment confirm modal style - Add skeleton loading states with active animation - Implement billing preference selector in card header - Handle payment gateway availability based on admin configuration Frontend - Usage Logs: - Display subscription deduction details in log entries - Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining) - Add subscription deduction tag for subscription-covered requests
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Modal, Space, Tag } from '@douyinfe/semi-ui';
|
||||
|
||||
const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
|
||||
|
||||
function formatDuration(plan, t) {
|
||||
if (!plan) return '';
|
||||
const u = plan.duration_unit || 'month';
|
||||
if (u === 'custom') {
|
||||
return `${t('自定义')} ${plan.custom_seconds || 0}s`;
|
||||
}
|
||||
const unitMap = {
|
||||
year: t('年'),
|
||||
month: t('月'),
|
||||
day: t('日'),
|
||||
hour: t('小时'),
|
||||
};
|
||||
return `${plan.duration_value || 0}${unitMap[u] || u}`;
|
||||
}
|
||||
|
||||
const renderPlanTitle = (text, record) => {
|
||||
return (
|
||||
<div>
|
||||
<div className='font-medium'>{text}</div>
|
||||
{record?.plan?.subtitle ? (
|
||||
<div className='text-xs text-gray-500'>{record.plan.subtitle}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPrice = (text, record) => {
|
||||
return `${record?.plan?.currency || 'USD'} ${Number(text || 0).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const renderDuration = (text, record, t) => {
|
||||
return formatDuration(record?.plan, t);
|
||||
};
|
||||
|
||||
const renderEnabled = (text, record) => {
|
||||
return text ? (
|
||||
<Tag color='green' shape='circle'>
|
||||
启用
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='grey' shape='circle'>
|
||||
禁用
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const renderModels = (text, record, t) => {
|
||||
const items = record?.items || [];
|
||||
if (items.length === 0) {
|
||||
return <div className='text-xs text-gray-500'>{t('无模型')}</div>;
|
||||
}
|
||||
return (
|
||||
<div className='text-xs space-y-1'>
|
||||
{items.slice(0, 3).map((it, idx) => (
|
||||
<div key={idx}>
|
||||
{it.model_name} ({quotaTypeLabel(it.quota_type)}: {it.amount_total})
|
||||
</div>
|
||||
))}
|
||||
{items.length > 3 && (
|
||||
<div className='text-gray-500'>...{t('共')} {items.length} {t('个模型')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOperations = (text, record, { openEdit, disablePlan, t }) => {
|
||||
const handleDisable = () => {
|
||||
Modal.confirm({
|
||||
title: t('确认禁用'),
|
||||
content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'),
|
||||
centered: true,
|
||||
onOk: () => disablePlan(record?.plan?.id),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
openEdit(record);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button type='danger' size='small' onClick={handleDisable}>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export const getSubscriptionsColumns = ({ t, openEdit, disablePlan }) => {
|
||||
return [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: ['plan', 'id'],
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: t('标题'),
|
||||
dataIndex: ['plan', 'title'],
|
||||
render: (text, record) => renderPlanTitle(text, record),
|
||||
},
|
||||
{
|
||||
title: t('价格'),
|
||||
dataIndex: ['plan', 'price_amount'],
|
||||
width: 140,
|
||||
render: (text, record) => renderPrice(text, record),
|
||||
},
|
||||
{
|
||||
title: t('有效期'),
|
||||
width: 140,
|
||||
render: (text, record) => renderDuration(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: ['plan', 'enabled'],
|
||||
width: 90,
|
||||
render: (text, record) => renderEnabled(text, record),
|
||||
},
|
||||
{
|
||||
title: t('模型权益'),
|
||||
width: 200,
|
||||
render: (text, record) => renderModels(text, record, t),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 180,
|
||||
render: (text, record) =>
|
||||
renderOperations(text, record, { openEdit, disablePlan, t }),
|
||||
},
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user