test-napi / web /src /components /topup /SubscriptionPlansCard.jsx
Kyou0203's picture
init for HF Space
daa8246
/*
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, useState } from 'react';
import {
Badge,
Button,
Card,
Divider,
Select,
Skeleton,
Space,
Tag,
Tooltip,
Typography,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess, renderQuota } from '../../helpers';
import { getCurrencyConfig } from '../../helpers/render';
import { RefreshCw, Sparkles } from 'lucide-react';
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
import {
formatSubscriptionDuration,
formatSubscriptionResetPeriod,
} from '../../helpers/subscriptionFormat';
const { Text } = Typography;
// 过滤易支付方式
function getEpayMethods(payMethods = []) {
return (payMethods || []).filter(
(m) => m?.type && m.type !== 'stripe' && m.type !== 'creem',
);
}
// 提交易支付表单
function submitEpayForm({ url, params }) {
const form = document.createElement('form');
form.action = url;
form.method = 'POST';
const isSafari =
navigator.userAgent.indexOf('Safari') > -1 &&
navigator.userAgent.indexOf('Chrome') < 1;
if (!isSafari) form.target = '_blank';
Object.keys(params || {}).forEach((key) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = params[key];
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
const SubscriptionPlansCard = ({
t,
loading = false,
plans = [],
payMethods = [],
enableOnlineTopUp = false,
enableStripeTopUp = false,
enableCreemTopUp = false,
billingPreference,
onChangeBillingPreference,
activeSubscriptions = [],
allSubscriptions = [],
reloadSubscriptionSelf,
withCard = true,
}) => {
const [open, setOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState(null);
const [paying, setPaying] = useState(false);
const [selectedEpayMethod, setSelectedEpayMethod] = useState('');
const [refreshing, setRefreshing] = useState(false);
const epayMethods = useMemo(() => getEpayMethods(payMethods), [payMethods]);
const openBuy = (p) => {
setSelectedPlan(p);
setSelectedEpayMethod(epayMethods?.[0]?.type || '');
setOpen(true);
};
const closeBuy = () => {
setOpen(false);
setSelectedPlan(null);
setPaying(false);
};
const handleRefresh = async () => {
setRefreshing(true);
try {
await reloadSubscriptionSelf?.();
} finally {
setRefreshing(false);
}
};
const payStripe = async () => {
if (!selectedPlan?.plan?.stripe_price_id) {
showError(t('该套餐未配置 Stripe'));
return;
}
setPaying(true);
try {
const res = await API.post('/api/subscription/stripe/pay', {
plan_id: selectedPlan.plan.id,
});
if (res.data?.message === 'success') {
window.open(res.data.data?.pay_link, '_blank');
showSuccess(t('已打开支付页面'));
closeBuy();
} else {
const errorMsg =
typeof res.data?.data === 'string'
? res.data.data
: res.data?.message || t('支付失败');
showError(errorMsg);
}
} catch (e) {
showError(t('支付请求失败'));
} finally {
setPaying(false);
}
};
const payCreem = async () => {
if (!selectedPlan?.plan?.creem_product_id) {
showError(t('该套餐未配置 Creem'));
return;
}
setPaying(true);
try {
const res = await API.post('/api/subscription/creem/pay', {
plan_id: selectedPlan.plan.id,
});
if (res.data?.message === 'success') {
window.open(res.data.data?.checkout_url, '_blank');
showSuccess(t('已打开支付页面'));
closeBuy();
} else {
const errorMsg =
typeof res.data?.data === 'string'
? res.data.data
: res.data?.message || t('支付失败');
showError(errorMsg);
}
} catch (e) {
showError(t('支付请求失败'));
} finally {
setPaying(false);
}
};
const payEpay = async () => {
if (!selectedEpayMethod) {
showError(t('请选择支付方式'));
return;
}
setPaying(true);
try {
const res = await API.post('/api/subscription/epay/pay', {
plan_id: selectedPlan.plan.id,
payment_method: selectedEpayMethod,
});
if (res.data?.message === 'success') {
submitEpayForm({ url: res.data.url, params: res.data.data });
showSuccess(t('已发起支付'));
closeBuy();
} else {
const errorMsg =
typeof res.data?.data === 'string'
? res.data.data
: res.data?.message || t('支付失败');
showError(errorMsg);
}
} catch (e) {
showError(t('支付请求失败'));
} finally {
setPaying(false);
}
};
// 当前订阅信息 - 支持多个订阅
const hasActiveSubscription = activeSubscriptions.length > 0;
const hasAnySubscription = allSubscriptions.length > 0;
const disableSubscriptionPreference = !hasActiveSubscription;
const isSubscriptionPreference =
billingPreference === 'subscription_first' ||
billingPreference === 'subscription_only';
const displayBillingPreference =
disableSubscriptionPreference && isSubscriptionPreference
? 'wallet_first'
: billingPreference;
const subscriptionPreferenceLabel =
billingPreference === 'subscription_only' ? t('仅用订阅') : t('优先订阅');
const planPurchaseCountMap = useMemo(() => {
const map = new Map();
(allSubscriptions || []).forEach((sub) => {
const planId = sub?.subscription?.plan_id;
if (!planId) return;
map.set(planId, (map.get(planId) || 0) + 1);
});
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;
// 计算单个订阅的剩余天数
const getRemainingDays = (sub) => {
if (!sub?.subscription?.end_time) return 0;
const now = Date.now() / 1000;
const remaining = sub.subscription.end_time - now;
return Math.max(0, Math.ceil(remaining / 86400));
};
// 计算单个订阅的使用进度
const getUsagePercent = (sub) => {
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);
};
const cardContent = (
<>
{/* 卡片头部 */}
{loading ? (
<div className='space-y-4'>
{/* 我的订阅骨架屏 */}
<Card className='!rounded-xl w-full' bodyStyle={{ padding: '12px' }}>
<div className='flex items-center justify-between mb-3'>
<Skeleton.Title active style={{ width: 100, height: 20 }} />
<Skeleton.Button active style={{ width: 24, height: 24 }} />
</div>
<div className='space-y-2'>
<Skeleton.Paragraph active rows={2} />
</div>
</Card>
{/* 套餐列表骨架屏 */}
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 w-full px-1'>
{[1, 2, 3].map((i) => (
<Card
key={i}
className='!rounded-xl w-full h-full'
bodyStyle={{ padding: 16 }}
>
<Skeleton.Title
active
style={{ width: '60%', height: 24, marginBottom: 8 }}
/>
<Skeleton.Paragraph
active
rows={1}
style={{ marginBottom: 12 }}
/>
<div className='text-center py-4'>
<Skeleton.Title
active
style={{ width: '40%', height: 32, margin: '0 auto' }}
/>
</div>
<Skeleton.Paragraph active rows={3} style={{ marginTop: 12 }} />
<Skeleton.Button
active
block
style={{ marginTop: 16, height: 32 }}
/>
</Card>
))}
</div>
</div>
) : (
<Space vertical style={{ width: '100%' }} spacing={8}>
{/* 当前订阅状态 */}
<Card className='!rounded-xl w-full' bodyStyle={{ padding: '12px' }}>
<div className='flex items-center justify-between mb-2 gap-3'>
<div className='flex items-center gap-2 flex-1 min-w-0'>
<Text strong>{t('我的订阅')}</Text>
{hasActiveSubscription ? (
<Tag
color='white'
size='small'
shape='circle'
prefixIcon={<Badge dot type='success' />}
>
{activeSubscriptions.length} {t('个生效中')}
</Tag>
) : (
<Tag color='white' size='small' shape='circle'>
{t('无生效')}
</Tag>
)}
{allSubscriptions.length > activeSubscriptions.length && (
<Tag color='white' size='small' shape='circle'>
{allSubscriptions.length - activeSubscriptions.length}{' '}
{t('个已过期')}
</Tag>
)}
</div>
<div className='flex items-center gap-2'>
<Select
value={displayBillingPreference}
onChange={onChangeBillingPreference}
size='small'
optionList={[
{
value: 'subscription_first',
label: disableSubscriptionPreference
? `${t('优先订阅')} (${t('无生效')})`
: t('优先订阅'),
disabled: disableSubscriptionPreference,
},
{ value: 'wallet_first', label: t('优先钱包') },
{
value: 'subscription_only',
label: disableSubscriptionPreference
? `${t('仅用订阅')} (${t('无生效')})`
: t('仅用订阅'),
disabled: disableSubscriptionPreference,
},
{ value: 'wallet_only', label: t('仅用钱包') },
]}
/>
<Button
size='small'
theme='light'
type='tertiary'
icon={
<RefreshCw
size={12}
className={refreshing ? 'animate-spin' : ''}
/>
}
onClick={handleRefresh}
loading={refreshing}
/>
</div>
</div>
{disableSubscriptionPreference && isSubscriptionPreference && (
<Text type='tertiary' size='small'>
{t('已保存偏好为')}
{subscriptionPreferenceLabel}
{t(',当前无生效订阅,将自动使用钱包')}
</Text>
)}
{hasAnySubscription ? (
<>
<Divider margin={8} />
<div className='max-h-64 overflow-y-auto pr-1 semi-table-body'>
{allSubscriptions.map((sub, subIndex) => {
const isLast = subIndex === allSubscriptions.length - 1;
const subscription = sub.subscription;
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 planTitle =
planTitleMap.get(subscription?.plan_id) || '';
const remainDays = getRemainingDays(sub);
const usagePercent = getUsagePercent(sub);
const now = Date.now() / 1000;
const isExpired = (subscription?.end_time || 0) < now;
const isCancelled = subscription?.status === 'cancelled';
const isActive =
subscription?.status === 'active' && !isExpired;
return (
<div key={subscription?.id || subIndex}>
{/* 订阅概要 */}
<div className='flex items-center justify-between text-xs mb-2'>
<div className='flex items-center gap-2'>
<span className='font-medium'>
{planTitle
? `${planTitle} · ${t('订阅')} #${subscription?.id}`
: `${t('订阅')} #${subscription?.id}`}
</span>
{isActive ? (
<Tag
color='white'
size='small'
shape='circle'
prefixIcon={<Badge dot type='success' />}
>
{t('生效')}
</Tag>
) : isCancelled ? (
<Tag color='white' size='small' shape='circle'>
{t('已作废')}
</Tag>
) : (
<Tag color='white' size='small' shape='circle'>
{t('已过期')}
</Tag>
)}
</div>
{isActive && (
<span className='text-gray-500'>
{t('剩余')} {remainDays} {t('天')}
</span>
)}
</div>
<div className='text-xs text-gray-500 mb-2'>
{isActive
? t('至')
: isCancelled
? t('作废于')
: t('过期于')}{' '}
{new Date(
(subscription?.end_time || 0) * 1000,
).toLocaleString()}
</div>
<div className='text-xs text-gray-500 mb-2'>
{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}%
</span>
)}
</div>
{!isLast && <Divider margin={12} />}
</div>
);
})}
</div>
</>
) : (
<div className='text-xs text-gray-500'>
{t('购买套餐后即可享受模型权益')}
</div>
)}
</Card>
{/* 可购买套餐 - 标准定价卡片 */}
{plans.length > 0 ? (
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 w-full px-1'>
{plans.map((p, index) => {
const plan = p?.plan;
const totalAmount = Number(plan?.total_amount || 0);
const { symbol, rate } = getCurrencyConfig();
const price = Number(plan?.price_amount || 0);
const convertedPrice = price * rate;
const displayPrice = convertedPrice.toFixed(
Number.isInteger(convertedPrice) ? 0 : 2,
);
const isPopular = index === 0 && plans.length > 1;
const limit = Number(plan?.max_purchase_per_user || 0);
const limitLabel = limit > 0 ? `${t('限购')} ${limit}` : null;
const totalLabel =
totalAmount > 0
? `${t('总额度')}: ${renderQuota(totalAmount)}`
: `${t('总额度')}: ${t('不限')}`;
const upgradeLabel = plan?.upgrade_group
? `${t('升级分组')}: ${plan.upgrade_group}`
: null;
const resetLabel =
formatSubscriptionResetPeriod(plan, t) === t('不重置')
? null
: `${t('额度重置')}: ${formatSubscriptionResetPeriod(plan, t)}`;
const planBenefits = [
{
label: `${t('有效期')}: ${formatSubscriptionDuration(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
key={plan?.id}
className={`!rounded-xl transition-all hover:shadow-lg w-full h-full ${
isPopular ? 'ring-2 ring-purple-500' : ''
}`}
bodyStyle={{ padding: 0 }}
>
<div className='p-4 h-full flex flex-col'>
{/* 推荐标签 */}
{isPopular && (
<div className='mb-2'>
<Tag color='purple' shape='circle' size='small'>
<Sparkles size={10} className='mr-1' />
{t('推荐')}
</Tag>
</div>
)}
{/* 套餐名称 */}
<div className='mb-3'>
<Typography.Title
heading={5}
ellipsis={{ rows: 1, showTooltip: true }}
style={{ margin: 0 }}
>
{plan?.title || t('订阅套餐')}
</Typography.Title>
{plan?.subtitle && (
<Text
type='tertiary'
size='small'
ellipsis={{ rows: 1, showTooltip: true }}
style={{ display: 'block' }}
>
{plan.subtitle}
</Text>
)}
</div>
{/* 价格区域 */}
<div className='py-2'>
<div className='flex items-baseline justify-start'>
<span className='text-xl font-bold text-purple-600'>
{symbol}
</span>
<span className='text-3xl font-bold text-purple-600'>
{displayPrice}
</span>
</div>
</div>
{/* 套餐权益描述 */}
<div className='flex flex-col items-start 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-start'
>
{content}
</div>
);
}
return (
<Tooltip key={item.label} content={item.tooltip}>
<div className='w-full flex justify-start'>
{content}
</div>
</Tooltip>
);
})}
</div>
<div className='mt-auto'>
<Divider margin={12} />
{/* 购买按钮 */}
{(() => {
const count = getPlanPurchaseCount(p?.plan?.id);
const reached = limit > 0 && count >= limit;
const tip = reached
? t('已达到购买上限') + ` (${count}/${limit})`
: '';
const buttonEl = (
<Button
theme='outline'
type='primary'
block
disabled={reached}
onClick={() => {
if (!reached) openBuy(p);
}}
>
{reached ? t('已达上限') : t('立即订阅')}
</Button>
);
return reached ? (
<Tooltip content={tip} position='top'>
{buttonEl}
</Tooltip>
) : (
buttonEl
);
})()}
</div>
</div>
</Card>
);
})}
</div>
) : (
<div className='text-center text-gray-400 text-sm py-4'>
{t('暂无可购买套餐')}
</div>
)}
</Space>
)}
</>
);
return (
<>
{withCard ? (
<Card className='!rounded-2xl shadow-sm border-0'>{cardContent}</Card>
) : (
<div className='space-y-3'>{cardContent}</div>
)}
{/* 购买确认弹窗 */}
<SubscriptionPurchaseModal
t={t}
visible={open}
onCancel={closeBuy}
selectedPlan={selectedPlan}
paying={paying}
selectedEpayMethod={selectedEpayMethod}
setSelectedEpayMethod={setSelectedEpayMethod}
epayMethods={epayMethods}
enableOnlineTopUp={enableOnlineTopUp}
enableStripeTopUp={enableStripeTopUp}
enableCreemTopUp={enableCreemTopUp}
purchaseLimitInfo={
selectedPlan?.plan?.id
? {
limit: Number(selectedPlan?.plan?.max_purchase_per_user || 0),
count: getPlanPurchaseCount(selectedPlan?.plan?.id),
}
: null
}
onPayStripe={payStripe}
onPayCreem={payCreem}
onPayEpay={payEpay}
/>
</>
);
};
export default SubscriptionPlansCard;