630 lines
23 KiB
React
630 lines
23 KiB
React
|
|
/*
|
|||
|
|
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 {
|
|||
|
|
Avatar,
|
|||
|
|
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 { Crown, 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,
|
|||
|
|
}) => {
|
|||
|
|
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 {
|
|||
|
|
showError(res.data?.data || res.data?.message || t('支付失败'));
|
|||
|
|
}
|
|||
|
|
} 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 {
|
|||
|
|
showError(res.data?.data || res.data?.message || t('支付失败'));
|
|||
|
|
}
|
|||
|
|
} 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 {
|
|||
|
|
showError(res.data?.data || res.data?.message || t('支付失败'));
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
showError(t('支付请求失败'));
|
|||
|
|
} finally {
|
|||
|
|
setPaying(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 当前订阅信息 - 支持多个订阅
|
|||
|
|
const hasActiveSubscription = activeSubscriptions.length > 0;
|
|||
|
|
const hasAnySubscription = allSubscriptions.length > 0;
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Card className='!rounded-2xl shadow-sm border-0'>
|
|||
|
|
{/* 卡片头部 */}
|
|||
|
|
<div className='flex items-center justify-between mb-3'>
|
|||
|
|
<div className='flex items-center'>
|
|||
|
|
<Avatar size='small' color='violet' className='mr-3 shadow-md'>
|
|||
|
|
<Crown size={16} />
|
|||
|
|
</Avatar>
|
|||
|
|
<div>
|
|||
|
|
<Text className='text-lg font-medium'>{t('订阅套餐')}</Text>
|
|||
|
|
<div className='text-xs'>{t('购买订阅获得模型额度/次数')}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{/* 扣费策略 - 右上角 */}
|
|||
|
|
<Select
|
|||
|
|
value={billingPreference}
|
|||
|
|
onChange={onChangeBillingPreference}
|
|||
|
|
size='small'
|
|||
|
|
optionList={[
|
|||
|
|
{ value: 'subscription_first', label: t('优先订阅') },
|
|||
|
|
{ value: 'wallet_first', label: t('优先钱包') },
|
|||
|
|
{ value: 'subscription_only', label: t('仅用订阅') },
|
|||
|
|
{ value: 'wallet_only', label: t('仅用钱包') },
|
|||
|
|
]}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{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-3 gap-4'>
|
|||
|
|
{[1, 2, 3].map((i) => (
|
|||
|
|
<Card key={i} className='!rounded-xl' 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'>
|
|||
|
|
<div className='flex items-center gap-2'>
|
|||
|
|
<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>
|
|||
|
|
<Button
|
|||
|
|
size='small'
|
|||
|
|
theme='light'
|
|||
|
|
type='tertiary'
|
|||
|
|
icon={
|
|||
|
|
<RefreshCw
|
|||
|
|
size={12}
|
|||
|
|
className={refreshing ? 'animate-spin' : ''}
|
|||
|
|
/>
|
|||
|
|
}
|
|||
|
|
onClick={handleRefresh}
|
|||
|
|
loading={refreshing}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{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 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>
|
|||
|
|
) : (
|
|||
|
|
<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('至') : 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-3 gap-4'>
|
|||
|
|
{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 ${
|
|||
|
|
isPopular ? 'ring-2 ring-purple-500' : ''
|
|||
|
|
}`}
|
|||
|
|
bodyStyle={{ padding: 0 }}
|
|||
|
|
>
|
|||
|
|
<div className='p-4'>
|
|||
|
|
{/* 推荐标签 */}
|
|||
|
|
{isPopular && (
|
|||
|
|
<div className='text-center mb-2'>
|
|||
|
|
<Tag color='purple' shape='circle' size='small'>
|
|||
|
|
<Sparkles size={10} className='mr-1' />
|
|||
|
|
{t('推荐')}
|
|||
|
|
</Tag>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{/* 套餐名称 */}
|
|||
|
|
<div className='text-center 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='text-center py-2'>
|
|||
|
|
<div className='flex items-baseline justify-center'>
|
|||
|
|
<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-center 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-center'
|
|||
|
|
>
|
|||
|
|
{content}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
return (
|
|||
|
|
<Tooltip key={item.label} content={item.tooltip}>
|
|||
|
|
<div className='w-full flex justify-center'>
|
|||
|
|
{content}
|
|||
|
|
</div>
|
|||
|
|
</Tooltip>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<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='tertiary'
|
|||
|
|
block
|
|||
|
|
disabled={reached}
|
|||
|
|
onClick={() => {
|
|||
|
|
if (!reached) openBuy(p);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{reached ? t('已达上限') : t('立即订阅')}
|
|||
|
|
</Button>
|
|||
|
|
);
|
|||
|
|
return reached ? (
|
|||
|
|
<Tooltip content={tip} position='top'>
|
|||
|
|
{buttonEl}
|
|||
|
|
</Tooltip>
|
|||
|
|
) : (
|
|||
|
|
buttonEl
|
|||
|
|
);
|
|||
|
|
})()}
|
|||
|
|
</div>
|
|||
|
|
</Card>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className='text-center text-gray-400 text-sm py-4'>
|
|||
|
|
{t('暂无可购买套餐')}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</Space>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 购买确认弹窗 */}
|
|||
|
|
<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}
|
|||
|
|
/>
|
|||
|
|
</Card>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default SubscriptionPlansCard;
|