✨ feat: add subscription billing system (#2808)
* ci: create docker automation * ✨ feat: add subscription billing system with admin management and user purchase flow Implement a new subscription-based billing model alongside existing metered/per-request billing: Backend: - Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.) - Implement CRUD APIs for subscription plan management (admin only) - Add user subscription queries with support for multiple active/expired subscriptions - Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases - Implement pre-consume and post-consume billing logic for subscription quota tracking - Add billing preference settings (subscription_first, wallet_first, etc.) - Enhance usage logs with subscription deduction details Frontend - Admin: - Add subscription management page with table view and drawer-based edit form - Match UI/UX style with existing admin pages (redemption codes, users) - Support enabling/disabling plans, configuring payment IDs, and model quotas - Add user subscription binding modal in user management Frontend - Wallet: - Add subscription plans card with current subscription status display - Show all subscriptions (active and expired) with remaining days/usage percentage - Display purchasable plans with pricing cards following SaaS best practices - Extract purchase modal to separate component matching payment confirm modal style - Add skeleton loading states with active animation - Implement billing preference selector in card header - Handle payment gateway availability based on admin configuration Frontend - Usage Logs: - Display subscription deduction details in log entries - Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining) - Add subscription deduction tag for subscription-covered requests * ✨ feat(admin): add user subscription management and refine UI/pagination Add admin APIs to list/create/invalidate/delete user subscriptions Add model helpers to fetch all user subscriptions (incl. expired) and support cancel/hard-delete Wire new admin routes for user subscription operations Replace “Bind subscription plan” entry with a dedicated User Subscriptions SideSheet in Users table Use CardTable with responsive layout and working client-side pagination inside the SideSheet Improve subscription purchase modal empty-gateway state with a Banner notice * ✨ feat(admin): streamline subscription plan benefits editor with bulk actions Restore the avatar/icon header for the “Model Benefits” section Replace scattered controls with a compact toolbar-style workflow Support multi-select add with a default quota for new items Add row selection with bulk apply-to-selected / apply-to-all quota updates Enable delete-selected to manage benefits faster and reduce mistakes * ✨ fix(subscription): finalize payments, log billing, and clean up dead code Complete subscription orders by creating a matching top-up record and writing billing logs Add Epay return handler to verify and finalize browser callbacks Require Stripe/Creem webhook configuration before starting subscription payments Show subscription purchases in topup history with clearer labels/methods Remove unused subscription helper, legacy Creem webhook struct, and unused topup fields Simplify subscription self API payload to active/all lists only * 🎨 style: format all code with gofmt and lint:fix Apply consistent code formatting across the entire codebase using gofmt and lint:fix tools. This ensures adherence to Go community standards and improves code readability and maintainability. Changes include: - Run gofmt on all .go files to standardize formatting - Apply lint:fix to automatically resolve linting issues - Fix code style inconsistencies and formatting violations No functional changes were made in this commit. * ✨ feat(subscription): add quota reset periods and admin configuration - Add reset period fields on subscription plans and user items - Apply automatic quota resets during pre-consume based on plan schedule - Expose reset-period configuration in the admin plan editor - Display reset cadence in subscription cards and purchase modal - Validate custom reset seconds on plan create/update * ✨ feat(subscription): harden subscription billing with resets, idempotency, and production-grade stability Add plan-level quota reset periods and display/reset cadence in admin/UI Enforce natural reset alignment with background reset task and cleanup job Make subscription pre-consume/refund idempotent with request-scoped records and retries Use database time for consistent resets across multi-instance deployments Harden payment callbacks with locking and idempotent order completion Record subscription purchases in topup history and billing logs Optimize subscription queries and add critical composite indexes * ✨ feat(subscription): cache plan lookups and stabilize pre-consume Introduce hybrid caches for subscription plans, items, and plan info with explicit invalidation on admin updates. Streamline pre-consume transactions to reduce redundant queries while preserving idempotency and reset logic. * 🐛 fix(subscription): avoid pre-consume lookup noise Use a RowsAffected check for the idempotency lookup so missing records no longer surface as "record not found" errors while preserving behavior. * 🔧 ci: Change workflow trigger to sub branch Update the Docker image workflow to run on pushes to the sub branch instead of main. * 💸 chore: Align subscription pricing display with global currency settings Unify subscription price rendering to use the site-wide currency symbol/rate on the wallet and admin views. Make subscription plan currency read-only in the editor and force USD on create/update to avoid drift. Use global currency display type when creating Creem checkout payloads. * 🔧 chore: Unify subscription plan status toggle with PATCH endpoint Replace separate enable/disable flows with a single PATCH API that updates the enabled flag. Update frontend hooks and table actions to call the unified endpoint and keep UI behavior consistent. Introduce a minimal admin controller handler and route for the status update. * ✨ feat: Add subscription limits and UI tags consistency Add per-plan purchase limits with backend enforcement and UI disable states. Expose limit configuration in admin plan editor and show limits in plan tables/cards. Refine subscription UI tags with unified badge style and streamlined “My Subscriptions” layout. * 🎨 style: tag color to white * 🚀 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. * 🚀 chore: Remove duplicate subscription usage percentage display Keep the usage percentage shown only in the total quota line to avoid redundant “已用 0%” text while preserving remaining days in the summary. * ✨ feat: Add subscription upgrade group with auto downgrade * ✨ feat: Update subscription purchase modal display Show total quota as currency with tooltip for raw quota, hide reset cycle when never, and display upgrade group when configured to match card display rules. * ✨ feat: Extract quota conversion helpers to shared utils Move quota display/conversion helpers into web/src/helpers/quota.js and update the subscription plan editor to import and use the shared utilities instead of inline functions. * ✨ chore: Add upgrade group guidance in subscription editor Add explanatory helper text under the upgrade group field to clarify automatic group upgrades, rollback conditions, and the expected delay before downgrading takes effect. * 🔧 chore: remove unused Creem settings state Drop the unused originInputs state and redundant updates to keep the Creem settings form state minimal and easier to maintain. * 🚀 chore: Remove useless action * ✨ Add full i18n coverage for subscription-related UI across locales * ✨ feat: harden subscription billing and improve UI consistency Improve subscription payment safety and data integrity by handling user/URL lookup failures, fixing Stripe subscription mode, persisting quota reset fields, and correcting subscription delta accounting and DB timestamp casting. Refine the UI with stricter custom duration validation, accurate currency rounding, conditional Epay labeling, rollback on preference update failure, and shared subscription formatting helpers plus clearer component naming. * 🔧 fix: make epay webhook and return flow subscription-aware Ensure Epay webhook acknowledges success only after order completion, returning fail on processing errors to allow retries. Redirect subscription payment returns to the subscription page instead of top-up for correct user flow. * 🚦 fix: guard epay return success on order completion Redirect subscription return flow to failure when order completion fails, preventing false success states after payment verification. * 🔧 fix: normalize epay error handling and webhook retries Standardize SubscriptionRequestEpay error responses via ApiErrorMsg for a consistent schema. Return "fail" on non-success trade statuses in the epay webhook to preserve retry behavior. * 🧾 fix: persist epay orders before purchase Create the subscription order before initiating epay payment and expire it if the provider call fails, preventing orphaned transactions and improving reconciliation. * 🔧 fix: harden epay callbacks and billing fallbacks Use POST and form parsing for epay notify/return routes, persist epay orders before provider calls with expiry on failure, and ensure notify handlers retry correctly. Restrict subscription-first fallback to insufficient-subscription errors and log refund failures after retries to avoid silent quota drift. * 🔧 fix: harden billing flow and sidebar settings Add missing strings import for subscription fallback checks, log failed subscription refunds after retries, and extend sidebar module settings with a subscription management toggle plus translations. * 🛡️ fix: fail fast on epay form parse errors Handle ParseForm errors in epay notify/return handlers by returning fail or redirecting to failure, avoiding unsafe fallback to query parameters. * ✨ fix: refine Japanese subscription status labels Adjust Japanese UI wording for active-count labels to read more naturally and consistently. * ✅ fix: standardize epay success response schema Return subscription epay pay success responses via ApiSuccess to include the consistent success field and align with error schema.
This commit is contained in:
629
web/src/components/topup/SubscriptionPlansCard.jsx
Normal file
629
web/src/components/topup/SubscriptionPlansCard.jsx
Normal file
@@ -0,0 +1,629 @@
|
||||
/*
|
||||
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;
|
||||
@@ -35,6 +35,7 @@ import { StatusContext } from '../../context/Status';
|
||||
|
||||
import RechargeCard from './RechargeCard';
|
||||
import InvitationCard from './InvitationCard';
|
||||
import SubscriptionPlansCard from './SubscriptionPlansCard';
|
||||
import TransferModal from './modals/TransferModal';
|
||||
import PaymentConfirmModal from './modals/PaymentConfirmModal';
|
||||
import TopupHistoryModal from './modals/TopupHistoryModal';
|
||||
@@ -87,6 +88,14 @@ const TopUp = () => {
|
||||
// 账单Modal状态
|
||||
const [openHistory, setOpenHistory] = useState(false);
|
||||
|
||||
// 订阅相关
|
||||
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
|
||||
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
|
||||
const [billingPreference, setBillingPreference] =
|
||||
useState('subscription_first');
|
||||
const [activeSubscriptions, setActiveSubscriptions] = useState([]);
|
||||
const [allSubscriptions, setAllSubscriptions] = useState([]);
|
||||
|
||||
// 预设充值额度选项
|
||||
const [presetAmounts, setPresetAmounts] = useState([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
@@ -313,6 +322,61 @@ const TopUp = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getSubscriptionPlans = async () => {
|
||||
setSubscriptionLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/plans');
|
||||
if (res.data?.success) {
|
||||
setSubscriptionPlans(res.data.data || []);
|
||||
}
|
||||
} catch (e) {
|
||||
setSubscriptionPlans([]);
|
||||
} finally {
|
||||
setSubscriptionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSubscriptionSelf = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/subscription/self');
|
||||
if (res.data?.success) {
|
||||
setBillingPreference(
|
||||
res.data.data?.billing_preference || 'subscription_first',
|
||||
);
|
||||
// Active subscriptions
|
||||
const activeSubs = res.data.data?.subscriptions || [];
|
||||
setActiveSubscriptions(activeSubs);
|
||||
// All subscriptions (including expired)
|
||||
const allSubs = res.data.data?.all_subscriptions || [];
|
||||
setAllSubscriptions(allSubs);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const updateBillingPreference = async (pref) => {
|
||||
const previousPref = billingPreference;
|
||||
setBillingPreference(pref);
|
||||
try {
|
||||
const res = await API.put('/api/subscription/self/preference', {
|
||||
billing_preference: pref,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
const normalizedPref =
|
||||
res.data?.data?.billing_preference || pref || previousPref;
|
||||
setBillingPreference(normalizedPref);
|
||||
} else {
|
||||
showError(res.data?.message || t('更新失败'));
|
||||
setBillingPreference(previousPref);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
setBillingPreference(previousPref);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取充值配置信息
|
||||
const getTopupInfo = async () => {
|
||||
try {
|
||||
@@ -479,6 +543,8 @@ const TopUp = () => {
|
||||
// 在 statusState 可用时获取充值信息
|
||||
useEffect(() => {
|
||||
getTopupInfo().then();
|
||||
getSubscriptionPlans().then();
|
||||
getSubscriptionSelf().then();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -662,60 +728,72 @@ const TopUp = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 用户信息头部 */}
|
||||
<div className='space-y-6'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||
{/* 左侧充值区域 */}
|
||||
<div className='lg:col-span-7 space-y-6 w-full'>
|
||||
<RechargeCard
|
||||
t={t}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
creemProducts={creemProducts}
|
||||
creemPreTopUp={creemPreTopUp}
|
||||
presetAmounts={presetAmounts}
|
||||
selectedPreset={selectedPreset}
|
||||
selectPresetAmount={selectPresetAmount}
|
||||
formatLargeNumber={formatLargeNumber}
|
||||
priceRatio={priceRatio}
|
||||
topUpCount={topUpCount}
|
||||
minTopUp={minTopUp}
|
||||
renderQuotaWithAmount={renderQuotaWithAmount}
|
||||
getAmount={getAmount}
|
||||
setTopUpCount={setTopUpCount}
|
||||
setSelectedPreset={setSelectedPreset}
|
||||
renderAmount={renderAmount}
|
||||
amountLoading={amountLoading}
|
||||
payMethods={payMethods}
|
||||
preTopUp={preTopUp}
|
||||
paymentLoading={paymentLoading}
|
||||
payWay={payWay}
|
||||
redemptionCode={redemptionCode}
|
||||
setRedemptionCode={setRedemptionCode}
|
||||
topUp={topUp}
|
||||
isSubmitting={isSubmitting}
|
||||
topUpLink={topUpLink}
|
||||
openTopUpLink={openTopUpLink}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
statusLoading={statusLoading}
|
||||
topupInfo={topupInfo}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
{/* 主布局区域 */}
|
||||
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||
{/* 左侧 - 订阅套餐 */}
|
||||
<div className='lg:col-span-7'>
|
||||
<SubscriptionPlansCard
|
||||
t={t}
|
||||
loading={subscriptionLoading}
|
||||
plans={subscriptionPlans}
|
||||
payMethods={payMethods}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
billingPreference={billingPreference}
|
||||
onChangeBillingPreference={updateBillingPreference}
|
||||
activeSubscriptions={activeSubscriptions}
|
||||
allSubscriptions={allSubscriptions}
|
||||
reloadSubscriptionSelf={getSubscriptionSelf}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧信息区域 */}
|
||||
<div className='lg:col-span-5'>
|
||||
<InvitationCard
|
||||
t={t}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
setOpenTransfer={setOpenTransfer}
|
||||
affLink={affLink}
|
||||
handleAffLinkClick={handleAffLinkClick}
|
||||
/>
|
||||
</div>
|
||||
{/* 右侧 - 账户充值 + 邀请奖励 */}
|
||||
<div className='lg:col-span-5 flex flex-col gap-6'>
|
||||
<RechargeCard
|
||||
t={t}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
creemProducts={creemProducts}
|
||||
creemPreTopUp={creemPreTopUp}
|
||||
presetAmounts={presetAmounts}
|
||||
selectedPreset={selectedPreset}
|
||||
selectPresetAmount={selectPresetAmount}
|
||||
formatLargeNumber={formatLargeNumber}
|
||||
priceRatio={priceRatio}
|
||||
topUpCount={topUpCount}
|
||||
minTopUp={minTopUp}
|
||||
renderQuotaWithAmount={renderQuotaWithAmount}
|
||||
getAmount={getAmount}
|
||||
setTopUpCount={setTopUpCount}
|
||||
setSelectedPreset={setSelectedPreset}
|
||||
renderAmount={renderAmount}
|
||||
amountLoading={amountLoading}
|
||||
payMethods={payMethods}
|
||||
preTopUp={preTopUp}
|
||||
paymentLoading={paymentLoading}
|
||||
payWay={payWay}
|
||||
redemptionCode={redemptionCode}
|
||||
setRedemptionCode={setRedemptionCode}
|
||||
topUp={topUp}
|
||||
isSubmitting={isSubmitting}
|
||||
topUpLink={topUpLink}
|
||||
openTopUpLink={openTopUpLink}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
statusLoading={statusLoading}
|
||||
topupInfo={topupInfo}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
/>
|
||||
<InvitationCard
|
||||
t={t}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
setOpenTransfer={setOpenTransfer}
|
||||
affLink={affLink}
|
||||
handleAffLinkClick={handleAffLinkClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
259
web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
Normal file
259
web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Modal,
|
||||
Typography,
|
||||
Card,
|
||||
Button,
|
||||
Select,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Crown, CalendarClock, Package } from 'lucide-react';
|
||||
import { SiStripe } from 'react-icons/si';
|
||||
import { IconCreditCard } from '@douyinfe/semi-icons';
|
||||
import { renderQuota } from '../../../helpers';
|
||||
import { getCurrencyConfig } from '../../../helpers/render';
|
||||
import {
|
||||
formatSubscriptionDuration,
|
||||
formatSubscriptionResetPeriod,
|
||||
} from '../../../helpers/subscriptionFormat';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SubscriptionPurchaseModal = ({
|
||||
t,
|
||||
visible,
|
||||
onCancel,
|
||||
selectedPlan,
|
||||
paying,
|
||||
selectedEpayMethod,
|
||||
setSelectedEpayMethod,
|
||||
epayMethods = [],
|
||||
enableOnlineTopUp = false,
|
||||
enableStripeTopUp = false,
|
||||
enableCreemTopUp = false,
|
||||
purchaseLimitInfo = null,
|
||||
onPayStripe,
|
||||
onPayCreem,
|
||||
onPayEpay,
|
||||
}) => {
|
||||
const plan = selectedPlan?.plan;
|
||||
const totalAmount = Number(plan?.total_amount || 0);
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
const price = plan ? Number(plan.price_amount || 0) : 0;
|
||||
const convertedPrice = price * rate;
|
||||
const displayPrice = convertedPrice.toFixed(
|
||||
Number.isInteger(convertedPrice) ? 0 : 2,
|
||||
);
|
||||
// 只有当管理员开启支付网关 AND 套餐配置了对应的支付ID时才显示
|
||||
const hasStripe = enableStripeTopUp && !!plan?.stripe_price_id;
|
||||
const hasCreem = enableCreemTopUp && !!plan?.creem_product_id;
|
||||
const hasEpay = enableOnlineTopUp && epayMethods.length > 0;
|
||||
const hasAnyPayment = hasStripe || hasCreem || hasEpay;
|
||||
const purchaseLimit = Number(purchaseLimitInfo?.limit || 0);
|
||||
const purchaseCount = Number(purchaseLimitInfo?.count || 0);
|
||||
const purchaseLimitReached =
|
||||
purchaseLimit > 0 && purchaseCount >= purchaseLimit;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<Crown className='mr-2' size={18} />
|
||||
{t('购买订阅套餐')}
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
size='small'
|
||||
centered
|
||||
>
|
||||
{plan ? (
|
||||
<div className='space-y-4 pb-10'>
|
||||
{/* 套餐信息 */}
|
||||
<Card className='!rounded-xl !border-0 bg-slate-50 dark:bg-slate-800'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('套餐名称')}:
|
||||
</Text>
|
||||
<Typography.Text
|
||||
ellipsis={{ rows: 1, showTooltip: true }}
|
||||
className='text-slate-900 dark:text-slate-100'
|
||||
style={{ maxWidth: 200 }}
|
||||
>
|
||||
{plan.title}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('有效期')}:
|
||||
</Text>
|
||||
<div className='flex items-center'>
|
||||
<CalendarClock size={14} className='mr-1 text-slate-500' />
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{formatSubscriptionDuration(plan, t)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
{formatSubscriptionResetPeriod(plan, t) !== t('不重置') && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('重置周期')}:
|
||||
</Text>
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{formatSubscriptionResetPeriod(plan, t)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('总额度')}:
|
||||
</Text>
|
||||
<div className='flex items-center'>
|
||||
<Package size={14} className='mr-1 text-slate-500' />
|
||||
{totalAmount > 0 ? (
|
||||
<Tooltip content={`${t('原生额度')}:${totalAmount}`}>
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{renderQuota(totalAmount)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{t('不限')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{plan?.upgrade_group ? (
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('升级分组')}:
|
||||
</Text>
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{plan.upgrade_group}
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
<Divider margin={8} />
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('应付金额')}:
|
||||
</Text>
|
||||
<Text strong className='text-xl text-purple-600'>
|
||||
{symbol}
|
||||
{displayPrice}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 支付方式 */}
|
||||
{purchaseLimitReached && (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={`${t('已达到购买上限')} (${purchaseCount}/${purchaseLimit})`}
|
||||
className='!rounded-xl'
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasAnyPayment ? (
|
||||
<div className='space-y-3'>
|
||||
<Text size='small' type='tertiary'>
|
||||
{t('选择支付方式')}:
|
||||
</Text>
|
||||
|
||||
{/* Stripe / Creem */}
|
||||
{(hasStripe || hasCreem) && (
|
||||
<div className='flex gap-2'>
|
||||
{hasStripe && (
|
||||
<Button
|
||||
theme='light'
|
||||
className='flex-1'
|
||||
icon={<SiStripe size={14} color='#635BFF' />}
|
||||
onClick={onPayStripe}
|
||||
loading={paying}
|
||||
disabled={purchaseLimitReached}
|
||||
>
|
||||
Stripe
|
||||
</Button>
|
||||
)}
|
||||
{hasCreem && (
|
||||
<Button
|
||||
theme='light'
|
||||
className='flex-1'
|
||||
icon={<IconCreditCard />}
|
||||
onClick={onPayCreem}
|
||||
loading={paying}
|
||||
disabled={purchaseLimitReached}
|
||||
>
|
||||
Creem
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 易支付 */}
|
||||
{hasEpay && (
|
||||
<div className='flex gap-2'>
|
||||
<Select
|
||||
value={selectedEpayMethod}
|
||||
onChange={setSelectedEpayMethod}
|
||||
style={{ flex: 1 }}
|
||||
size='default'
|
||||
placeholder={t('选择支付方式')}
|
||||
optionList={epayMethods.map((m) => ({
|
||||
value: m.type,
|
||||
label: m.name || m.type,
|
||||
}))}
|
||||
disabled={purchaseLimitReached}
|
||||
/>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={onPayEpay}
|
||||
loading={paying}
|
||||
disabled={!selectedEpayMethod || purchaseLimitReached}
|
||||
>
|
||||
{t('支付')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('管理员未开启在线支付功能,请联系管理员配置。')}
|
||||
className='!rounded-xl'
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionPurchaseModal;
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Empty,
|
||||
Button,
|
||||
Input,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
@@ -49,6 +50,7 @@ const STATUS_CONFIG = {
|
||||
// 支付方式映射
|
||||
const PAYMENT_METHOD_MAP = {
|
||||
stripe: 'Stripe',
|
||||
creem: 'Creem',
|
||||
alipay: '支付宝',
|
||||
wxpay: '微信',
|
||||
};
|
||||
@@ -150,6 +152,11 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
||||
return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
|
||||
};
|
||||
|
||||
const isSubscriptionTopup = (record) => {
|
||||
const tradeNo = (record?.trade_no || '').toLowerCase();
|
||||
return Number(record?.amount || 0) === 0 && tradeNo.startsWith('sub');
|
||||
};
|
||||
|
||||
// 检查是否为管理员
|
||||
const userIsAdmin = useMemo(() => isAdmin(), []);
|
||||
|
||||
@@ -171,12 +178,21 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
||||
title: t('充值额度'),
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (amount) => (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Coins size={16} />
|
||||
<Text>{amount}</Text>
|
||||
</span>
|
||||
),
|
||||
render: (amount, record) => {
|
||||
if (isSubscriptionTopup(record)) {
|
||||
return (
|
||||
<Tag color='purple' shape='circle' size='small'>
|
||||
{t('订阅套餐')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Coins size={16} />
|
||||
<Text>{amount}</Text>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('支付金额'),
|
||||
|
||||
Reference in New Issue
Block a user