mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-30 21:21:46 +00:00
🚀 refactor: Simplify subscription quota to total amount model
Remove per-model subscription items and switch to a single total quota per plan and user subscription. Update billing, reset, and logging flows to operate on total quota, and refactor admin/user UI to configure and display total quota consistently.
This commit is contained in:
@@ -33,8 +33,6 @@ import { convertUSDToCurrency } from '../../../helpers/render';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
|
||||
|
||||
function formatDuration(plan, t) {
|
||||
if (!plan) return '';
|
||||
const u = plan.duration_unit || 'month';
|
||||
@@ -68,8 +66,6 @@ function formatResetPeriod(plan, t) {
|
||||
const renderPlanTitle = (text, record, t) => {
|
||||
const subtitle = record?.plan?.subtitle;
|
||||
const plan = record?.plan;
|
||||
const items = record?.items || [];
|
||||
|
||||
const popoverContent = (
|
||||
<div style={{ width: 260 }}>
|
||||
<Text strong>{text}</Text>
|
||||
@@ -84,6 +80,8 @@ 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?.total_amount > 0 ? plan.total_amount : t('不限')}</Text>
|
||||
<Text type='tertiary'>{t('购买上限')}</Text>
|
||||
<Text>
|
||||
{plan?.max_purchase_per_user > 0
|
||||
@@ -94,10 +92,6 @@ const renderPlanTitle = (text, record, t) => {
|
||||
<Text>{formatDuration(plan, t)}</Text>
|
||||
<Text type='tertiary'>{t('重置')}</Text>
|
||||
<Text>{formatResetPeriod(plan, t)}</Text>
|
||||
<Text type='tertiary'>{t('模型')}</Text>
|
||||
<Text>
|
||||
{items.length} {t('个')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -165,54 +159,12 @@ const renderEnabled = (text, record, t) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderModels = (text, record, t) => {
|
||||
const items = record?.items || [];
|
||||
if (items.length === 0) {
|
||||
return <Text type='tertiary'>—</Text>;
|
||||
}
|
||||
|
||||
const popoverContent = (
|
||||
<div style={{ maxWidth: 320, maxHeight: 260, overflowY: 'auto' }}>
|
||||
<Text strong>
|
||||
{t('模型权益')} ({items.length})
|
||||
</Text>
|
||||
<Divider margin={8} />
|
||||
<Space vertical align='start' spacing={6}>
|
||||
{items.map((it, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth: 180 }}>
|
||||
{it.model_name}
|
||||
</Text>
|
||||
<Space spacing={8}>
|
||||
<Tag
|
||||
color={it.quota_type === 1 ? 'amber' : 'teal'}
|
||||
shape='circle'
|
||||
>
|
||||
{quotaTypeLabel(it.quota_type)}
|
||||
</Tag>
|
||||
<Text type='secondary'>{it.amount_total}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTotalAmount = (text, record, t) => {
|
||||
const total = Number(record?.plan?.total_amount || 0);
|
||||
return (
|
||||
<Popover content={popoverContent} position='leftTop' showArrow>
|
||||
<Tag color='blue' shape='circle' style={{ cursor: 'pointer' }}>
|
||||
{items.length} {t('个模型')}
|
||||
</Tag>
|
||||
</Popover>
|
||||
<Text type={total > 0 ? 'secondary' : 'tertiary'}>
|
||||
{total > 0 ? total : t('不限')}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -359,9 +311,9 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
|
||||
render: (text, record) => renderPaymentConfig(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('模型'),
|
||||
title: t('总额度'),
|
||||
width: 100,
|
||||
render: (text, record) => renderModels(text, record, t),
|
||||
render: (text, record) => renderTotalAmount(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
|
||||
@@ -41,7 +41,6 @@ const SubscriptionsPage = () => {
|
||||
openCreate,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
pricingModels,
|
||||
t,
|
||||
} = subscriptionsData;
|
||||
|
||||
@@ -52,7 +51,6 @@ const SubscriptionsPage = () => {
|
||||
handleClose={closeEdit}
|
||||
editingPlan={editingPlan}
|
||||
placement={sheetPlacement}
|
||||
pricingModels={pricingModels}
|
||||
refresh={refresh}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -17,22 +17,18 @@ 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 React, { 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';
|
||||
@@ -40,11 +36,15 @@ import {
|
||||
IconCalendarClock,
|
||||
IconClose,
|
||||
IconCreditCard,
|
||||
IconPlusCircle,
|
||||
IconSave,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Trash2, Clock, Boxes, RefreshCw } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { Clock, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../../../helpers';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
@@ -65,14 +65,11 @@ const resetPeriodOptions = [
|
||||
{ value: 'custom', label: '自定义(秒)' },
|
||||
];
|
||||
|
||||
const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
|
||||
|
||||
const AddEditSubscriptionModal = ({
|
||||
visible,
|
||||
handleClose,
|
||||
editingPlan,
|
||||
placement = 'left',
|
||||
pricingModels = [],
|
||||
refresh,
|
||||
t,
|
||||
}) => {
|
||||
@@ -95,17 +92,11 @@ const AddEditSubscriptionModal = ({
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
max_purchase_per_user: 0,
|
||||
total_amount: 0,
|
||||
stripe_price_id: '',
|
||||
creem_product_id: '',
|
||||
});
|
||||
|
||||
const [items, setItems] = useState([]);
|
||||
// Model benefits UX
|
||||
const [pendingModels, setPendingModels] = useState([]);
|
||||
const [defaultNewAmountTotal, setDefaultNewAmountTotal] = useState(0);
|
||||
const [bulkAmountTotal, setBulkAmountTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
|
||||
const buildFormValues = () => {
|
||||
const base = getInitValues();
|
||||
if (editingPlan?.plan?.id === undefined) return base;
|
||||
@@ -124,152 +115,17 @@ const AddEditSubscriptionModal = ({
|
||||
enabled: p.enabled !== false,
|
||||
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),
|
||||
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 addPendingModels = () => {
|
||||
const selected = (pendingModels || []).filter(Boolean);
|
||||
if (selected.length === 0) {
|
||||
showError(t('请选择要添加的模型'));
|
||||
return;
|
||||
}
|
||||
const existing = new Set((items || []).map((it) => it.model_name));
|
||||
const toAdd = selected.filter((name) => !existing.has(name));
|
||||
if (toAdd.length === 0) {
|
||||
showError(t('所选模型已全部存在'));
|
||||
return;
|
||||
}
|
||||
const defaultAmount = Number(defaultNewAmountTotal || 0);
|
||||
const next = [...items];
|
||||
toAdd.forEach((modelName) => {
|
||||
const modelMeta = modelOptions.find((m) => m.value === modelName);
|
||||
if (!modelMeta) return;
|
||||
next.push({
|
||||
model_name: modelName,
|
||||
quota_type: modelMeta.quota_type,
|
||||
amount_total:
|
||||
Number.isFinite(defaultAmount) && defaultAmount >= 0
|
||||
? defaultAmount
|
||||
: 0,
|
||||
});
|
||||
});
|
||||
setItems(next);
|
||||
setPendingModels([]);
|
||||
showSuccess(t('已添加'));
|
||||
};
|
||||
|
||||
const applyBulkAmountTotal = ({ scope }) => {
|
||||
const n = Number(bulkAmountTotal || 0);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
showError(t('请输入有效的数量'));
|
||||
return;
|
||||
}
|
||||
if (!items || items.length === 0) {
|
||||
showError(t('请先添加模型权益'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope === 'selected') {
|
||||
if (!selectedRowKeys || selectedRowKeys.length === 0) {
|
||||
showError(t('请先勾选要批量设置的权益'));
|
||||
return;
|
||||
}
|
||||
const keySet = new Set(selectedRowKeys);
|
||||
setItems(
|
||||
items.map((it) => {
|
||||
const k = `${it.model_name}-${it.quota_type}`;
|
||||
if (!keySet.has(k)) return it;
|
||||
return { ...it, amount_total: n };
|
||||
}),
|
||||
);
|
||||
showSuccess(t('已对选中项批量设置'));
|
||||
return;
|
||||
}
|
||||
|
||||
// scope === 'all'
|
||||
setItems(items.map((it) => ({ ...it, amount_total: n })));
|
||||
showSuccess(t('已对全部批量设置'));
|
||||
};
|
||||
|
||||
const deleteSelectedItems = () => {
|
||||
if (!selectedRowKeys || selectedRowKeys.length === 0) {
|
||||
showError(t('请先勾选要删除的权益'));
|
||||
return;
|
||||
}
|
||||
const keySet = new Set(selectedRowKeys);
|
||||
const next = (items || []).filter(
|
||||
(it) => !keySet.has(`${it.model_name}-${it.quota_type}`),
|
||||
);
|
||||
setItems(next);
|
||||
setSelectedRowKeys([]);
|
||||
showSuccess(t('已删除选中项'));
|
||||
};
|
||||
|
||||
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 = {
|
||||
@@ -286,8 +142,8 @@ const AddEditSubscriptionModal = ({
|
||||
: 0,
|
||||
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),
|
||||
},
|
||||
items: cleanedItems,
|
||||
};
|
||||
if (editingPlan?.plan?.id) {
|
||||
const res = await API.put(
|
||||
@@ -318,48 +174,6 @@ const AddEditSubscriptionModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
@@ -382,7 +196,7 @@ const AddEditSubscriptionModal = ({
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={isMobile ? '100%' : 700}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
@@ -470,6 +284,27 @@ const AddEditSubscriptionModal = ({
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.AutoComplete
|
||||
field='total_amount'
|
||||
label={t('总额度')}
|
||||
type='number'
|
||||
rules={[{ required: true, message: t('请输入总额度') }]}
|
||||
extraText={`${t('0 表示不限')} · ${renderQuotaWithPrompt(
|
||||
Number(values.total_amount || 0),
|
||||
)}`}
|
||||
data={[
|
||||
{ value: 500000, label: '1' },
|
||||
{ value: 5000000, label: '10' },
|
||||
{ value: 25000000, label: '50' },
|
||||
{ value: 50000000, label: '100' },
|
||||
{ value: 250000000, label: '500' },
|
||||
{ value: 500000000, label: '1000' },
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field='currency'
|
||||
@@ -665,123 +500,6 @@ const AddEditSubscriptionModal = ({
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 模型权益 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center justify-between mb-3 gap-3'>
|
||||
<div className='flex items-center'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='orange'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<Boxes size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('模型权益')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('配置套餐可使用的模型及额度')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具栏:最少步骤完成“添加 + 批量设置” */}
|
||||
<div className='flex flex-col gap-2 mb-3'>
|
||||
<div className='flex flex-col md:flex-row gap-2 md:items-center'>
|
||||
<Select
|
||||
placeholder={t('选择模型(可多选)')}
|
||||
multiple
|
||||
filter
|
||||
value={pendingModels}
|
||||
onChange={setPendingModels}
|
||||
style={{ width: '100%', flex: 1 }}
|
||||
>
|
||||
{modelOptions.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<InputNumber
|
||||
value={Number(defaultNewAmountTotal || 0)}
|
||||
min={0}
|
||||
precision={0}
|
||||
onChange={(v) => setDefaultNewAmountTotal(v)}
|
||||
style={{ width: isMobile ? '100%' : 180 }}
|
||||
placeholder={t('默认数量')}
|
||||
/>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
icon={<IconPlusCircle />}
|
||||
onClick={addPendingModels}
|
||||
>
|
||||
{t('添加')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col md:flex-row gap-2 md:items-center md:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tag color='white' shape='circle'>
|
||||
{t('已选')} {selectedRowKeys?.length || 0}
|
||||
</Tag>
|
||||
<InputNumber
|
||||
value={Number(bulkAmountTotal || 0)}
|
||||
min={0}
|
||||
precision={0}
|
||||
onChange={(v) => setBulkAmountTotal(v)}
|
||||
style={{ width: isMobile ? '100%' : 220 }}
|
||||
placeholder={t('统一设置数量')}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 justify-end'>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={() =>
|
||||
applyBulkAmountTotal({ scope: 'selected' })
|
||||
}
|
||||
>
|
||||
{t('应用到选中')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={() => applyBulkAmountTotal({ scope: 'all' })}
|
||||
>
|
||||
{t('应用到全部')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='danger'
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={deleteSelectedItems}
|
||||
>
|
||||
{t('删除选中')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={itemColumns}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys || []),
|
||||
}}
|
||||
rowKey={(row) => `${row.model_name}-${row.quota_type}`}
|
||||
empty={
|
||||
<div className='py-6 text-center text-gray-500'>
|
||||
{t('尚未添加任何模型')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
Button,
|
||||
Empty,
|
||||
Modal,
|
||||
Popover,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
@@ -295,34 +294,17 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('权益'),
|
||||
key: 'items',
|
||||
width: 80,
|
||||
title: t('总额度'),
|
||||
key: 'total',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
const items = record?.items || [];
|
||||
if (items.length === 0) return <Text type='tertiary'>-</Text>;
|
||||
const content = (
|
||||
<div className='max-w-[320px] space-y-1'>
|
||||
{items.map((it) => (
|
||||
<div
|
||||
key={`${it.id}-${it.model_name}`}
|
||||
className='flex justify-between text-xs'
|
||||
>
|
||||
<span className='truncate mr-2'>{it.model_name}</span>
|
||||
<span className='text-gray-600'>
|
||||
{it.amount_used}/{it.amount_total}
|
||||
{it.quota_type === 1 ? t('次') : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const sub = record?.subscription;
|
||||
const total = Number(sub?.amount_total || 0);
|
||||
const used = Number(sub?.amount_used || 0);
|
||||
return (
|
||||
<Popover content={content} position='top' showArrow>
|
||||
<Tag color='white' shape='circle'>
|
||||
{items.length} {t('项')}
|
||||
</Tag>
|
||||
</Popover>
|
||||
<Text type={total > 0 ? 'secondary' : 'tertiary'}>
|
||||
{total > 0 ? `${used}/${total}` : t('不限')}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { getCurrencyConfig, stringToColor } from '../../helpers/render';
|
||||
import { CalendarClock, Check, Crown, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { Crown, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -245,16 +245,10 @@ const SubscriptionPlansCard = ({
|
||||
|
||||
// 计算单个订阅的使用进度
|
||||
const getUsagePercent = (sub) => {
|
||||
const items = sub?.items || [];
|
||||
if (items.length === 0) return 0;
|
||||
let totalUsed = 0;
|
||||
let totalAmount = 0;
|
||||
items.forEach((it) => {
|
||||
totalUsed += Number(it.amount_used || 0);
|
||||
totalAmount += Number(it.amount_total || 0);
|
||||
});
|
||||
if (totalAmount === 0) return 0;
|
||||
return Math.round((totalUsed / totalAmount) * 100);
|
||||
const total = Number(sub?.subscription?.amount_total || 0);
|
||||
const used = Number(sub?.subscription?.amount_used || 0);
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((used / total) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -374,7 +368,12 @@ const SubscriptionPlansCard = ({
|
||||
{allSubscriptions.map((sub, subIndex) => {
|
||||
const isLast = subIndex === allSubscriptions.length - 1;
|
||||
const subscription = sub.subscription;
|
||||
const items = sub.items || [];
|
||||
const totalAmount = Number(subscription?.amount_total || 0);
|
||||
const usedAmount = Number(subscription?.amount_used || 0);
|
||||
const remainAmount =
|
||||
totalAmount > 0
|
||||
? Math.max(0, totalAmount - usedAmount)
|
||||
: 0;
|
||||
const remainDays = getRemainingDays(sub);
|
||||
const usagePercent = getUsagePercent(sub);
|
||||
const now = Date.now() / 1000;
|
||||
@@ -418,34 +417,17 @@ const SubscriptionPlansCard = ({
|
||||
(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>
|
||||
)}
|
||||
<div className='text-xs text-gray-500 mb-2'>
|
||||
{t('总额度')}:{' '}
|
||||
{totalAmount > 0
|
||||
? `${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`
|
||||
: t('不限')}
|
||||
{totalAmount > 0 && (
|
||||
<span className='ml-2'>
|
||||
{t('已用')} {usagePercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isLast && <Divider margin={12} />}
|
||||
</div>
|
||||
);
|
||||
@@ -464,7 +446,7 @@ const SubscriptionPlansCard = ({
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||
{plans.map((p, index) => {
|
||||
const plan = p?.plan;
|
||||
const planItems = p?.items || [];
|
||||
const totalAmount = Number(plan?.total_amount || 0);
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
const price = Number(plan?.price_amount || 0);
|
||||
const displayPrice = (price * rate).toFixed(
|
||||
@@ -474,10 +456,14 @@ const SubscriptionPlansCard = ({
|
||||
const limit = Number(plan?.max_purchase_per_user || 0);
|
||||
const limitLabel =
|
||||
limit > 0 ? `${t('限购')} ${limit}` : t('不限购');
|
||||
const totalLabel =
|
||||
totalAmount > 0
|
||||
? `${t('总额度')}: ${totalAmount}`
|
||||
: `${t('总额度')}: ${t('不限')}`;
|
||||
const planTags = [
|
||||
`${t('有效期')}: ${formatDuration(plan, t)}`,
|
||||
`${t('重置')}: ${formatResetPeriod(plan, t)}`,
|
||||
`${t('权益')}: ${planItems.length} ${t('项')}`,
|
||||
totalLabel,
|
||||
limitLabel,
|
||||
];
|
||||
|
||||
@@ -548,35 +534,6 @@ const SubscriptionPlansCard = ({
|
||||
|
||||
<Divider margin={12} />
|
||||
|
||||
{/* 权益列表 */}
|
||||
<div className='space-y-2 mb-4'>
|
||||
{planItems.slice(0, 5).map((it, idx) => (
|
||||
<div key={idx} className='flex items-center text-sm'>
|
||||
<Check
|
||||
size={14}
|
||||
className='text-green-500 mr-2 flex-shrink-0'
|
||||
/>
|
||||
<span className='truncate flex-1'>
|
||||
{it.model_name}
|
||||
</span>
|
||||
<Tag size='small' color='white' shape='circle'>
|
||||
{it.amount_total}
|
||||
{it.quota_type === 1 ? t('次') : ''}
|
||||
</Tag>
|
||||
</div>
|
||||
))}
|
||||
{planItems.length > 5 && (
|
||||
<div className='text-xs text-gray-400 text-center'>
|
||||
+{planItems.length - 5} {t('项更多权益')}
|
||||
</div>
|
||||
)}
|
||||
{planItems.length === 0 && (
|
||||
<div className='text-xs text-gray-400 text-center py-2'>
|
||||
{t('暂无权益配置')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 购买按钮 */}
|
||||
{(() => {
|
||||
const count = getPlanPurchaseCount(p?.plan?.id);
|
||||
|
||||
@@ -23,12 +23,11 @@ import {
|
||||
Modal,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Button,
|
||||
Select,
|
||||
Divider,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Crown, CalendarClock, Package, Check } from 'lucide-react';
|
||||
import { Crown, CalendarClock, Package } from 'lucide-react';
|
||||
import { SiStripe } from 'react-icons/si';
|
||||
import { IconCreditCard } from '@douyinfe/semi-icons';
|
||||
import { getCurrencyConfig } from '../../../helpers/render';
|
||||
@@ -89,7 +88,7 @@ const SubscriptionPurchaseModal = ({
|
||||
onPayEpay,
|
||||
}) => {
|
||||
const plan = selectedPlan?.plan;
|
||||
const items = selectedPlan?.items || [];
|
||||
const totalAmount = Number(plan?.total_amount || 0);
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
const price = plan ? Number(plan.price_amount || 0) : 0;
|
||||
const displayPrice = (price * rate).toFixed(price % 1 === 0 ? 0 : 2);
|
||||
@@ -156,12 +155,12 @@ const SubscriptionPurchaseModal = ({
|
||||
</div>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('包含权益')}:
|
||||
{t('总额度')}:
|
||||
</Text>
|
||||
<div className='flex items-center'>
|
||||
<Package size={14} className='mr-1 text-slate-500' />
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{items.length} {t('项')}
|
||||
{totalAmount > 0 ? totalAmount : t('不限')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,35 +177,6 @@ const SubscriptionPurchaseModal = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 权益列表 */}
|
||||
{items.length > 0 && (
|
||||
<div className='space-y-2'>
|
||||
<Text size='small' type='tertiary'>
|
||||
{t('权益明细')}:
|
||||
</Text>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{items.slice(0, 6).map((it, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size='small'
|
||||
color='white'
|
||||
type='light'
|
||||
shape='circle'
|
||||
>
|
||||
<Check size={10} className='mr-1' />
|
||||
{it.model_name}: {it.amount_total}
|
||||
{it.quota_type === 1 ? t('次') : ''}
|
||||
</Tag>
|
||||
))}
|
||||
{items.length > 6 && (
|
||||
<Tag size='small' color='white' type='light' shape='circle'>
|
||||
+{items.length - 6}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 支付方式 */}
|
||||
{purchaseLimitReached && (
|
||||
<Banner
|
||||
|
||||
@@ -29,7 +29,6 @@ export const useSubscriptionsData = () => {
|
||||
// State management
|
||||
const [allPlans, setAllPlans] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pricingModels, setPricingModels] = useState([]);
|
||||
|
||||
// Pagination (client-side for now)
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
@@ -40,18 +39,6 @@ export const useSubscriptionsData = () => {
|
||||
const [editingPlan, setEditingPlan] = useState(null);
|
||||
const [sheetPlacement, setSheetPlacement] = useState('left'); // 'left' | 'right'
|
||||
|
||||
// Load pricing models for dropdown
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/pricing');
|
||||
if (res.data?.success) {
|
||||
setPricingModels(res.data.data || []);
|
||||
}
|
||||
} catch (e) {
|
||||
setPricingModels([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Load subscription plans
|
||||
const loadPlans = async () => {
|
||||
setLoading(true);
|
||||
@@ -133,7 +120,6 @@ export const useSubscriptionsData = () => {
|
||||
|
||||
// Initialize data on component mount
|
||||
useEffect(() => {
|
||||
loadModels();
|
||||
loadPlans();
|
||||
}, []);
|
||||
|
||||
@@ -148,7 +134,6 @@ export const useSubscriptionsData = () => {
|
||||
plans,
|
||||
planCount,
|
||||
loading,
|
||||
pricingModels,
|
||||
|
||||
// Modal state
|
||||
showEdit,
|
||||
|
||||
@@ -517,14 +517,11 @@ export const useLogsData = () => {
|
||||
if (other?.billing_source === 'subscription') {
|
||||
const planId = other?.subscription_plan_id;
|
||||
const planTitle = other?.subscription_plan_title || '';
|
||||
const itemId = other?.subscription_item_id;
|
||||
const quotaType = other?.subscription_quota_type;
|
||||
const unit = quotaType === 1 ? t('次') : t('额度');
|
||||
const subscriptionId = other?.subscription_id;
|
||||
const unit = t('额度');
|
||||
const pre = other?.subscription_pre_consumed ?? 0;
|
||||
const postDelta = other?.subscription_post_delta ?? 0;
|
||||
const finalConsumed =
|
||||
other?.subscription_consumed ??
|
||||
(quotaType === 1 ? 1 : pre + postDelta);
|
||||
const finalConsumed = other?.subscription_consumed ?? pre + postDelta;
|
||||
const remain = other?.subscription_remain;
|
||||
const total = other?.subscription_total;
|
||||
// Use multiple Description items to avoid an overlong single line.
|
||||
@@ -534,20 +531,15 @@ export const useLogsData = () => {
|
||||
value: `#${planId} ${planTitle}`.trim(),
|
||||
});
|
||||
}
|
||||
if (itemId) {
|
||||
if (subscriptionId) {
|
||||
expandDataLocal.push({
|
||||
key: t('订阅权益'),
|
||||
value:
|
||||
quotaType === 1
|
||||
? `${t('权益ID')} ${itemId} · ${t('按次')}(1 ${t('次')}/${t('请求')})`
|
||||
: `${t('权益ID')} ${itemId} · ${t('按量')}`,
|
||||
key: t('订阅实例'),
|
||||
value: `#${subscriptionId}`,
|
||||
});
|
||||
}
|
||||
const settlementLines = [
|
||||
`${t('预扣')}:${pre} ${unit}`,
|
||||
quotaType === 0
|
||||
? `${t('结算差额')}:${postDelta > 0 ? '+' : ''}${postDelta} ${unit}`
|
||||
: null,
|
||||
`${t('结算差额')}:${postDelta > 0 ? '+' : ''}${postDelta} ${unit}`,
|
||||
`${t('最终抵扣')}:${finalConsumed} ${unit}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
Reference in New Issue
Block a user