| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645 |
- /*
- 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 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={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('仅用钱包') },
- ]}
- />
- <Button
- size='small'
- theme='light'
- type='tertiary'
- icon={
- <RefreshCw
- size={12}
- className={refreshing ? 'animate-spin' : ''}
- />
- }
- onClick={handleRefresh}
- loading={refreshing}
- />
- </div>
- </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-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;
|