Files
vf_react/src/components/Subscription/SubscriptionContentNew.tsx
2025-12-02 14:30:27 +08:00

1479 lines
52 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Button,
Flex,
Text,
Badge,
VStack,
HStack,
useToast,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
Image,
Progress,
Divider,
Input,
Icon,
Container,
useBreakpointValue,
} from '@chakra-ui/react';
import {
FaWeixin,
FaGem,
FaCheck,
FaQrcode,
FaClock,
FaRedo,
FaCrown,
FaStar,
FaTimes,
FaChevronDown,
FaChevronUp,
} from 'react-icons/fa';
import { logger } from '../../utils/logger';
import { useAuth } from '../../contexts/AuthContext';
import { useSubscriptionEvents } from '../../hooks/useSubscriptionEvents';
import { subscriptionConfig, themeColors } from '../../views/Pages/Account/subscription-content';
// 计费周期选择器组件 - 移动端垂直布局(年付在上),桌面端水平布局
interface CycleSelectorProps {
options: any[];
selectedCycle: string;
onSelectCycle: (cycle: string) => void;
}
function CycleSelector({ options, selectedCycle, onSelectCycle }: CycleSelectorProps) {
// 使用 useBreakpointValue 动态获取是否是移动端
const isMobile = useBreakpointValue({ base: true, md: false });
// 移动端倒序显示(年付在上),桌面端正常顺序
const displayOptions = isMobile ? [...options].reverse() : options;
return (
<Flex
direction={{ base: 'column', md: 'row' }}
gap={3}
p={2}
bg="rgba(255, 255, 255, 0.03)"
borderRadius="xl"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(10px)"
justify="center"
align="center"
w={{ base: 'full', md: 'auto' }}
maxW={{ base: '320px', md: 'none' }}
mx="auto"
>
{displayOptions.map((option: any) => (
<Box key={option.cycleKey} position="relative" w={{ base: 'full', md: 'auto' }}>
{option.discountPercent > 0 && (
<Badge
position="absolute"
top={{ base: '50%', md: '-10px' }}
right={{ base: '10px', md: '-10px' }}
transform={{ base: 'translateY(-50%)', md: 'none' }}
colorScheme="red"
fontSize="xs"
px={2}
py={1}
borderRadius="full"
fontWeight="bold"
zIndex={1}
>
{option.discountPercent}%
</Badge>
)}
<Button
size="lg"
w={{ base: 'full', md: 'auto' }}
px={6}
py={6}
borderRadius="lg"
bg={selectedCycle === option.cycleKey ? 'linear-gradient(135deg, #D4AF37, #B8941F)' : 'transparent'}
color={selectedCycle === option.cycleKey ? '#000' : '#fff'}
border="1px solid"
borderColor={selectedCycle === option.cycleKey ? 'rgba(212, 175, 55, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
onClick={() => onSelectCycle(option.cycleKey)}
_hover={{
transform: 'translateY(-2px)',
borderColor: 'rgba(212, 175, 55, 0.5)',
shadow: selectedCycle === option.cycleKey
? '0 0 20px rgba(212, 175, 55, 0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.5)',
}}
transition="all 0.3s"
fontWeight="bold"
justifyContent={{ base: 'flex-start', md: 'center' }}
pl={{ base: 6, md: 6 }}
>
{option.label}
</Button>
</Box>
))}
</Flex>
);
}
export default function SubscriptionContentNew() {
const { user } = useAuth();
const subscriptionEvents = useSubscriptionEvents({
currentSubscription: {
plan: user?.subscription_plan || 'free',
status: user?.subscription_status || 'inactive',
},
});
const [selectedCycle, setSelectedCycle] = useState('monthly');
const [selectedPlan, setSelectedPlan] = useState(null);
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
const [priceInfo, setPriceInfo] = useState(null);
const [loading, setLoading] = useState(false);
const [promoCode, setPromoCode] = useState('');
const [promoCodeApplied, setPromoCodeApplied] = useState(false);
const [promoCodeError, setPromoCodeError] = useState('');
const [validatingPromo, setValidatingPromo] = useState(false);
const [paymentOrder, setPaymentOrder] = useState(null);
const [paymentCountdown, setPaymentCountdown] = useState(300);
const [autoCheckInterval, setAutoCheckInterval] = useState(null);
const [forceUpdating, setForceUpdating] = useState(false);
const [openFaqIndex, setOpenFaqIndex] = useState(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
// 倒计时更新
useEffect(() => {
let timer: any;
if (paymentCountdown > 0) {
timer = setInterval(() => {
setPaymentCountdown((prev) => {
if (prev <= 1) {
handlePaymentExpire();
return 0;
}
return prev - 1;
});
}, 1000);
}
return () => clearInterval(timer);
}, [paymentCountdown]);
// 组件卸载时清理定时器
useEffect(() => {
return () => {
stopAutoPaymentCheck();
};
}, []);
// 组件加载时获取套餐数据
useEffect(() => {
fetchSubscriptionPlans();
}, []);
const fetchSubscriptionPlans = async () => {
try {
logger.debug('SubscriptionContentNew', '正在获取订阅套餐');
const response = await fetch('/api/subscription/plans');
if (response.ok) {
const data = await response.json();
if (data.success && Array.isArray(data.data)) {
const validPlans = data.data.filter(
(plan: any) =>
plan &&
plan.name &&
typeof plan.monthly_price === 'number' &&
typeof plan.yearly_price === 'number'
);
logger.debug('SubscriptionContentNew', '套餐加载成功', {
status: response.status,
validPlansCount: validPlans.length,
});
setSubscriptionPlans(validPlans);
} else {
logger.warn('SubscriptionContentNew', '套餐数据格式异常', { data });
setSubscriptionPlans([]);
}
} else {
logger.error('SubscriptionContentNew', 'fetchSubscriptionPlans', new Error(`HTTP ${response.status}`));
setSubscriptionPlans([]);
}
} catch (error) {
logger.error('SubscriptionContentNew', 'fetchSubscriptionPlans', error);
setSubscriptionPlans([]);
}
};
const handlePaymentExpire = () => {
stopAutoPaymentCheck();
toast({
title: '支付二维码已过期',
description: '请重新创建订单',
status: 'warning',
duration: 3000,
isClosable: true,
});
};
const stopAutoPaymentCheck = () => {
if (autoCheckInterval) {
clearInterval(autoCheckInterval);
setAutoCheckInterval(null);
}
};
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// 计算价格
const calculatePrice = async (plan: any, cycle: string, promoCodeValue: any = null) => {
try {
const validPromoCode = promoCodeValue && typeof promoCodeValue === 'string' && promoCodeValue.trim()
? promoCodeValue.trim()
: null;
const response = await fetch('/api/subscription/calculate-price', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
to_plan: plan.name,
to_cycle: cycle,
promo_code: validPromoCode,
}),
});
if (response.ok) {
const data = await response.json();
if (data.success) {
setPriceInfo(data.data);
return data.data;
}
}
return null;
} catch (error) {
logger.error('SubscriptionContent', 'calculatePrice', error);
return null;
}
};
// 验证优惠码
const handleValidatePromoCode = async () => {
const trimmedCode = promoCode.trim();
if (!trimmedCode) {
setPromoCodeError('请输入优惠码');
return;
}
if (!selectedPlan) {
setPromoCodeError('请先选择套餐');
return;
}
setValidatingPromo(true);
setPromoCodeError('');
try {
const result = await calculatePrice(selectedPlan, selectedCycle, trimmedCode);
if (result && !result.promo_error) {
setPromoCodeApplied(true);
toast({
title: '优惠码已应用',
description: `节省 ¥${result.discount_amount.toFixed(2)}`,
status: 'success',
duration: 3000,
isClosable: true,
});
} else {
setPromoCodeError(result?.promo_error || '优惠码无效');
setPromoCodeApplied(false);
}
} catch (error) {
setPromoCodeError('验证失败,请重试');
setPromoCodeApplied(false);
} finally {
setValidatingPromo(false);
}
};
const handleRemovePromoCode = async () => {
setPromoCode('');
setPromoCodeApplied(false);
setPromoCodeError('');
if (selectedPlan) {
await calculatePrice(selectedPlan, selectedCycle, null);
}
};
const handleSubscribe = async (plan: any) => {
if (!user) {
toast({
title: '请先登录',
description: '登录后即可订阅',
status: 'warning',
duration: 3000,
isClosable: true,
});
return;
}
subscriptionEvents.trackPricingPlanSelected(
plan.name,
selectedCycle,
getCurrentPrice(plan)
);
setSelectedPlan(plan);
await calculatePrice(plan, selectedCycle, promoCodeApplied ? promoCode : null);
onOpen();
};
const handleCreatePaymentOrder = async () => {
if (!selectedPlan || !user) return;
setLoading(true);
try {
const price = priceInfo?.final_amount || getCurrentPrice(selectedPlan);
// 检查是否为免费升级(剩余价值足够抵扣新套餐价格)
if (price === 0 && priceInfo?.is_upgrade) {
const response = await fetch('/api/subscription/free-upgrade', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
plan_name: selectedPlan.name,
billing_cycle: selectedCycle,
}),
});
const data = await response.json();
if (data.success) {
subscriptionEvents.trackPaymentSuccessful({
planName: selectedPlan.name,
paymentMethod: 'free_upgrade',
amount: 0,
orderId: 'free_upgrade',
transactionId: 'free_upgrade',
});
toast({
title: '升级成功!',
description: data.message,
status: 'success',
duration: 5000,
isClosable: true,
});
onClose();
setTimeout(() => window.location.reload(), 2000);
return;
} else {
throw new Error(data.error || '免费升级失败');
}
}
subscriptionEvents.trackPaymentInitiated({
planName: selectedPlan.name,
paymentMethod: 'wechat_pay',
amount: price,
billingCycle: selectedCycle,
});
const response = await fetch('/api/payment/create-order', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
plan_name: selectedPlan.name,
billing_cycle: selectedCycle,
promo_code: promoCodeApplied ? promoCode : null,
}),
});
if (response.ok) {
const data = await response.json();
if (data.success) {
setPaymentOrder(data.data);
setPaymentCountdown(30 * 60); // 30分钟
startAutoPaymentCheck(data.data.id);
toast({
title: '订单创建成功',
description: '请使用微信扫描二维码完成支付',
status: 'success',
duration: 3000,
isClosable: true,
});
} else {
throw new Error(data.message || '创建订单失败');
}
} else {
throw new Error('网络请求失败');
}
} catch (error: any) {
subscriptionEvents.trackPaymentFailed(
{
planName: selectedPlan.name,
paymentMethod: 'wechat_pay',
amount: getCurrentPrice(selectedPlan),
},
error.message
);
toast({
title: '创建订单失败',
description: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoading(false);
}
};
const startAutoPaymentCheck = (orderId: string) => {
const checkInterval = setInterval(async () => {
try {
const response = await fetch(`/api/payment/order/${orderId}/status`, {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
if (data.success && data.data.status === 'paid') {
clearInterval(checkInterval);
setAutoCheckInterval(null);
subscriptionEvents.trackPaymentSuccessful({
planName: selectedPlan?.name,
paymentMethod: 'wechat_pay',
amount: paymentOrder?.amount,
orderId: orderId,
transactionId: data.data.transaction_id,
});
toast({
title: '支付成功!',
description: '您的订阅已激活',
status: 'success',
duration: 5000,
isClosable: true,
});
onClose();
setTimeout(() => window.location.reload(), 2000);
}
}
} catch (error) {
logger.error('SubscriptionContent', 'checkPaymentStatus', error);
}
}, 3000);
setAutoCheckInterval(checkInterval as any);
};
const handleForceUpdate = async () => {
if (!paymentOrder) return;
setForceUpdating(true);
try {
const response = await fetch(`/api/payment/order/${paymentOrder.order_id}/status`, {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
if (data.success && data.data.status === 'paid') {
toast({
title: '支付成功!',
description: '您的订阅已激活',
status: 'success',
duration: 5000,
isClosable: true,
});
onClose();
setTimeout(() => window.location.reload(), 2000);
} else {
toast({
title: '未检测到支付',
description: '请确认已完成支付后重试',
status: 'info',
duration: 3000,
isClosable: true,
});
}
}
} catch (error: any) {
toast({
title: '查询失败',
description: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setForceUpdating(false);
}
};
// 合并数据库数据和前端配置
const getMergedPlans = () => {
// 如果数据库还没有加载数据,使用静态配置
if (subscriptionPlans.length === 0) {
return subscriptionConfig.plans;
}
// 合并数据库价格和前端UI配置
return subscriptionConfig.plans.map((configPlan: any) => {
const dbPlan = subscriptionPlans.find((p: any) => p.name === configPlan.name);
if (!dbPlan) {
return configPlan; // 如果数据库中没有,使用前端配置
}
// 解析数据库中的 pricing_options JSON
let pricingOptions = configPlan.pricingOptions;
if (dbPlan.pricing_options) {
try {
const parsedOptions = typeof dbPlan.pricing_options === 'string'
? JSON.parse(dbPlan.pricing_options)
: dbPlan.pricing_options;
if (Array.isArray(parsedOptions) && parsedOptions.length > 0) {
pricingOptions = parsedOptions.map((opt: any) => ({
cycleKey: opt.cycle_key,
label: opt.label,
months: opt.months,
price: parseFloat(opt.price),
originalPrice: opt.original_price ? parseFloat(opt.original_price) : null,
discountPercent: opt.discount_percent || 0,
}));
}
} catch (error) {
logger.error('SubscriptionContentNew', '解析pricing_options失败', error);
}
}
// 合并数据,数据库价格优先
return {
...configPlan,
monthly_price: dbPlan.monthly_price,
yearly_price: dbPlan.yearly_price,
pricingOptions: pricingOptions,
displayName: dbPlan.display_name || configPlan.displayName,
description: dbPlan.description || configPlan.description,
};
});
};
const getCurrentPrice = (plan: any) => {
if (!plan || plan.name === 'free') return 0;
const option = plan.pricingOptions?.find(
(opt: any) => opt.cycleKey === selectedCycle
);
return option ? option.price : plan.pricingOptions?.[0]?.price || 0;
};
const getCurrentPriceOption = (plan: any) => {
if (!plan || plan.name === 'free') return null;
return plan.pricingOptions?.find((opt: any) => opt.cycleKey === selectedCycle);
};
const getIconComponent = (iconName: string) => {
const icons: any = {
star: FaStar,
gem: FaGem,
crown: FaCrown,
};
return icons[iconName] || FaStar;
};
// 获取按钮文字
const getButtonText = (plan: any) => {
const currentPlanName = user?.subscription_type;
const isActive = user?.subscription_status === 'active';
if (!isActive || !currentPlanName || currentPlanName === 'free') {
return `选择${plan.displayName}`;
}
if (currentPlanName === plan.name) {
// 同级续费
return `续费${plan.displayName}`;
}
// 升级或降级
if (currentPlanName === 'pro' && plan.name === 'max') {
return `升级为${plan.displayName}`;
}
if (currentPlanName === 'max' && plan.name === 'pro') {
return `到期后切换到${plan.displayName}`;
}
return `选择${plan.displayName}`;
};
// 判断按钮是否可点击
const isButtonDisabled = (plan: any) => {
return false; // 所有套餐都可以选择,包括当前套餐(续费)
};
return (
<Box
minH="100vh"
bg="#0a0a0a"
pt={{ base: 8, md: 16 }}
pb={{ base: 16, md: 24 }}
position="relative"
overflow="hidden"
>
{/* 背景光晕 */}
<Box
position="absolute"
top="20%"
left="10%"
w="400px"
h="400px"
bg="rgba(212, 175, 55, 0.15)"
borderRadius="full"
filter="blur(100px)"
pointerEvents="none"
zIndex={0}
/>
<Box
position="absolute"
bottom="10%"
right="10%"
w="500px"
h="500px"
bg="rgba(212, 175, 55, 0.1)"
borderRadius="full"
filter="blur(120px)"
pointerEvents="none"
zIndex={0}
/>
<Container maxW="1400px" px={{ base: 4, md: 6 }} position="relative" zIndex={1}>
{/* 标题区域 */}
<VStack spacing={4} mb={12} textAlign="center">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<Text
fontSize="sm"
color="rgba(212, 175, 55, 0.8)"
fontWeight="medium"
letterSpacing="wider"
textTransform="uppercase"
>
</Text>
</motion.div>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
>
<Text
fontSize={{ base: '3xl', md: '5xl', lg: '6xl' }}
fontWeight="bold"
bgGradient="radial-gradient(circle at center, #FFFFFF 0%, rgba(255,255,255,0.6) 100%)"
bgClip="text"
lineHeight="1.2"
>
</Text>
</motion.div>
</VStack>
{/* 当前订阅状态 */}
{user && user.subscription_type && user.subscription_type !== 'free' && user.subscription_status === 'active' && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Box
mb={12}
p={6}
borderRadius="2xl"
bg="rgba(212, 175, 55, 0.05)"
border="2px solid"
borderColor="rgba(212, 175, 55, 0.3)"
backdropFilter="blur(20px)"
maxW="600px"
mx="auto"
position="relative"
overflow="hidden"
_before={{
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '4px',
bgGradient: 'linear-gradient(90deg, rgba(212, 175, 55, 0.5), rgba(212, 175, 55, 1), rgba(212, 175, 55, 0.5))',
}}
>
<VStack spacing={4} align="stretch">
<HStack justify="space-between" align="center">
<HStack spacing={3}>
<Icon
as={user.subscription_type === 'max' ? FaCrown : FaGem}
color="#D4AF37"
boxSize={6}
/>
<VStack align="start" spacing={0}>
<Text color="white" fontSize="lg" fontWeight="bold">
: {user.subscription_type === 'max' ? 'Max 旗舰版' : 'Pro 专业版'}
</Text>
{user.billing_cycle && (
<Text color="rgba(255, 255, 255, 0.6)" fontSize="xs">
{user.billing_cycle === 'monthly' ? '月付' :
user.billing_cycle === 'quarterly' ? '季付' :
user.billing_cycle === 'semiannual' ? '半年付' :
user.billing_cycle === 'yearly' ? '年付' : user.billing_cycle}
</Text>
)}
</VStack>
</HStack>
<Badge
px={3}
py={1}
borderRadius="full"
bg="rgba(76, 175, 80, 0.2)"
border="1px solid rgba(76, 175, 80, 0.4)"
color="green.300"
fontSize="xs"
fontWeight="medium"
>
使
</Badge>
</HStack>
<Divider borderColor="rgba(212, 175, 55, 0.2)" />
<Flex justify="space-between" align="center">
<HStack spacing={2}>
<Icon as={FaClock} color="rgba(212, 175, 55, 0.8)" boxSize={4} />
<Text color="rgba(255, 255, 255, 0.7)" fontSize="sm">
</Text>
</HStack>
<Text color="#D4AF37" fontSize="md" fontWeight="bold">
{user.subscription_end_date
? new Date(user.subscription_end_date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
: '永久有效'
}
</Text>
</Flex>
{user.subscription_end_date && (() => {
const endDate = new Date(user.subscription_end_date);
const today = new Date();
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft > 0 && daysLeft <= 30) {
return (
<Box
p={2}
borderRadius="md"
bg="rgba(255, 165, 0, 0.1)"
border="1px solid rgba(255, 165, 0, 0.3)"
>
<Text color="orange.300" fontSize="xs" textAlign="center">
{daysLeft}
</Text>
</Box>
);
}
return null;
})()}
</VStack>
</Box>
</motion.div>
)}
{/* 计费周期选择器 */}
<VStack spacing={6} mb={16}>
<Text fontSize="md" color="rgba(255, 255, 255, 0.7)">
·
</Text>
<CycleSelector
options={getMergedPlans()[1]?.pricingOptions || []}
selectedCycle={selectedCycle}
onSelectCycle={setSelectedCycle}
/>
{(() => {
const currentOption = getMergedPlans()[1]?.pricingOptions?.find(
(opt: any) => opt.cycleKey === selectedCycle
);
if (currentOption && currentOption.discountPercent > 0) {
return (
<HStack spacing={2}>
<Icon as={FaStar} color="#D4AF37" boxSize={4} />
<Text fontSize="sm" color="#D4AF37" fontWeight="medium">
{currentOption.discountPercent}%
</Text>
</HStack>
);
}
return null;
})()}
</VStack>
{/* 套餐卡片 - 借鉴 index.pug 设计 */}
<Flex
justify="center"
gap={4}
mb={16}
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
maxW="1200px"
mx="auto"
>
{getMergedPlans().slice(1).map((plan: any, index: number) => {
const IconComponent = getIconComponent(plan.icon);
const currentPriceOption = getCurrentPriceOption(plan);
const isCurrentPlan =
user?.subscription_type === plan.name &&
user?.subscription_status === 'active';
const isPremium = plan.name === 'max';
return (
<motion.div
key={plan.name}
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1, duration: 0.6 }}
style={{ flex: 1, maxWidth: '500px', width: '100%' }}
>
<Box
position="relative"
h="100%"
borderRadius="20px"
overflow="hidden"
bg={isPremium ? 'rgba(10, 10, 10, 0.5)' : 'rgba(10, 10, 10, 0.3)'}
border={isPremium ? '1px solid rgba(212, 175, 55, 0.3)' : '1px solid rgba(255, 255, 255, 0.1)'}
boxShadow={isPremium ? '0 20px 60px rgba(212, 175, 55, 0.2)' : '0 20px 60px rgba(0, 0, 0, 0.3)'}
transition="all 0.4s cubic-bezier(0.4, 0, 0.2, 1)"
_hover={{
transform: 'translateY(-8px)',
borderColor: isPremium ? 'rgba(212, 175, 55, 0.5)' : 'rgba(255, 255, 255, 0.2)',
boxShadow: isPremium
? '0 30px 80px rgba(212, 175, 55, 0.3)'
: '0 30px 80px rgba(0, 0, 0, 0.5)',
}}
_before={
isPremium
? {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '120%',
height: '120%',
background: 'radial-gradient(circle at center, rgba(212, 175, 55, 0.1) 0%, transparent 70%)',
pointerEvents: 'none',
zIndex: 0,
}
: {}
}
_after={{
content: '""',
position: 'absolute',
inset: 0,
borderRadius: '20px',
border: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.1)',
pointerEvents: 'none',
}}
>
{/* 套餐标题 */}
<Box
position="relative"
zIndex={2}
py={3}
px={8.5}
fontSize="lg"
fontWeight="bold"
bgGradient={
isPremium
? 'linear-gradient(to right, rgba(212, 175, 55, 0.2), rgba(212, 175, 55, 0.2))'
: 'linear-gradient(to right, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05))'
}
borderTopRadius="20px"
color={isPremium ? '#D4AF37' : 'white'}
>
{plan.displayName}
</Box>
<VStack
spacing={0}
align="stretch"
position="relative"
zIndex={3}
flex={1}
mt={-1}
p={3.5}
pb={8}
backdropFilter={isPremium ? 'blur(32px)' : 'blur(20px)'}
bg={isPremium ? 'rgba(255, 255, 255, 0.07)' : 'rgba(255, 255, 255, 0.01)'}
borderRadius="20px"
_after={{
content: '""',
position: 'absolute',
inset: 0,
borderRadius: '20px',
border: '1px solid',
borderColor: 'rgba(255, 255, 255, 0.1)',
pointerEvents: 'none',
}}
>
{/* 价格卡片 */}
<Box
position="relative"
mb={6}
p={6}
borderRadius="16px"
bg={isPremium ? 'rgba(212, 175, 55, 0.1)' : 'rgba(255, 255, 255, 0.03)'}
backdropFilter="blur(20px)"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
_after={{
content: '""',
position: 'absolute',
inset: 0,
borderRadius: '16px',
border: '1px solid',
borderColor: isPremium ? 'rgba(212, 175, 55, 0.2)' : 'rgba(255, 255, 255, 0.1)',
pointerEvents: 'none',
}}
>
<Flex align="baseline" justify="center" mb={5}>
<Text
fontSize="5xl"
fontWeight="bold"
bgGradient="radial-gradient(circle at center, #FFFFFF 0%, rgba(255,255,255,0.6) 100%)"
bgClip="text"
lineHeight="1"
letterSpacing="-0.02em"
>
¥{getCurrentPrice(plan)}
</Text>
<Text fontSize="lg" color="rgba(255, 255, 255, 0.6)" ml={2}>
/ {currentPriceOption?.label || '月'}
</Text>
</Flex>
<Button
w="full"
size="lg"
h="56px"
bg={
isPremium
? 'linear-gradient(135deg, #D4AF37 0%, #B8941F 100%)'
: 'rgba(255, 255, 255, 0.05)'
}
color={
isPremium
? '#000'
: '#fff'
}
border={
isPremium
? 'none'
: '1px solid rgba(255, 255, 255, 0.1)'
}
fontWeight="bold"
fontSize="md"
borderRadius="lg"
onClick={() => handleSubscribe(plan)}
isDisabled={isButtonDisabled(plan)}
cursor="pointer"
_hover={{
transform: 'translateY(-2px)',
shadow: isPremium
? '0 8px 30px rgba(212, 175, 55, 0.4)'
: '0 8px 20px rgba(255, 255, 255, 0.1)',
bg: isPremium
? 'linear-gradient(135deg, #E5C047 0%, #C9A52F 100%)'
: 'rgba(255, 255, 255, 0.08)',
}}
_active={{
transform: 'translateY(0)',
}}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
>
{getButtonText(plan)}
</Button>
</Box>
{/* 功能列表 */}
<VStack spacing={4} align="stretch" px={2}>
{plan.features.map((feature: any, idx: number) => (
<Flex key={idx} align="start" gap={3}>
<Flex
justify="center"
align="center"
flexShrink={0}
w={5}
h={5}
mt={0.5}
bg={feature.enabled ? (isPremium ? '#D4AF37' : '#00ff88') : 'transparent'}
borderRadius="full"
boxShadow={
feature.enabled
? isPremium
? '0 0 0 1px rgba(212,175,55,0.30) inset, 0 0 10px rgba(212,175,55,0.50) inset'
: '0 0 0 1px rgba(255,255,255,0.20) inset, 0 0 10px rgba(255,255,255,0.50) inset'
: 'none'
}
>
<Icon
as={feature.enabled ? FaCheck : FaTimes}
color={feature.enabled ? '#000' : 'rgba(255, 255, 255, 0.3)'}
boxSize={3}
/>
</Flex>
<Text
fontSize="sm"
color={feature.enabled ? 'rgba(255, 255, 255, 0.85)' : 'rgba(255, 255, 255, 0.35)'}
flex={1}
fontWeight={feature.enabled && isPremium && idx === 0 ? 'semibold' : 'normal'}
lineHeight="1.6"
>
{feature.name}
{feature.limit && (
<Text as="span" fontSize="xs" color={isPremium ? '#E5C047' : '#00ff88'} ml={1.5} fontWeight="medium">
({feature.limit})
</Text>
)}
</Text>
</Flex>
))}
</VStack>
</VStack>
</Box>
</motion.div>
);
})}
</Flex>
{/* FAQ 区域 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
>
<Box
p={8}
borderRadius="2xl"
bg="rgba(30, 30, 30, 0.5)"
backdropFilter="blur(20px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
maxW="900px"
mx="auto"
>
<Text fontSize="2xl" fontWeight="bold" color="white" mb={6} textAlign="center">
</Text>
<VStack spacing={4} align="stretch">
{subscriptionConfig.faqs.map((faq: any, index: number) => (
<Box
key={index}
borderRadius="lg"
bg="rgba(255, 255, 255, 0.03)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
overflow="hidden"
transition="all 0.3s"
_hover={{
bg: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(212, 175, 55, 0.3)',
}}
>
<Flex
p={5}
justify="space-between"
align="center"
cursor="pointer"
onClick={() => setOpenFaqIndex(openFaqIndex === index ? null : index)}
>
<Text fontSize="md" fontWeight="medium" color="white">
{faq.question}
</Text>
<Icon
as={openFaqIndex === index ? FaChevronUp : FaChevronDown}
color="#D4AF37"
boxSize={5}
/>
</Flex>
<AnimatePresence>
{openFaqIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
>
<Box p={5} pt={0} color="rgba(255, 255, 255, 0.7)" fontSize="sm">
{faq.answer.split('\n').map((line: string, idx: number) => (
<Text key={idx} mb={line.startsWith('•') ? 1 : 2}>
{line}
</Text>
))}
</Box>
</motion.div>
)}
</AnimatePresence>
</Box>
))}
</VStack>
</Box>
</motion.div>
</Container>
{/* 支付模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered>
<ModalOverlay bg="rgba(0, 0, 0, 0.8)" backdropFilter="blur(10px)" />
<ModalContent bg="#1e1e1e" borderRadius="2xl" border="1px solid rgba(255, 255, 255, 0.1)">
<ModalHeader color="white"></ModalHeader>
<ModalCloseButton color="white" />
<ModalBody pb={6}>
{!paymentOrder ? (
<VStack spacing={6} align="stretch">
{/* 订阅类型提示 */}
{selectedPlan && priceInfo && (
<>
{priceInfo.is_upgrade && (
<Box
p={3}
bg="rgba(76, 175, 80, 0.1)"
borderRadius="lg"
border="1px solid rgba(76, 175, 80, 0.3)"
>
<HStack spacing={2}>
<Icon as={FaCheck} color="green.400" />
<Text color="green.400" fontSize="sm" fontWeight="medium">
{priceInfo.final_amount === 0
? `恭喜!您的当前订阅剩余价值足够直接升级到${selectedPlan.displayName},无需支付额外费用!`
: `升级到${selectedPlan.displayName},立即生效!按差价补缴费用`}
</Text>
</HStack>
</Box>
)}
{priceInfo.is_downgrade && (
<Box
p={3}
bg="rgba(255, 165, 0, 0.1)"
borderRadius="lg"
border="1px solid rgba(255, 165, 0, 0.3)"
>
<HStack spacing={2}>
<Icon as={FaClock} color="orange.400" />
<Text color="orange.400" fontSize="sm" fontWeight="medium">
{priceInfo.current_plan?.toUpperCase()}{selectedPlan.displayName}
</Text>
</HStack>
</Box>
)}
{priceInfo.is_renewal && (
<Box
p={3}
bg="rgba(33, 150, 243, 0.1)"
borderRadius="lg"
border="1px solid rgba(33, 150, 243, 0.3)"
>
<HStack spacing={2}>
<Icon as={FaRedo} color="blue.400" />
<Text color="blue.400" fontSize="sm" fontWeight="medium">
{selectedPlan.displayName}
</Text>
</HStack>
</Box>
)}
</>
)}
{/* 价格明细 */}
{selectedPlan && priceInfo && (
<Box
p={4}
bg="rgba(255, 255, 255, 0.05)"
borderRadius="lg"
border="1px solid rgba(255, 255, 255, 0.1)"
>
<VStack spacing={3} align="stretch">
<Flex justify="space-between" align="center">
<Text color="rgba(255, 255, 255, 0.7)" fontSize="sm">
{selectedPlan.displayName} · {selectedCycle === 'monthly' ? '月付' : selectedCycle === 'quarterly' ? '季付' : selectedCycle === 'semiannual' ? '半年付' : '年付'}
</Text>
<Text color="white" fontWeight="medium">
¥{priceInfo.original_price?.toFixed(2) || getCurrentPrice(selectedPlan).toFixed(2)}
</Text>
</Flex>
{/* 升级抵扣价值 */}
{priceInfo.is_upgrade && priceInfo.remaining_value > 0 && (
<Flex justify="space-between" align="center">
<Text color="rgba(255, 255, 255, 0.7)" fontSize="sm">
</Text>
<Text color="green.400" fontWeight="medium">
-¥{priceInfo.remaining_value.toFixed(2)}
</Text>
</Flex>
)}
{/* 优惠码折扣 */}
{promoCodeApplied && priceInfo.discount_amount > 0 && (
<Flex justify="space-between" align="center">
<HStack spacing={2}>
<Icon as={FaCheck} color="green.400" boxSize={3} />
<Text color="rgba(255, 255, 255, 0.7)" fontSize="sm">
</Text>
</HStack>
<Text color="green.400" fontWeight="medium">
-¥{priceInfo.discount_amount.toFixed(2)}
</Text>
</Flex>
)}
<Divider borderColor="rgba(255, 255, 255, 0.1)" />
<Flex justify="space-between" align="baseline">
<Text fontSize="lg" fontWeight="bold" color="white">:</Text>
<Text fontSize="2xl" fontWeight="bold" color="#D4AF37">
¥{priceInfo.final_amount.toFixed(2)}
</Text>
</Flex>
</VStack>
</Box>
)}
{/* 优惠码输入 */}
{selectedPlan && (
<Box>
<HStack spacing={2}>
<Input
placeholder="输入优惠码(可选)"
value={promoCode}
onChange={(e) => {
setPromoCode(e.target.value.toUpperCase());
setPromoCodeError('');
}}
size="md"
isDisabled={promoCodeApplied}
bg="rgba(255, 255, 255, 0.05)"
border="1px solid rgba(255, 255, 255, 0.1)"
color="white"
_placeholder={{ color: 'rgba(255, 255, 255, 0.4)' }}
_hover={{ borderColor: 'rgba(212, 175, 55, 0.3)' }}
_focus={{ borderColor: '#D4AF37', boxShadow: '0 0 0 1px #D4AF37' }}
/>
<Button
bgGradient="linear-gradient(135deg, rgba(138, 43, 226, 0.8), rgba(123, 31, 162, 0.8))"
color="white"
onClick={handleValidatePromoCode}
isLoading={validatingPromo}
isDisabled={!promoCode || promoCodeApplied}
minW="80px"
_hover={{
bgGradient: 'linear-gradient(135deg, rgba(138, 43, 226, 1), rgba(123, 31, 162, 1))',
}}
>
</Button>
</HStack>
{promoCodeError && (
<Text color="red.400" fontSize="sm" mt={2}>
{promoCodeError}
</Text>
)}
{promoCodeApplied && priceInfo && (
<HStack
mt={2}
p={2}
bg="rgba(72, 187, 120, 0.1)"
borderRadius="md"
border="1px solid rgba(72, 187, 120, 0.3)"
>
<Icon as={FaCheck} color="green.400" />
<Text color="green.400" fontSize="sm" fontWeight="medium" flex={1}>
¥{priceInfo.discount_amount.toFixed(2)}
</Text>
<Icon
as={FaTimes}
color="rgba(255, 255, 255, 0.5)"
cursor="pointer"
onClick={handleRemovePromoCode}
_hover={{ color: 'red.400' }}
/>
</HStack>
)}
</Box>
)}
<Button
w="full"
size="lg"
bgGradient="linear-gradient(135deg, #D4AF37, #B8941F)"
color="#000"
fontWeight="bold"
onClick={handleCreatePaymentOrder}
isLoading={loading}
isDisabled={!selectedPlan}
_hover={{
bgGradient: 'linear-gradient(135deg, #F4E3A7, #D4AF37)',
}}
>
{priceInfo?.is_upgrade && priceInfo?.final_amount === 0
? '立即免费升级'
: '创建微信支付订单'}
</Button>
</VStack>
) : (
<VStack spacing={4}>
<Text color="rgba(255, 255, 255, 0.7)" fontSize="lg" fontWeight="bold">
使
</Text>
{/* 倒计时 */}
<Box
p={3}
bg="rgba(255, 165, 0, 0.1)"
borderRadius="lg"
border="1px solid rgba(255, 165, 0, 0.3)"
w="full"
>
<HStack justify="center" spacing={2} mb={2}>
<Icon as={FaClock} color="orange.400" />
<Text color="orange.300" fontSize="sm">
: {formatTime(paymentCountdown)}
</Text>
</HStack>
<Progress
value={(paymentCountdown / (30 * 60)) * 100}
colorScheme="orange"
size="sm"
borderRadius="full"
/>
</Box>
{/* 二维码 */}
<Box textAlign="center">
{paymentOrder.qr_code_url ? (
<Image
src={paymentOrder.qr_code_url}
alt="微信支付二维码"
mx="auto"
maxW="200px"
border="2px solid"
borderColor="rgba(255, 255, 255, 0.2)"
borderRadius="lg"
bg="white"
p={2}
/>
) : (
<Flex
w="200px"
h="200px"
mx="auto"
bg="rgba(255, 255, 255, 0.05)"
alignItems="center"
justifyContent="center"
border="2px solid"
borderColor="rgba(255, 255, 255, 0.2)"
borderRadius="lg"
>
<Icon as={FaQrcode} color="rgba(255, 255, 255, 0.3)" boxSize={12} />
</Flex>
)}
</Box>
{/* 订单信息 */}
<Box
p={4}
bg="rgba(255, 255, 255, 0.05)"
borderRadius="lg"
border="1px solid rgba(255, 255, 255, 0.1)"
w="full"
>
<Text fontSize="xs" color="rgba(255, 255, 255, 0.5)" mb={2}>
: {paymentOrder.order_no}
</Text>
<Flex justify="space-between" align="baseline">
<Text color="rgba(255, 255, 255, 0.7)">:</Text>
<Text fontSize="xl" fontWeight="bold" color="#D4AF37">
¥{paymentOrder.amount}
</Text>
</Flex>
</Box>
{/* 操作按钮 */}
<VStack spacing={3} w="full">
<Button
w="full"
size="lg"
bgGradient="linear-gradient(135deg, #D4AF37, #B8941F)"
color="#000"
fontWeight="bold"
leftIcon={<Icon as={FaRedo} />}
onClick={handleForceUpdate}
isLoading={forceUpdating}
_hover={{
bgGradient: 'linear-gradient(135deg, #F4E3A7, #D4AF37)',
}}
>
</Button>
</VStack>
</VStack>
)}
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
}