feat: 添加导航徽章
This commit is contained in:
880
src/components/Subscription/SubscriptionContent.js
Normal file
880
src/components/Subscription/SubscriptionContent.js
Normal file
@@ -0,0 +1,880 @@
|
||||
// src/components/Subscription/SubscriptionContent.js
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Grid,
|
||||
Icon,
|
||||
Text,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Image,
|
||||
Progress,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Icons
|
||||
import {
|
||||
FaWeixin,
|
||||
FaGem,
|
||||
FaStar,
|
||||
FaCheck,
|
||||
FaQrcode,
|
||||
FaClock,
|
||||
FaRedo,
|
||||
FaCrown,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
export default function SubscriptionContent() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgCard = useColorModeValue('white', 'gray.800');
|
||||
const bgAccent = useColorModeValue('blue.50', 'blue.900');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
const toast = useToast();
|
||||
const { isOpen: isPaymentModalOpen, onOpen: onPaymentModalOpen, onClose: onPaymentModalClose } = useDisclosure();
|
||||
|
||||
// State
|
||||
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||
const [selectedCycle, setSelectedCycle] = useState('monthly');
|
||||
const [paymentOrder, setPaymentOrder] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [paymentCountdown, setPaymentCountdown] = useState(0);
|
||||
const [checkingPayment, setCheckingPayment] = useState(false);
|
||||
const [autoCheckInterval, setAutoCheckInterval] = useState(null);
|
||||
const [forceUpdating, setForceUpdating] = useState(false);
|
||||
|
||||
// 加载订阅套餐数据
|
||||
useEffect(() => {
|
||||
fetchSubscriptionPlans();
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
// 倒计时更新
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (paymentCountdown > 0) {
|
||||
timer = setInterval(() => {
|
||||
setPaymentCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
handlePaymentExpired();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [paymentCountdown]);
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopAutoPaymentCheck();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchSubscriptionPlans = async () => {
|
||||
try {
|
||||
logger.debug('SubscriptionContent', '正在获取订阅套餐');
|
||||
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 =>
|
||||
plan &&
|
||||
plan.name &&
|
||||
typeof plan.monthly_price === 'number' &&
|
||||
typeof plan.yearly_price === 'number'
|
||||
);
|
||||
logger.debug('SubscriptionContent', '套餐加载成功', {
|
||||
status: response.status,
|
||||
validPlansCount: validPlans.length
|
||||
});
|
||||
setSubscriptionPlans(validPlans);
|
||||
} else {
|
||||
logger.warn('SubscriptionContent', '套餐数据格式异常', { data });
|
||||
setSubscriptionPlans([]);
|
||||
}
|
||||
} else {
|
||||
logger.error('SubscriptionContent', 'fetchSubscriptionPlans', new Error(`HTTP ${response.status}`));
|
||||
setSubscriptionPlans([]);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('SubscriptionContent', 'fetchSubscriptionPlans', error);
|
||||
setSubscriptionPlans([]);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/session', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
logger.debug('SubscriptionContent', '用户数据获取成功', { data });
|
||||
if (data.success) {
|
||||
setCurrentUser(data.user);
|
||||
logger.debug('SubscriptionContent', '用户信息已更新', {
|
||||
userId: data.user?.id,
|
||||
subscriptionType: data.user?.subscription_type,
|
||||
subscriptionStatus: data.user?.subscription_status
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('SubscriptionContent', 'fetchCurrentUser', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = (plan) => {
|
||||
if (!currentUser) {
|
||||
toast({
|
||||
title: '请先登录',
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!plan || !plan.name) {
|
||||
toast({
|
||||
title: '套餐信息错误',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPlan(plan);
|
||||
onPaymentModalOpen();
|
||||
};
|
||||
|
||||
const handleCreateOrder = async () => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setPaymentOrder(data.data);
|
||||
setPaymentCountdown(30 * 60);
|
||||
|
||||
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) {
|
||||
toast({
|
||||
title: '创建订单失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentExpired = () => {
|
||||
setPaymentOrder(null);
|
||||
setPaymentCountdown(0);
|
||||
stopAutoPaymentCheck();
|
||||
toast({
|
||||
title: '支付二维码已过期',
|
||||
description: '请重新创建订单',
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const startAutoPaymentCheck = (orderId) => {
|
||||
logger.info('SubscriptionContent', '开始自动检查支付状态', { orderId });
|
||||
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/payment/order/${orderId}/status`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
logger.debug('SubscriptionContent', '支付状态检查结果', {
|
||||
orderId,
|
||||
paymentSuccess: data.payment_success,
|
||||
data
|
||||
});
|
||||
|
||||
if (data.success && data.payment_success) {
|
||||
clearInterval(checkInterval);
|
||||
setAutoCheckInterval(null);
|
||||
|
||||
logger.info('SubscriptionContent', '自动检测到支付成功', { orderId });
|
||||
toast({
|
||||
title: '支付成功!',
|
||||
description: '订阅已激活,正在跳转...',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
onPaymentModalClose();
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('SubscriptionContent', 'startAutoPaymentCheck', error, { orderId });
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
setAutoCheckInterval(checkInterval);
|
||||
};
|
||||
|
||||
const stopAutoPaymentCheck = () => {
|
||||
if (autoCheckInterval) {
|
||||
clearInterval(autoCheckInterval);
|
||||
setAutoCheckInterval(null);
|
||||
logger.debug('SubscriptionContent', '停止自动检查支付状态');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshUserStatus = async () => {
|
||||
try {
|
||||
await fetchCurrentUser();
|
||||
toast({
|
||||
title: '用户状态已刷新',
|
||||
description: '订阅信息已更新',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '刷新失败',
|
||||
description: '请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceUpdatePayment = async () => {
|
||||
if (!paymentOrder) return;
|
||||
|
||||
setForceUpdating(true);
|
||||
try {
|
||||
const response = await fetch(`/api/payment/order/${paymentOrder.id}/force-update`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
logger.info('SubscriptionContent', '强制更新支付状态结果', {
|
||||
orderId: paymentOrder.id,
|
||||
paymentSuccess: data.payment_success,
|
||||
data
|
||||
});
|
||||
|
||||
if (data.success && data.payment_success) {
|
||||
stopAutoPaymentCheck();
|
||||
|
||||
toast({
|
||||
title: '状态更新成功!',
|
||||
description: '订阅已激活,正在刷新页面...',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
onPaymentModalClose();
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
toast({
|
||||
title: '无法更新状态',
|
||||
description: data.error || '支付状态未更新',
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error('网络错误');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('SubscriptionContent', 'handleForceUpdatePayment', error, {
|
||||
orderId: paymentOrder?.id
|
||||
});
|
||||
toast({
|
||||
title: '强制更新失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setForceUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckPaymentStatus = async () => {
|
||||
if (!paymentOrder) return;
|
||||
|
||||
setCheckingPayment(true);
|
||||
try {
|
||||
const response = await fetch(`/api/payment/order/${paymentOrder.id}/status`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
logger.info('SubscriptionContent', '手动检查支付状态结果', {
|
||||
orderId: paymentOrder.id,
|
||||
paymentSuccess: data.payment_success,
|
||||
data: data.data
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
if (data.payment_success) {
|
||||
stopAutoPaymentCheck();
|
||||
|
||||
logger.info('SubscriptionContent', '手动检测到支付成功', {
|
||||
orderId: paymentOrder.id
|
||||
});
|
||||
toast({
|
||||
title: '支付成功!',
|
||||
description: '订阅已激活,正在跳转...',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
onPaymentModalClose();
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
|
||||
} else {
|
||||
toast({
|
||||
title: '支付状态检查',
|
||||
description: data.message || '还未检测到支付,请继续等待',
|
||||
status: 'info',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || '查询失败');
|
||||
}
|
||||
} else {
|
||||
throw new Error('网络错误');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('SubscriptionContent', 'handleCheckPaymentStatus', error, {
|
||||
orderId: paymentOrder?.id
|
||||
});
|
||||
toast({
|
||||
title: '查询失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setCheckingPayment(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getCurrentPrice = (plan) => {
|
||||
if (!plan) return 0;
|
||||
return selectedCycle === 'monthly' ? plan.monthly_price : plan.yearly_price;
|
||||
};
|
||||
|
||||
const getSavingsText = (plan) => {
|
||||
if (!plan || selectedCycle !== 'yearly') return null;
|
||||
const yearlyTotal = plan.monthly_price * 12;
|
||||
const savings = yearlyTotal - plan.yearly_price;
|
||||
const percentage = Math.round((savings / yearlyTotal) * 100);
|
||||
return `年付节省 ${percentage}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch" w="100%">
|
||||
{/* 当前订阅状态 */}
|
||||
{currentUser && (
|
||||
<Box
|
||||
p={6}
|
||||
borderRadius="xl"
|
||||
bg={bgCard}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
shadow="sm"
|
||||
>
|
||||
<Flex justify="space-between" align="center" mb={4}>
|
||||
<Text fontSize="lg" fontWeight="bold" color={textColor}>
|
||||
当前订阅状态
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Icon as={FaRedo} />}
|
||||
onClick={handleRefreshUserStatus}
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Box>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Badge
|
||||
colorScheme={
|
||||
currentUser.subscription_type === 'max' ? 'purple' :
|
||||
currentUser.subscription_type === 'pro' ? 'blue' : 'gray'
|
||||
}
|
||||
variant="subtle"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
>
|
||||
{currentUser.subscription_type === 'free' ? '基础版' :
|
||||
currentUser.subscription_type === 'pro' ? 'Pro 专业版' : 'Max 旗舰版'}
|
||||
</Badge>
|
||||
<Badge
|
||||
colorScheme={currentUser.subscription_status === 'active' ? 'green' : 'red'}
|
||||
variant="subtle"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
>
|
||||
{currentUser.subscription_status === 'active' ? '已激活' : '未激活'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{currentUser.subscription_end_date && (
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
到期时间: {new Date(currentUser.subscription_end_date).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{currentUser.subscription_status === 'active' && currentUser.subscription_type !== 'free' && (
|
||||
<Icon
|
||||
as={currentUser.subscription_type === 'max' ? FaCrown : FaGem}
|
||||
color={currentUser.subscription_type === 'max' ? 'purple.400' : 'blue.400'}
|
||||
boxSize={8}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 计费周期选择 */}
|
||||
<Flex justify="center">
|
||||
<HStack
|
||||
spacing={0}
|
||||
bg={bgAccent}
|
||||
borderRadius="lg"
|
||||
p={1}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Button
|
||||
variant={selectedCycle === 'monthly' ? 'solid' : 'ghost'}
|
||||
colorScheme={selectedCycle === 'monthly' ? 'blue' : 'gray'}
|
||||
size="md"
|
||||
onClick={() => setSelectedCycle('monthly')}
|
||||
borderRadius="md"
|
||||
>
|
||||
按月付费
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCycle === 'yearly' ? 'solid' : 'ghost'}
|
||||
colorScheme={selectedCycle === 'yearly' ? 'blue' : 'gray'}
|
||||
size="md"
|
||||
onClick={() => setSelectedCycle('yearly')}
|
||||
borderRadius="md"
|
||||
>
|
||||
按年付费
|
||||
<Badge ml={2} colorScheme="red" fontSize="xs">省20%</Badge>
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 订阅套餐 */}
|
||||
<Grid
|
||||
templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }}
|
||||
gap={6}
|
||||
>
|
||||
{subscriptionPlans.length === 0 ? (
|
||||
<Box gridColumn={{ base: '1', md: '1 / -1' }} textAlign="center" py={8}>
|
||||
<Text color={secondaryText}>正在加载订阅套餐...</Text>
|
||||
</Box>
|
||||
) : (
|
||||
subscriptionPlans.filter(plan => plan && plan.name).map((plan) => (
|
||||
<Box
|
||||
key={plan.id}
|
||||
position="relative"
|
||||
borderRadius="2xl"
|
||||
overflow="hidden"
|
||||
border="2px solid"
|
||||
borderColor={plan.name === 'max' ? 'purple.400' : 'blue.300'}
|
||||
bg={bgCard}
|
||||
transition="all 0.3s ease"
|
||||
_hover={{
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: 'xl',
|
||||
}}
|
||||
>
|
||||
{/* 推荐标签 */}
|
||||
{plan.name === 'max' && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bg="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
py={1}
|
||||
textAlign="center"
|
||||
>
|
||||
<Text color="white" fontSize="xs" fontWeight="bold">
|
||||
🔥 最受欢迎
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<VStack
|
||||
spacing={5}
|
||||
align="stretch"
|
||||
p={6}
|
||||
pt={plan.name === 'max' ? 10 : 6}
|
||||
>
|
||||
{/* 套餐头部 */}
|
||||
<VStack spacing={2} align="center">
|
||||
<Icon
|
||||
as={plan.name === 'pro' ? FaGem : FaCrown}
|
||||
boxSize={12}
|
||||
color={plan.name === 'pro' ? 'blue.400' : 'purple.400'}
|
||||
/>
|
||||
<Text fontSize="2xl" fontWeight="bold" color={textColor}>
|
||||
{plan.display_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={secondaryText} textAlign="center" minH="40px">
|
||||
{plan.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* 价格 */}
|
||||
<VStack spacing={2}>
|
||||
<HStack justify="center" align="baseline" spacing={1}>
|
||||
<Text fontSize="sm" color={secondaryText}>¥</Text>
|
||||
<Text fontSize="4xl" fontWeight="bold" color={textColor}>
|
||||
{getCurrentPrice(plan).toFixed(0)}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
/ {selectedCycle === 'monthly' ? '月' : '年'}
|
||||
</Text>
|
||||
</HStack>
|
||||
{getSavingsText(plan) && (
|
||||
<Badge colorScheme="green" fontSize="xs" px={2} py={1}>
|
||||
{getSavingsText(plan)}
|
||||
</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 功能列表 */}
|
||||
<VStack spacing={3} align="stretch" minH="200px">
|
||||
{plan.features.map((feature, index) => (
|
||||
<HStack key={index} spacing={3} align="start">
|
||||
<Icon
|
||||
as={FaCheck}
|
||||
color={plan.name === 'max' ? 'purple.400' : 'blue.400'}
|
||||
boxSize={4}
|
||||
mt={0.5}
|
||||
/>
|
||||
<Text fontSize="sm" color={textColor} flex={1}>
|
||||
{feature}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
{/* 订阅按钮 */}
|
||||
<Button
|
||||
size="lg"
|
||||
colorScheme={plan.name === 'max' ? 'purple' : 'blue'}
|
||||
variant="solid"
|
||||
onClick={() => handleSubscribe(plan)}
|
||||
isDisabled={
|
||||
currentUser?.subscription_type === plan.name &&
|
||||
currentUser?.subscription_status === 'active'
|
||||
}
|
||||
_hover={{
|
||||
transform: 'scale(1.02)',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{currentUser?.subscription_type === plan.name &&
|
||||
currentUser?.subscription_status === 'active'
|
||||
? '✓ 已订阅'
|
||||
: `选择 ${plan.display_name}`
|
||||
}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
)))}
|
||||
</Grid>
|
||||
|
||||
{/* 支付模态框 */}
|
||||
{isPaymentModalOpen && (
|
||||
<Modal
|
||||
isOpen={isPaymentModalOpen}
|
||||
onClose={() => {
|
||||
stopAutoPaymentCheck();
|
||||
setPaymentOrder(null);
|
||||
setPaymentCountdown(0);
|
||||
onPaymentModalClose();
|
||||
}}
|
||||
size="lg"
|
||||
closeOnOverlayClick={false}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<HStack>
|
||||
<Icon as={FaWeixin} color="green.500" />
|
||||
<Text>微信支付</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
{!paymentOrder ? (
|
||||
/* 订单确认 */
|
||||
<VStack spacing={4} align="stretch">
|
||||
{selectedPlan ? (
|
||||
<Box p={4} bg={bgAccent} borderRadius="lg">
|
||||
<Text fontSize="lg" fontWeight="bold" mb={3}>
|
||||
订单确认
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Flex justify="space-between">
|
||||
<Text color={secondaryText}>套餐:</Text>
|
||||
<Text fontWeight="bold">{selectedPlan.display_name}</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between">
|
||||
<Text color={secondaryText}>计费周期:</Text>
|
||||
<Text>{selectedCycle === 'monthly' ? '按月付费' : '按年付费'}</Text>
|
||||
</Flex>
|
||||
<Divider />
|
||||
<Flex justify="space-between" align="baseline">
|
||||
<Text color={secondaryText}>应付金额:</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
|
||||
¥{getCurrentPrice(selectedPlan).toFixed(2)}
|
||||
</Text>
|
||||
</Flex>
|
||||
{getSavingsText(selectedPlan) && (
|
||||
<Badge colorScheme="green" alignSelf="flex-end" fontSize="xs">
|
||||
{getSavingsText(selectedPlan)}
|
||||
</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
) : (
|
||||
<Box p={4} bg="red.50" borderRadius="lg">
|
||||
<Text color="red.600">请先选择一个订阅套餐</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
colorScheme="green"
|
||||
size="lg"
|
||||
leftIcon={<Icon as={FaWeixin} />}
|
||||
onClick={handleCreateOrder}
|
||||
isLoading={loading}
|
||||
loadingText="创建订单中..."
|
||||
isDisabled={!selectedPlan}
|
||||
>
|
||||
创建微信支付订单
|
||||
</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
/* 支付二维码 */
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Text textAlign="center" fontSize="lg" fontWeight="bold">
|
||||
请使用微信扫码支付
|
||||
</Text>
|
||||
|
||||
{/* 倒计时 */}
|
||||
<Box p={3} bg="orange.50" borderRadius="lg">
|
||||
<HStack justify="center" spacing={2} mb={2}>
|
||||
<Icon as={FaClock} color="orange.500" />
|
||||
<Text color="orange.700" 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="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
w="200px"
|
||||
h="200px"
|
||||
mx="auto"
|
||||
bg="gray.100"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Icon as={FaQrcode} color="gray.400" boxSize={12} />
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 订单信息 */}
|
||||
<Box p={4} bg={bgAccent} borderRadius="lg">
|
||||
<Text fontSize="xs" color={secondaryText} mb={2}>
|
||||
订单号: {paymentOrder.order_no}
|
||||
</Text>
|
||||
<Flex justify="space-between" align="baseline">
|
||||
<Text color={secondaryText}>支付金额:</Text>
|
||||
<Text fontSize="xl" fontWeight="bold" color="green.500">
|
||||
¥{paymentOrder.amount}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<VStack spacing={3}>
|
||||
<HStack spacing={3} w="100%">
|
||||
<Button
|
||||
flex={1}
|
||||
variant="outline"
|
||||
leftIcon={<Icon as={FaRedo} />}
|
||||
onClick={handleCheckPaymentStatus}
|
||||
isLoading={checkingPayment}
|
||||
loadingText="检查中..."
|
||||
>
|
||||
检查支付状态
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
w="100%"
|
||||
colorScheme="orange"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
onClick={handleForceUpdatePayment}
|
||||
isLoading={forceUpdating}
|
||||
loadingText="强制更新中..."
|
||||
>
|
||||
强制更新支付状态
|
||||
</Button>
|
||||
|
||||
<Text fontSize="xs" color={secondaryText} textAlign="center">
|
||||
支付完成但页面未更新?点击上方"强制更新"按钮
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* 支付状态提示 */}
|
||||
{autoCheckInterval && (
|
||||
<Box p={3} bg="blue.50" borderRadius="lg" borderWidth="1px" borderColor="blue.200">
|
||||
<Text fontSize="sm" color="blue.700" textAlign="center">
|
||||
🔄 正在自动检查支付状态...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 支付说明 */}
|
||||
<VStack align="stretch" spacing={1} fontSize="xs" color={secondaryText}>
|
||||
<Text>• 使用微信"扫一扫"功能扫描上方二维码</Text>
|
||||
<Text>• 支付完成后系统将自动检测并激活订阅</Text>
|
||||
<Text>• 系统每10秒自动检查一次支付状态</Text>
|
||||
<Text>• 如遇问题请联系客服支持</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user