mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-29 20:28: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,38 @@
|
||||
/*
|
||||
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 } from '@douyinfe/semi-ui';
|
||||
|
||||
const SubscriptionsActions = ({ openCreate, t }) => {
|
||||
return (
|
||||
<div className='flex gap-2 w-full md:w-auto'>
|
||||
<Button
|
||||
type='primary'
|
||||
className='w-full md:w-auto'
|
||||
onClick={openCreate}
|
||||
size='small'
|
||||
>
|
||||
{t('新建套餐')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsActions;
|
||||
@@ -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 }),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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 { Typography } from '@douyinfe/semi-ui';
|
||||
import { CalendarClock } from 'lucide-react';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SubscriptionsDescription = ({ compactMode, setCompactMode, t }) => {
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
<div className='flex items-center text-blue-500'>
|
||||
<CalendarClock size={16} className='mr-2' />
|
||||
<Text>{t('订阅管理')}</Text>
|
||||
</div>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsDescription;
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
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, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getSubscriptionsColumns } from './SubscriptionsColumnDefs';
|
||||
|
||||
const SubscriptionsTable = (subscriptionsData) => {
|
||||
const {
|
||||
plans,
|
||||
loading,
|
||||
compactMode,
|
||||
openEdit,
|
||||
disablePlan,
|
||||
t,
|
||||
} = subscriptionsData;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return getSubscriptionsColumns({
|
||||
t,
|
||||
openEdit,
|
||||
disablePlan,
|
||||
});
|
||||
}, [t, openEdit, disablePlan]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
? columns.map((col) => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
})
|
||||
: columns;
|
||||
}, [compactMode, columns]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={plans}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={false}
|
||||
hidePagination={true}
|
||||
loading={loading}
|
||||
rowKey={(row) => row?.plan?.id}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无订阅套餐')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className='overflow-hidden'
|
||||
size='middle'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsTable;
|
||||
90
web/src/components/table/subscriptions/index.jsx
Normal file
90
web/src/components/table/subscriptions/index.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
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 { Banner } from '@douyinfe/semi-ui';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import SubscriptionsTable from './SubscriptionsTable';
|
||||
import SubscriptionsActions from './SubscriptionsActions';
|
||||
import SubscriptionsDescription from './SubscriptionsDescription';
|
||||
import AddEditSubscriptionModal from './modals/AddEditSubscriptionModal';
|
||||
import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscriptionsData';
|
||||
|
||||
const SubscriptionsPage = () => {
|
||||
const subscriptionsData = useSubscriptionsData();
|
||||
|
||||
const {
|
||||
showEdit,
|
||||
editingPlan,
|
||||
sheetPlacement,
|
||||
closeEdit,
|
||||
refresh,
|
||||
openCreate,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
pricingModels,
|
||||
t,
|
||||
} = subscriptionsData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddEditSubscriptionModal
|
||||
visible={showEdit}
|
||||
handleClose={closeEdit}
|
||||
editingPlan={editingPlan}
|
||||
placement={sheetPlacement}
|
||||
pricingModels={pricingModels}
|
||||
refresh={refresh}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<CardPro
|
||||
type='type1'
|
||||
descriptionArea={
|
||||
<SubscriptionsDescription
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
}
|
||||
actionsArea={
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
{/* Mobile: actions first; Desktop: actions left */}
|
||||
<div className='order-1 md:order-0 w-full md:w-auto'>
|
||||
<SubscriptionsActions openCreate={openCreate} t={t} />
|
||||
</div>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('Stripe/Creem 需在第三方平台创建商品并填入 ID')}
|
||||
closeIcon={null}
|
||||
// Mobile: banner below; Desktop: banner right
|
||||
className='!rounded-lg order-2 md:order-1'
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
t={t}
|
||||
>
|
||||
<SubscriptionsTable {...subscriptionsData} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsPage;
|
||||
@@ -0,0 +1,542 @@
|
||||
/*
|
||||
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, { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCalendarClock,
|
||||
IconClose,
|
||||
IconCreditCard,
|
||||
IconSave,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Trash2, Clock } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const durationUnitOptions = [
|
||||
{ value: 'year', label: '年' },
|
||||
{ value: 'month', label: '月' },
|
||||
{ value: 'day', label: '日' },
|
||||
{ value: 'hour', label: '小时' },
|
||||
{ value: 'custom', label: '自定义(秒)' },
|
||||
];
|
||||
|
||||
const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
|
||||
|
||||
const AddEditSubscriptionModal = ({
|
||||
visible,
|
||||
handleClose,
|
||||
editingPlan,
|
||||
placement = 'left',
|
||||
pricingModels = [],
|
||||
refresh,
|
||||
t,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = editingPlan?.plan?.id !== undefined;
|
||||
const formKey = isEdit ? `edit-${editingPlan?.plan?.id}` : 'create';
|
||||
|
||||
const getInitValues = () => ({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
price_amount: 0,
|
||||
currency: 'USD',
|
||||
duration_unit: 'month',
|
||||
duration_value: 1,
|
||||
custom_seconds: 0,
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
stripe_price_id: '',
|
||||
creem_product_id: '',
|
||||
});
|
||||
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
const buildFormValues = () => {
|
||||
const base = getInitValues();
|
||||
if (editingPlan?.plan?.id === undefined) return base;
|
||||
const p = editingPlan.plan || {};
|
||||
return {
|
||||
...base,
|
||||
title: p.title || '',
|
||||
subtitle: p.subtitle || '',
|
||||
price_amount: Number(p.price_amount || 0),
|
||||
currency: p.currency || 'USD',
|
||||
duration_unit: p.duration_unit || 'month',
|
||||
duration_value: Number(p.duration_value || 1),
|
||||
custom_seconds: Number(p.custom_seconds || 0),
|
||||
enabled: p.enabled !== false,
|
||||
sort_order: Number(p.sort_order || 0),
|
||||
stripe_price_id: p.stripe_price_id || '',
|
||||
creem_product_id: p.creem_product_id || '',
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 1) always keep items in sync
|
||||
if (visible && isEdit && editingPlan) {
|
||||
setItems((editingPlan.items || []).map((it) => ({ ...it })));
|
||||
} else if (visible && !isEdit) {
|
||||
setItems([]);
|
||||
}
|
||||
}, [visible, editingPlan]);
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
return (pricingModels || []).map((m) => ({
|
||||
label: `${m.model_name} (${quotaTypeLabel(m.quota_type)})`,
|
||||
value: m.model_name,
|
||||
quota_type: m.quota_type,
|
||||
}));
|
||||
}, [pricingModels]);
|
||||
|
||||
const addItem = (modelName) => {
|
||||
const modelMeta = modelOptions.find((m) => m.value === modelName);
|
||||
if (!modelMeta) return;
|
||||
if (items.some((it) => it.model_name === modelName)) {
|
||||
showError(t('该模型已添加'));
|
||||
return;
|
||||
}
|
||||
setItems([
|
||||
...items,
|
||||
{
|
||||
model_name: modelName,
|
||||
quota_type: modelMeta.quota_type,
|
||||
amount_total: 0,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updateItem = (idx, patch) => {
|
||||
const next = [...items];
|
||||
next[idx] = { ...next[idx], ...patch };
|
||||
setItems(next);
|
||||
};
|
||||
|
||||
const removeItem = (idx) => {
|
||||
const next = [...items];
|
||||
next.splice(idx, 1);
|
||||
setItems(next);
|
||||
};
|
||||
|
||||
const submit = async (values) => {
|
||||
if (!values.title || values.title.trim() === '') {
|
||||
showError(t('套餐标题不能为空'));
|
||||
return;
|
||||
}
|
||||
const cleanedItems = items
|
||||
.filter((it) => it.model_name && Number(it.amount_total) > 0)
|
||||
.map((it) => ({
|
||||
model_name: it.model_name,
|
||||
quota_type: Number(it.quota_type || 0),
|
||||
amount_total: Number(it.amount_total),
|
||||
}));
|
||||
if (cleanedItems.length === 0) {
|
||||
showError(t('请至少配置一个模型权益(且数量>0)'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
plan: {
|
||||
...values,
|
||||
price_amount: Number(values.price_amount || 0),
|
||||
duration_value: Number(values.duration_value || 0),
|
||||
custom_seconds: Number(values.custom_seconds || 0),
|
||||
sort_order: Number(values.sort_order || 0),
|
||||
},
|
||||
items: cleanedItems,
|
||||
};
|
||||
if (editingPlan?.plan?.id) {
|
||||
const res = await API.put(
|
||||
`/api/subscription/admin/plans/${editingPlan.plan.id}`,
|
||||
payload,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
handleClose();
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('更新失败'));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/subscription/admin/plans', payload);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('创建成功'));
|
||||
handleClose();
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('创建失败'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const itemColumns = [
|
||||
{
|
||||
title: t('模型'),
|
||||
dataIndex: 'model_name',
|
||||
render: (v, row) => (
|
||||
<div className='text-sm'>
|
||||
<div className='font-medium'>{v}</div>
|
||||
<div className='text-xs text-gray-500'>
|
||||
{t('计费')}: {quotaTypeLabel(row.quota_type)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('数量'),
|
||||
dataIndex: 'amount_total',
|
||||
width: 220,
|
||||
render: (v, row, idx) => (
|
||||
<InputNumber
|
||||
value={Number(v || 0)}
|
||||
min={0}
|
||||
precision={0}
|
||||
onChange={(val) => updateItem(idx, { amount_total: val })}
|
||||
placeholder={row.quota_type === 1 ? t('次数') : t('额度')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 60,
|
||||
render: (_, __, idx) => (
|
||||
<Button
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => removeItem(idx)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement={placement}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新套餐信息') : t('创建新的订阅套餐')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={isMobile ? '100%' : 700}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={handleClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={handleClose}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={formKey}
|
||||
initValues={buildFormValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='blue'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCalendarClock 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={24}>
|
||||
<Form.Input
|
||||
field='title'
|
||||
label={t('套餐标题')}
|
||||
placeholder={t('例如:基础套餐')}
|
||||
rules={[{ required: true, message: t('请输入套餐标题') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='subtitle'
|
||||
label={t('套餐副标题')}
|
||||
placeholder={t('例如:适合轻度使用')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='price_amount'
|
||||
label={t('实付金额')}
|
||||
min={0}
|
||||
precision={2}
|
||||
rules={[{ required: true, message: t('请输入金额') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='currency'
|
||||
label={t('币种')}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select.Option value='USD'>USD</Select.Option>
|
||||
<Select.Option value='EUR'>EUR</Select.Option>
|
||||
<Select.Option value='CNY'>CNY</Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='sort_order'
|
||||
label={t('排序')}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Switch
|
||||
field='enabled'
|
||||
label={t('启用状态')}
|
||||
size='large'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 有效期设置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='green'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<Clock 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='duration_unit'
|
||||
label={t('有效期单位')}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
{durationUnitOptions.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
{values.duration_unit === 'custom' ? (
|
||||
<Form.InputNumber
|
||||
field='custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
min={0}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Form.InputNumber
|
||||
field='duration_value'
|
||||
label={t('有效期数值')}
|
||||
min={1}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入数值') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 第三方支付配置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('第三方支付配置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('Stripe/Creem 商品ID(可选)')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='stripe_price_id'
|
||||
label='Stripe PriceId'
|
||||
placeholder='price_...'
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='creem_product_id'
|
||||
label='Creem ProductId'
|
||||
placeholder='prod_...'
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 模型权益 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('模型权益')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('配置套餐可使用的模型及额度')}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
placeholder={t('添加模型')}
|
||||
style={{ width: 280 }}
|
||||
filter
|
||||
onChange={addItem}
|
||||
>
|
||||
{modelOptions.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Table
|
||||
columns={itemColumns}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
rowKey={(row) => `${row.model_name}-${row.quota_type}`}
|
||||
empty={
|
||||
<div className='py-6 text-center text-gray-500'>
|
||||
{t('尚未添加任何模型')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddEditSubscriptionModal;
|
||||
@@ -182,6 +182,18 @@ function renderFirstUseTime(type, t) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderBillingTag(record, t) {
|
||||
const other = getLogOther(record.other);
|
||||
if (other?.billing_source === 'subscription') {
|
||||
return (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('订阅抵扣')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderModelName(record, copyText, t) {
|
||||
let other = getLogOther(record.other);
|
||||
let modelMapped =
|
||||
@@ -191,7 +203,7 @@ function renderModelName(record, copyText, t) {
|
||||
if (!modelMapped) {
|
||||
return renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -208,7 +220,7 @@ function renderModelName(record, copyText, t) {
|
||||
</Typography.Text>
|
||||
{renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
@@ -219,7 +231,7 @@ function renderModelName(record, copyText, t) {
|
||||
{renderModelTag(other.upstream_model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, other.upstream_model_name).then(
|
||||
(r) => {},
|
||||
(r) => { },
|
||||
);
|
||||
},
|
||||
})}
|
||||
@@ -230,7 +242,7 @@ function renderModelName(record, copyText, t) {
|
||||
>
|
||||
{renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
},
|
||||
suffixIcon: (
|
||||
<Route
|
||||
@@ -457,11 +469,20 @@ export const getLogsColumns = ({
|
||||
title: t('花费'),
|
||||
dataIndex: 'quota',
|
||||
render: (text, record, index) => {
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 ? (
|
||||
<>{renderQuota(text, 6)}</>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
if (!(record.type === 0 || record.type === 2 || record.type === 5)) {
|
||||
return <></>;
|
||||
}
|
||||
const other = getLogOther(record.other);
|
||||
const isSubscription = other?.billing_source === 'subscription';
|
||||
if (isSubscription) {
|
||||
// Subscription billed: show only tag (no $0), but keep tooltip for equivalent cost.
|
||||
return (
|
||||
<Tooltip content={`${t('由订阅抵扣')}:${renderQuota(text, 6)}`}>
|
||||
<span>{renderBillingTag(record, t)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <>{renderQuota(text, 6)}</>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -532,42 +553,42 @@ export const getLogsColumns = ({
|
||||
return isAdminUser ? (
|
||||
<Space>
|
||||
<div>{content}</div>
|
||||
{affinity ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('规则')}:{affinity.rule_name || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('分组')}:{affinity.selected_group || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('Key')}:
|
||||
{(affinity.key_source || '-') +
|
||||
':' +
|
||||
(affinity.key_path || affinity.key_key || '-') +
|
||||
{affinity ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('规则')}:{affinity.rule_name || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('分组')}:{affinity.selected_group || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('Key')}:
|
||||
{(affinity.key_source || '-') +
|
||||
':' +
|
||||
(affinity.key_path || affinity.key_key || '-') +
|
||||
(affinity.key_fp ? `#${affinity.key_fp}` : '')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
|
||||
<span className='channel-affinity-tag-content'>
|
||||
<IconStarStroked style={{ fontSize: 13 }} />
|
||||
{t('优选')}
|
||||
</span>
|
||||
</Tag>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
|
||||
<span className='channel-affinity-tag-content'>
|
||||
<IconStarStroked style={{ fontSize: 13 }} />
|
||||
{t('优选')}
|
||||
</span>
|
||||
</Tag>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
) : (
|
||||
@@ -632,45 +653,49 @@ export const getLogsColumns = ({
|
||||
|
||||
let content = other?.claude
|
||||
? renderModelPriceSimple(
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_tokens || 0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
other.cache_creation_tokens_5m || 0,
|
||||
other.cache_creation_ratio_5m ||
|
||||
other.cache_creation_ratio ||
|
||||
1.0,
|
||||
other.cache_creation_tokens_1h || 0,
|
||||
other.cache_creation_ratio_1h ||
|
||||
other.cache_creation_ratio ||
|
||||
1.0,
|
||||
false,
|
||||
1.0,
|
||||
other?.is_system_prompt_overwritten,
|
||||
'claude',
|
||||
)
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_tokens || 0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
other.cache_creation_tokens_5m || 0,
|
||||
other.cache_creation_ratio_5m ||
|
||||
other.cache_creation_ratio ||
|
||||
1.0,
|
||||
other.cache_creation_tokens_1h || 0,
|
||||
other.cache_creation_ratio_1h ||
|
||||
other.cache_creation_ratio ||
|
||||
1.0,
|
||||
false,
|
||||
1.0,
|
||||
other?.is_system_prompt_overwritten,
|
||||
'claude',
|
||||
)
|
||||
: renderModelPriceSimple(
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
0,
|
||||
1.0,
|
||||
0,
|
||||
1.0,
|
||||
0,
|
||||
1.0,
|
||||
false,
|
||||
1.0,
|
||||
other?.is_system_prompt_overwritten,
|
||||
'openai',
|
||||
);
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
0,
|
||||
1.0,
|
||||
0,
|
||||
1.0,
|
||||
0,
|
||||
1.0,
|
||||
false,
|
||||
1.0,
|
||||
other?.is_system_prompt_overwritten,
|
||||
'openai',
|
||||
);
|
||||
// Do not add billing source here; keep details clean.
|
||||
const summary = [content, text ? `${t('详情')}:${text}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
return (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{
|
||||
@@ -678,7 +703,7 @@ export const getLogsColumns = ({
|
||||
}}
|
||||
style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{content}
|
||||
{summary}
|
||||
</Typography.Paragraph>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -208,6 +208,7 @@ const renderOperations = (
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showBindSubscriptionModal,
|
||||
t,
|
||||
},
|
||||
) => {
|
||||
@@ -216,6 +217,14 @@ const renderOperations = (
|
||||
}
|
||||
|
||||
const moreMenu = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('绑定订阅套餐'),
|
||||
onClick: () => showBindSubscriptionModal(record),
|
||||
},
|
||||
{
|
||||
node: 'divider',
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('重置 Passkey'),
|
||||
@@ -299,6 +308,7 @@ export const getUsersColumns = ({
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showBindSubscriptionModal,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -355,6 +365,7 @@ export const getUsersColumns = ({
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showBindSubscriptionModal,
|
||||
t,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ import EnableDisableUserModal from './modals/EnableDisableUserModal';
|
||||
import DeleteUserModal from './modals/DeleteUserModal';
|
||||
import ResetPasskeyModal from './modals/ResetPasskeyModal';
|
||||
import ResetTwoFAModal from './modals/ResetTwoFAModal';
|
||||
import BindSubscriptionModal from './modals/BindSubscriptionModal';
|
||||
|
||||
const UsersTable = (usersData) => {
|
||||
const {
|
||||
@@ -61,6 +62,8 @@ const UsersTable = (usersData) => {
|
||||
const [enableDisableAction, setEnableDisableAction] = useState('');
|
||||
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
|
||||
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
|
||||
const [showBindSubscriptionModal, setShowBindSubscriptionModal] =
|
||||
useState(false);
|
||||
|
||||
// Modal handlers
|
||||
const showPromoteUserModal = (user) => {
|
||||
@@ -94,6 +97,11 @@ const UsersTable = (usersData) => {
|
||||
setShowResetTwoFAModal(true);
|
||||
};
|
||||
|
||||
const showBindSubscriptionUserModal = (user) => {
|
||||
setModalUser(user);
|
||||
setShowBindSubscriptionModal(true);
|
||||
};
|
||||
|
||||
// Modal confirm handlers
|
||||
const handlePromoteConfirm = () => {
|
||||
manageUser(modalUser.id, 'promote', modalUser);
|
||||
@@ -132,6 +140,7 @@ const UsersTable = (usersData) => {
|
||||
showDeleteModal: showDeleteUserModal,
|
||||
showResetPasskeyModal: showResetPasskeyUserModal,
|
||||
showResetTwoFAModal: showResetTwoFAUserModal,
|
||||
showBindSubscriptionModal: showBindSubscriptionUserModal,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
@@ -143,6 +152,7 @@ const UsersTable = (usersData) => {
|
||||
showDeleteUserModal,
|
||||
showResetPasskeyUserModal,
|
||||
showResetTwoFAUserModal,
|
||||
showBindSubscriptionUserModal,
|
||||
]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
@@ -242,6 +252,14 @@ const UsersTable = (usersData) => {
|
||||
user={modalUser}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<BindSubscriptionModal
|
||||
visible={showBindSubscriptionModal}
|
||||
onCancel={() => setShowBindSubscriptionModal(false)}
|
||||
user={modalUser}
|
||||
t={t}
|
||||
onSuccess={() => refresh?.()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
124
web/src/components/table/users/modals/BindSubscriptionModal.jsx
Normal file
124
web/src/components/table/users/modals/BindSubscriptionModal.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
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, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Select, Space, Typography } from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const BindSubscriptionModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [selectedPlanId, setSelectedPlanId] = useState(null);
|
||||
|
||||
const loadPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/admin/plans');
|
||||
if (res.data?.success) {
|
||||
setPlans(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedPlanId(null);
|
||||
loadPlans();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const planOptions = useMemo(() => {
|
||||
return (plans || []).map((p) => ({
|
||||
label: `${p?.plan?.title || ''} (${p?.plan?.currency || 'USD'} ${Number(p?.plan?.price_amount || 0)})`,
|
||||
value: p?.plan?.id,
|
||||
}));
|
||||
}, [plans]);
|
||||
|
||||
const bind = async () => {
|
||||
if (!user?.id) {
|
||||
showError(t('用户信息缺失'));
|
||||
return;
|
||||
}
|
||||
if (!selectedPlanId) {
|
||||
showError(t('请选择订阅套餐'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/subscription/admin/bind', {
|
||||
user_id: user.id,
|
||||
plan_id: selectedPlanId,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('绑定成功'));
|
||||
onSuccess?.();
|
||||
onCancel?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('绑定失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('绑定订阅套餐')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={bind}
|
||||
confirmLoading={loading}
|
||||
maskClosable={false}
|
||||
centered
|
||||
>
|
||||
<Space vertical style={{ width: '100%' }} spacing='medium'>
|
||||
<div className='text-sm'>
|
||||
<Text strong>{t('用户')}:</Text>
|
||||
<Text>{user?.username}</Text>
|
||||
<Text type='tertiary'> (ID: {user?.id})</Text>
|
||||
</div>
|
||||
<Select
|
||||
placeholder={t('选择订阅套餐')}
|
||||
optionList={planOptions}
|
||||
value={selectedPlanId}
|
||||
onChange={setSelectedPlanId}
|
||||
loading={loading}
|
||||
filter
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div className='text-xs text-gray-500'>
|
||||
{t('绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。')}
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BindSubscriptionModal;
|
||||
|
||||
Reference in New Issue
Block a user