SubscriptionPlansCard.jsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useMemo, useState } from 'react';
  16. import {
  17. Badge,
  18. Button,
  19. Card,
  20. Divider,
  21. Select,
  22. Skeleton,
  23. Space,
  24. Tag,
  25. Tooltip,
  26. Typography,
  27. } from '@douyinfe/semi-ui';
  28. import { API, showError, showSuccess, renderQuota } from '../../helpers';
  29. import { getCurrencyConfig } from '../../helpers/render';
  30. import { RefreshCw, Sparkles } from 'lucide-react';
  31. import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
  32. import {
  33. formatSubscriptionDuration,
  34. formatSubscriptionResetPeriod,
  35. } from '../../helpers/subscriptionFormat';
  36. const { Text } = Typography;
  37. // 过滤易支付方式
  38. function getEpayMethods(payMethods = []) {
  39. return (payMethods || []).filter(
  40. (m) => m?.type && m.type !== 'stripe' && m.type !== 'creem',
  41. );
  42. }
  43. // 提交易支付表单
  44. function submitEpayForm({ url, params }) {
  45. const form = document.createElement('form');
  46. form.action = url;
  47. form.method = 'POST';
  48. const isSafari =
  49. navigator.userAgent.indexOf('Safari') > -1 &&
  50. navigator.userAgent.indexOf('Chrome') < 1;
  51. if (!isSafari) form.target = '_blank';
  52. Object.keys(params || {}).forEach((key) => {
  53. const input = document.createElement('input');
  54. input.type = 'hidden';
  55. input.name = key;
  56. input.value = params[key];
  57. form.appendChild(input);
  58. });
  59. document.body.appendChild(form);
  60. form.submit();
  61. document.body.removeChild(form);
  62. }
  63. const SubscriptionPlansCard = ({
  64. t,
  65. loading = false,
  66. plans = [],
  67. payMethods = [],
  68. enableOnlineTopUp = false,
  69. enableStripeTopUp = false,
  70. enableCreemTopUp = false,
  71. billingPreference,
  72. onChangeBillingPreference,
  73. activeSubscriptions = [],
  74. allSubscriptions = [],
  75. reloadSubscriptionSelf,
  76. withCard = true,
  77. }) => {
  78. const [open, setOpen] = useState(false);
  79. const [selectedPlan, setSelectedPlan] = useState(null);
  80. const [paying, setPaying] = useState(false);
  81. const [selectedEpayMethod, setSelectedEpayMethod] = useState('');
  82. const [refreshing, setRefreshing] = useState(false);
  83. const epayMethods = useMemo(() => getEpayMethods(payMethods), [payMethods]);
  84. const openBuy = (p) => {
  85. setSelectedPlan(p);
  86. setSelectedEpayMethod(epayMethods?.[0]?.type || '');
  87. setOpen(true);
  88. };
  89. const closeBuy = () => {
  90. setOpen(false);
  91. setSelectedPlan(null);
  92. setPaying(false);
  93. };
  94. const handleRefresh = async () => {
  95. setRefreshing(true);
  96. try {
  97. await reloadSubscriptionSelf?.();
  98. } finally {
  99. setRefreshing(false);
  100. }
  101. };
  102. const payStripe = async () => {
  103. if (!selectedPlan?.plan?.stripe_price_id) {
  104. showError(t('该套餐未配置 Stripe'));
  105. return;
  106. }
  107. setPaying(true);
  108. try {
  109. const res = await API.post('/api/subscription/stripe/pay', {
  110. plan_id: selectedPlan.plan.id,
  111. });
  112. if (res.data?.message === 'success') {
  113. window.open(res.data.data?.pay_link, '_blank');
  114. showSuccess(t('已打开支付页面'));
  115. closeBuy();
  116. } else {
  117. const errorMsg =
  118. typeof res.data?.data === 'string'
  119. ? res.data.data
  120. : res.data?.message || t('支付失败');
  121. showError(errorMsg);
  122. }
  123. } catch (e) {
  124. showError(t('支付请求失败'));
  125. } finally {
  126. setPaying(false);
  127. }
  128. };
  129. const payCreem = async () => {
  130. if (!selectedPlan?.plan?.creem_product_id) {
  131. showError(t('该套餐未配置 Creem'));
  132. return;
  133. }
  134. setPaying(true);
  135. try {
  136. const res = await API.post('/api/subscription/creem/pay', {
  137. plan_id: selectedPlan.plan.id,
  138. });
  139. if (res.data?.message === 'success') {
  140. window.open(res.data.data?.checkout_url, '_blank');
  141. showSuccess(t('已打开支付页面'));
  142. closeBuy();
  143. } else {
  144. const errorMsg =
  145. typeof res.data?.data === 'string'
  146. ? res.data.data
  147. : res.data?.message || t('支付失败');
  148. showError(errorMsg);
  149. }
  150. } catch (e) {
  151. showError(t('支付请求失败'));
  152. } finally {
  153. setPaying(false);
  154. }
  155. };
  156. const payEpay = async () => {
  157. if (!selectedEpayMethod) {
  158. showError(t('请选择支付方式'));
  159. return;
  160. }
  161. setPaying(true);
  162. try {
  163. const res = await API.post('/api/subscription/epay/pay', {
  164. plan_id: selectedPlan.plan.id,
  165. payment_method: selectedEpayMethod,
  166. });
  167. if (res.data?.message === 'success') {
  168. submitEpayForm({ url: res.data.url, params: res.data.data });
  169. showSuccess(t('已发起支付'));
  170. closeBuy();
  171. } else {
  172. const errorMsg =
  173. typeof res.data?.data === 'string'
  174. ? res.data.data
  175. : res.data?.message || t('支付失败');
  176. showError(errorMsg);
  177. }
  178. } catch (e) {
  179. showError(t('支付请求失败'));
  180. } finally {
  181. setPaying(false);
  182. }
  183. };
  184. // 当前订阅信息 - 支持多个订阅
  185. const hasActiveSubscription = activeSubscriptions.length > 0;
  186. const hasAnySubscription = allSubscriptions.length > 0;
  187. const planPurchaseCountMap = useMemo(() => {
  188. const map = new Map();
  189. (allSubscriptions || []).forEach((sub) => {
  190. const planId = sub?.subscription?.plan_id;
  191. if (!planId) return;
  192. map.set(planId, (map.get(planId) || 0) + 1);
  193. });
  194. return map;
  195. }, [allSubscriptions]);
  196. const planTitleMap = useMemo(() => {
  197. const map = new Map();
  198. (plans || []).forEach((p) => {
  199. const plan = p?.plan;
  200. if (!plan?.id) return;
  201. map.set(plan.id, plan.title || '');
  202. });
  203. return map;
  204. }, [plans]);
  205. const getPlanPurchaseCount = (planId) =>
  206. planPurchaseCountMap.get(planId) || 0;
  207. // 计算单个订阅的剩余天数
  208. const getRemainingDays = (sub) => {
  209. if (!sub?.subscription?.end_time) return 0;
  210. const now = Date.now() / 1000;
  211. const remaining = sub.subscription.end_time - now;
  212. return Math.max(0, Math.ceil(remaining / 86400));
  213. };
  214. // 计算单个订阅的使用进度
  215. const getUsagePercent = (sub) => {
  216. const total = Number(sub?.subscription?.amount_total || 0);
  217. const used = Number(sub?.subscription?.amount_used || 0);
  218. if (total <= 0) return 0;
  219. return Math.round((used / total) * 100);
  220. };
  221. const cardContent = (
  222. <>
  223. {/* 卡片头部 */}
  224. {loading ? (
  225. <div className='space-y-4'>
  226. {/* 我的订阅骨架屏 */}
  227. <Card className='!rounded-xl w-full' bodyStyle={{ padding: '12px' }}>
  228. <div className='flex items-center justify-between mb-3'>
  229. <Skeleton.Title active style={{ width: 100, height: 20 }} />
  230. <Skeleton.Button active style={{ width: 24, height: 24 }} />
  231. </div>
  232. <div className='space-y-2'>
  233. <Skeleton.Paragraph active rows={2} />
  234. </div>
  235. </Card>
  236. {/* 套餐列表骨架屏 */}
  237. <div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 w-full px-1'>
  238. {[1, 2, 3].map((i) => (
  239. <Card
  240. key={i}
  241. className='!rounded-xl w-full h-full'
  242. bodyStyle={{ padding: 16 }}
  243. >
  244. <Skeleton.Title
  245. active
  246. style={{ width: '60%', height: 24, marginBottom: 8 }}
  247. />
  248. <Skeleton.Paragraph
  249. active
  250. rows={1}
  251. style={{ marginBottom: 12 }}
  252. />
  253. <div className='text-center py-4'>
  254. <Skeleton.Title
  255. active
  256. style={{ width: '40%', height: 32, margin: '0 auto' }}
  257. />
  258. </div>
  259. <Skeleton.Paragraph active rows={3} style={{ marginTop: 12 }} />
  260. <Skeleton.Button
  261. active
  262. block
  263. style={{ marginTop: 16, height: 32 }}
  264. />
  265. </Card>
  266. ))}
  267. </div>
  268. </div>
  269. ) : (
  270. <Space vertical style={{ width: '100%' }} spacing={8}>
  271. {/* 当前订阅状态 */}
  272. <Card className='!rounded-xl w-full' bodyStyle={{ padding: '12px' }}>
  273. <div className='flex items-center justify-between mb-2 gap-3'>
  274. <div className='flex items-center gap-2 flex-1 min-w-0'>
  275. <Text strong>{t('我的订阅')}</Text>
  276. {hasActiveSubscription ? (
  277. <Tag
  278. color='white'
  279. size='small'
  280. shape='circle'
  281. prefixIcon={<Badge dot type='success' />}
  282. >
  283. {activeSubscriptions.length} {t('个生效中')}
  284. </Tag>
  285. ) : (
  286. <Tag color='white' size='small' shape='circle'>
  287. {t('无生效')}
  288. </Tag>
  289. )}
  290. {allSubscriptions.length > activeSubscriptions.length && (
  291. <Tag color='white' size='small' shape='circle'>
  292. {allSubscriptions.length - activeSubscriptions.length}{' '}
  293. {t('个已过期')}
  294. </Tag>
  295. )}
  296. </div>
  297. <div className='flex items-center gap-2'>
  298. <Select
  299. value={billingPreference}
  300. onChange={onChangeBillingPreference}
  301. size='small'
  302. optionList={[
  303. { value: 'subscription_first', label: t('优先订阅') },
  304. { value: 'wallet_first', label: t('优先钱包') },
  305. { value: 'subscription_only', label: t('仅用订阅') },
  306. { value: 'wallet_only', label: t('仅用钱包') },
  307. ]}
  308. />
  309. <Button
  310. size='small'
  311. theme='light'
  312. type='tertiary'
  313. icon={
  314. <RefreshCw
  315. size={12}
  316. className={refreshing ? 'animate-spin' : ''}
  317. />
  318. }
  319. onClick={handleRefresh}
  320. loading={refreshing}
  321. />
  322. </div>
  323. </div>
  324. {hasAnySubscription ? (
  325. <>
  326. <Divider margin={8} />
  327. <div className='max-h-64 overflow-y-auto pr-1 semi-table-body'>
  328. {allSubscriptions.map((sub, subIndex) => {
  329. const isLast = subIndex === allSubscriptions.length - 1;
  330. const subscription = sub.subscription;
  331. const totalAmount = Number(subscription?.amount_total || 0);
  332. const usedAmount = Number(subscription?.amount_used || 0);
  333. const remainAmount =
  334. totalAmount > 0
  335. ? Math.max(0, totalAmount - usedAmount)
  336. : 0;
  337. const planTitle =
  338. planTitleMap.get(subscription?.plan_id) || '';
  339. const remainDays = getRemainingDays(sub);
  340. const usagePercent = getUsagePercent(sub);
  341. const now = Date.now() / 1000;
  342. const isExpired = (subscription?.end_time || 0) < now;
  343. const isActive =
  344. subscription?.status === 'active' && !isExpired;
  345. return (
  346. <div key={subscription?.id || subIndex}>
  347. {/* 订阅概要 */}
  348. <div className='flex items-center justify-between text-xs mb-2'>
  349. <div className='flex items-center gap-2'>
  350. <span className='font-medium'>
  351. {planTitle
  352. ? `${planTitle} · ${t('订阅')} #${subscription?.id}`
  353. : `${t('订阅')} #${subscription?.id}`}
  354. </span>
  355. {isActive ? (
  356. <Tag
  357. color='white'
  358. size='small'
  359. shape='circle'
  360. prefixIcon={<Badge dot type='success' />}
  361. >
  362. {t('生效')}
  363. </Tag>
  364. ) : (
  365. <Tag color='white' size='small' shape='circle'>
  366. {t('已过期')}
  367. </Tag>
  368. )}
  369. </div>
  370. {isActive && (
  371. <span className='text-gray-500'>
  372. {t('剩余')} {remainDays} {t('天')}
  373. </span>
  374. )}
  375. </div>
  376. <div className='text-xs text-gray-500 mb-2'>
  377. {isActive ? t('至') : t('过期于')}{' '}
  378. {new Date(
  379. (subscription?.end_time || 0) * 1000,
  380. ).toLocaleString()}
  381. </div>
  382. <div className='text-xs text-gray-500 mb-2'>
  383. {t('总额度')}:{' '}
  384. {totalAmount > 0 ? (
  385. <Tooltip
  386. content={`${t('原生额度')}:${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`}
  387. >
  388. <span>
  389. {renderQuota(usedAmount)}/
  390. {renderQuota(totalAmount)} · {t('剩余')}{' '}
  391. {renderQuota(remainAmount)}
  392. </span>
  393. </Tooltip>
  394. ) : (
  395. t('不限')
  396. )}
  397. {totalAmount > 0 && (
  398. <span className='ml-2'>
  399. {t('已用')} {usagePercent}%
  400. </span>
  401. )}
  402. </div>
  403. {!isLast && <Divider margin={12} />}
  404. </div>
  405. );
  406. })}
  407. </div>
  408. </>
  409. ) : (
  410. <div className='text-xs text-gray-500'>
  411. {t('购买套餐后即可享受模型权益')}
  412. </div>
  413. )}
  414. </Card>
  415. {/* 可购买套餐 - 标准定价卡片 */}
  416. {plans.length > 0 ? (
  417. <div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 w-full px-1'>
  418. {plans.map((p, index) => {
  419. const plan = p?.plan;
  420. const totalAmount = Number(plan?.total_amount || 0);
  421. const { symbol, rate } = getCurrencyConfig();
  422. const price = Number(plan?.price_amount || 0);
  423. const convertedPrice = price * rate;
  424. const displayPrice = convertedPrice.toFixed(
  425. Number.isInteger(convertedPrice) ? 0 : 2,
  426. );
  427. const isPopular = index === 0 && plans.length > 1;
  428. const limit = Number(plan?.max_purchase_per_user || 0);
  429. const limitLabel = limit > 0 ? `${t('限购')} ${limit}` : null;
  430. const totalLabel =
  431. totalAmount > 0
  432. ? `${t('总额度')}: ${renderQuota(totalAmount)}`
  433. : `${t('总额度')}: ${t('不限')}`;
  434. const upgradeLabel = plan?.upgrade_group
  435. ? `${t('升级分组')}: ${plan.upgrade_group}`
  436. : null;
  437. const resetLabel =
  438. formatSubscriptionResetPeriod(plan, t) === t('不重置')
  439. ? null
  440. : `${t('额度重置')}: ${formatSubscriptionResetPeriod(plan, t)}`;
  441. const planBenefits = [
  442. {
  443. label: `${t('有效期')}: ${formatSubscriptionDuration(plan, t)}`,
  444. },
  445. resetLabel ? { label: resetLabel } : null,
  446. totalAmount > 0
  447. ? {
  448. label: totalLabel,
  449. tooltip: `${t('原生额度')}:${totalAmount}`,
  450. }
  451. : { label: totalLabel },
  452. limitLabel ? { label: limitLabel } : null,
  453. upgradeLabel ? { label: upgradeLabel } : null,
  454. ].filter(Boolean);
  455. return (
  456. <Card
  457. key={plan?.id}
  458. className={`!rounded-xl transition-all hover:shadow-lg w-full h-full ${isPopular ? 'ring-2 ring-purple-500' : ''
  459. }`}
  460. bodyStyle={{ padding: 0 }}
  461. >
  462. <div className='p-4 h-full flex flex-col'>
  463. {/* 推荐标签 */}
  464. {isPopular && (
  465. <div className='mb-2'>
  466. <Tag color='purple' shape='circle' size='small'>
  467. <Sparkles size={10} className='mr-1' />
  468. {t('推荐')}
  469. </Tag>
  470. </div>
  471. )}
  472. {/* 套餐名称 */}
  473. <div className='mb-3'>
  474. <Typography.Title
  475. heading={5}
  476. ellipsis={{ rows: 1, showTooltip: true }}
  477. style={{ margin: 0 }}
  478. >
  479. {plan?.title || t('订阅套餐')}
  480. </Typography.Title>
  481. {plan?.subtitle && (
  482. <Text
  483. type='tertiary'
  484. size='small'
  485. ellipsis={{ rows: 1, showTooltip: true }}
  486. style={{ display: 'block' }}
  487. >
  488. {plan.subtitle}
  489. </Text>
  490. )}
  491. </div>
  492. {/* 价格区域 */}
  493. <div className='py-2'>
  494. <div className='flex items-baseline justify-start'>
  495. <span className='text-xl font-bold text-purple-600'>
  496. {symbol}
  497. </span>
  498. <span className='text-3xl font-bold text-purple-600'>
  499. {displayPrice}
  500. </span>
  501. </div>
  502. </div>
  503. {/* 套餐权益描述 */}
  504. <div className='flex flex-col items-start gap-1 pb-2'>
  505. {planBenefits.map((item) => {
  506. const content = (
  507. <div className='flex items-center gap-2 text-xs text-gray-500'>
  508. <Badge dot type='tertiary' />
  509. <span>{item.label}</span>
  510. </div>
  511. );
  512. if (!item.tooltip) {
  513. return (
  514. <div
  515. key={item.label}
  516. className='w-full flex justify-start'
  517. >
  518. {content}
  519. </div>
  520. );
  521. }
  522. return (
  523. <Tooltip key={item.label} content={item.tooltip}>
  524. <div className='w-full flex justify-start'>
  525. {content}
  526. </div>
  527. </Tooltip>
  528. );
  529. })}
  530. </div>
  531. <div className='mt-auto'>
  532. <Divider margin={12} />
  533. {/* 购买按钮 */}
  534. {(() => {
  535. const count = getPlanPurchaseCount(p?.plan?.id);
  536. const reached = limit > 0 && count >= limit;
  537. const tip = reached
  538. ? t('已达到购买上限') + ` (${count}/${limit})`
  539. : '';
  540. const buttonEl = (
  541. <Button
  542. theme='outline'
  543. type='primary'
  544. block
  545. disabled={reached}
  546. onClick={() => {
  547. if (!reached) openBuy(p);
  548. }}
  549. >
  550. {reached ? t('已达上限') : t('立即订阅')}
  551. </Button>
  552. );
  553. return reached ? (
  554. <Tooltip content={tip} position='top'>
  555. {buttonEl}
  556. </Tooltip>
  557. ) : (
  558. buttonEl
  559. );
  560. })()}
  561. </div>
  562. </div>
  563. </Card>
  564. );
  565. })}
  566. </div>
  567. ) : (
  568. <div className='text-center text-gray-400 text-sm py-4'>
  569. {t('暂无可购买套餐')}
  570. </div>
  571. )}
  572. </Space>
  573. )}
  574. </>
  575. );
  576. return (
  577. <>
  578. {withCard ? (
  579. <Card className='!rounded-2xl shadow-sm border-0'>{cardContent}</Card>
  580. ) : (
  581. <div className='space-y-3'>{cardContent}</div>
  582. )}
  583. {/* 购买确认弹窗 */}
  584. <SubscriptionPurchaseModal
  585. t={t}
  586. visible={open}
  587. onCancel={closeBuy}
  588. selectedPlan={selectedPlan}
  589. paying={paying}
  590. selectedEpayMethod={selectedEpayMethod}
  591. setSelectedEpayMethod={setSelectedEpayMethod}
  592. epayMethods={epayMethods}
  593. enableOnlineTopUp={enableOnlineTopUp}
  594. enableStripeTopUp={enableStripeTopUp}
  595. enableCreemTopUp={enableCreemTopUp}
  596. purchaseLimitInfo={
  597. selectedPlan?.plan?.id
  598. ? {
  599. limit: Number(selectedPlan?.plan?.max_purchase_per_user || 0),
  600. count: getPlanPurchaseCount(selectedPlan?.plan?.id),
  601. }
  602. : null
  603. }
  604. onPayStripe={payStripe}
  605. onPayCreem={payCreem}
  606. onPayEpay={payEpay}
  607. />
  608. </>
  609. );
  610. };
  611. export default SubscriptionPlansCard;