401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import CloseSvg from '@/components/CustomIcon/icons/close.svg';
|
||
import { getSubscription } from '@/services/user/portal';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Dialog, DialogContent, DialogTitle } from '@workspace/ui/components/dialog';
|
||
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
||
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||
import { unitConversion } from '@workspace/ui/utils';
|
||
import Image from 'next/image';
|
||
import {
|
||
forwardRef,
|
||
useCallback,
|
||
useEffect,
|
||
useImperativeHandle,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import { TabContent } from './TabContent';
|
||
import { ProcessedPlanData } from './types';
|
||
|
||
// 加载状态组件
|
||
const LoadingState = () => (
|
||
<div className='py-12 text-center'>
|
||
<div className='mx-auto h-12 w-12 animate-spin rounded-full border-b-2 border-[#0F2C53]'></div>
|
||
<p className='mt-4 text-gray-600'>加载中...</p>
|
||
</div>
|
||
);
|
||
|
||
// 错误状态组件
|
||
const ErrorState = ({ onRetry }: { onRetry: () => void }) => (
|
||
<div className='py-12 text-center'>
|
||
<p className='text-lg text-red-500'>加载失败,请重试</p>
|
||
<button
|
||
onClick={onRetry}
|
||
className='mt-4 rounded-lg bg-[#0F2C53] px-6 py-2 text-white transition-colors hover:bg-[#0A2C47]'
|
||
>
|
||
重新加载
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
// 空状态组件
|
||
const EmptyState = ({ message }: { message: string }) => (
|
||
<div className='py-12 text-center'>
|
||
<p className='text-lg text-gray-500'>{message}</p>
|
||
</div>
|
||
);
|
||
|
||
// 价格显示组件
|
||
const PriceDisplay = ({ plan }: { plan: ProcessedPlanData }) => (
|
||
<div className='mb-4 sm:mb-8'>
|
||
<div className='mb-1 flex items-baseline gap-2'>
|
||
{plan.origin_price && (
|
||
<span className='text-lg font-bold leading-[1.125em] text-[#666666] line-through sm:text-xl md:text-[24px]'>
|
||
${plan.origin_price}
|
||
</span>
|
||
)}
|
||
<span className='text-lg font-bold leading-[1.125em] text-[#091B33] sm:text-xl md:text-[24px]'>
|
||
${plan.discount_price}
|
||
</span>
|
||
<span className='text-sm font-normal leading-[1.8em] text-[#4D4D4D] sm:text-[15px]'>/年</span>
|
||
</div>
|
||
{plan.origin_price && (
|
||
<p className='text-left text-[10px] font-normal text-black'>年付享受8折优惠</p>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
// 订阅按钮组件
|
||
const SubscribeButton = ({ onClick }: { onClick?: () => void }) => (
|
||
<button
|
||
onClick={onClick}
|
||
className='h-10 w-full rounded-full bg-[#0F2C53] text-sm font-medium text-white shadow-md transition-all duration-300 hover:bg-[#0A2C47] sm:h-10 sm:text-sm md:h-[40px] md:text-[14px]'
|
||
>
|
||
订阅
|
||
</button>
|
||
);
|
||
|
||
// 星级评分组件
|
||
const StarRating = ({ rating, maxRating = 5 }: { rating: number; maxRating?: number }) => (
|
||
<div className='text-right text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]'>
|
||
{Array.from({ length: Math.min(rating, maxRating) }, (_, i) => (
|
||
<span key={i} className='text-black'>
|
||
✭
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
// 功能列表组件
|
||
const FeatureList = ({ plan }: { plan: ProcessedPlanData }) => {
|
||
const features = [
|
||
{ label: '可用流量', value: plan.features?.traffic || '1' },
|
||
{ label: '套餐时长', value: plan.features?.duration || '1' },
|
||
{ label: '在线IP', value: plan.features?.onlineIPs || '2' },
|
||
{ label: '在线连接数', value: plan.features?.connections || '3' },
|
||
{ label: '峰值带宽', value: plan.features?.bandwidth || '2' },
|
||
{ label: '可用节点', value: plan.features?.nodes || '11' },
|
||
];
|
||
|
||
return (
|
||
<div className='mt-6 space-y-0 sm:mt-8'>
|
||
<ul className='space-y-1'>
|
||
{features.map((feature) => (
|
||
<li key={feature.label} className='flex items-start justify-between py-1'>
|
||
<span className='text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]'>
|
||
{feature.label}:
|
||
</span>
|
||
<span className='text-right text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]'>
|
||
{feature.value}
|
||
</span>
|
||
</li>
|
||
))}
|
||
<li className='flex items-start justify-between py-1'>
|
||
<span className='text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]'>
|
||
网络稳定指数:
|
||
</span>
|
||
<StarRating rating={plan.features?.stability || 4} />
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 套餐卡片组件
|
||
const PlanCard = forwardRef<
|
||
HTMLDivElement,
|
||
{
|
||
plan: ProcessedPlanData;
|
||
tabValue: string;
|
||
onSubscribe?: (plan: ProcessedPlanData) => void;
|
||
isFirstCard?: boolean;
|
||
}
|
||
>(({ plan, onSubscribe, isFirstCard = false }, ref) => {
|
||
const handleSubscribe = () => {
|
||
onSubscribe?.(plan);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={ref}
|
||
className='relative w-full max-w-[345px] cursor-pointer rounded-[20px] border border-[#D9D9D9] bg-white p-4 transition-all duration-300 hover:shadow-lg sm:p-6 md:p-8'
|
||
>
|
||
{/* 套餐名称 */}
|
||
<h3 className='mb-4 text-left text-sm font-normal sm:mb-6 sm:text-base'>{plan.name}</h3>
|
||
|
||
{/* 价格区域 */}
|
||
<PriceDisplay plan={plan} />
|
||
|
||
{/* 订阅按钮 */}
|
||
<SubscribeButton onClick={handleSubscribe} />
|
||
|
||
{/* 功能列表 */}
|
||
<FeatureList plan={plan} />
|
||
</div>
|
||
);
|
||
});
|
||
|
||
PlanCard.displayName = 'PlanCard';
|
||
|
||
// 套餐列表组件
|
||
export const PlanList = ({
|
||
plans,
|
||
tabValue,
|
||
isLoading,
|
||
error,
|
||
onRetry,
|
||
emptyMessage,
|
||
onSubscribe,
|
||
firstPlanCardRef, // 新增参数
|
||
}: {
|
||
plans: ProcessedPlanData[];
|
||
tabValue: string;
|
||
isLoading: boolean;
|
||
error: any;
|
||
onRetry: () => void;
|
||
emptyMessage: string;
|
||
onSubscribe?: (plan: ProcessedPlanData) => void;
|
||
firstPlanCardRef?: React.RefObject<HTMLDivElement | null>;
|
||
}) => {
|
||
if (isLoading) return <LoadingState />;
|
||
if (error) return <ErrorState onRetry={onRetry} />;
|
||
if (plans.length === 0) return <EmptyState message={emptyMessage} />;
|
||
|
||
return (
|
||
<div className='grid grid-cols-1 justify-items-center gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8'>
|
||
{plans.map((plan, index) => (
|
||
<PlanCard
|
||
key={`${plan.id}-${plan.name}`}
|
||
tabValue={tabValue}
|
||
plan={plan}
|
||
onSubscribe={onSubscribe}
|
||
isFirstCard={index === 0} // 标识第一项
|
||
ref={index === 0 ? firstPlanCardRef : undefined} // 第一项使用ref
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export interface OfferDialogRef {
|
||
show: () => void;
|
||
hide: () => void;
|
||
}
|
||
|
||
const OfferDialog = forwardRef<OfferDialogRef>((props, ref) => {
|
||
const [open, setOpen] = useState(false);
|
||
const [tabValue, setTabValue] = useState('year');
|
||
const [selectedPlan, setSelectedPlan] = useState<ProcessedPlanData | null>(null);
|
||
const [scrollAreaHeight, setScrollAreaHeight] = useState(450);
|
||
const [planCardHeight, setPlanCardHeight] = useState(600);
|
||
const dialogRef = useRef<HTMLDivElement>(null);
|
||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||
const firstPlanCardRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 获取PlanCard高度
|
||
const getPlanCardHeight = useCallback(() => {
|
||
if (firstPlanCardRef.current) {
|
||
return firstPlanCardRef.current.offsetHeight;
|
||
}
|
||
return 600; // 默认高度
|
||
}, []);
|
||
|
||
// 计算 ScrollArea 高度
|
||
const calculateScrollAreaHeight = useCallback(() => {
|
||
if (dialogRef.current && scrollAreaRef.current) {
|
||
const isMobile = window.innerWidth < 768;
|
||
|
||
if (isMobile) {
|
||
// 移动端:使用动态计算
|
||
const dialogHeight = dialogRef.current.offsetHeight;
|
||
const scrollAreaTop = scrollAreaRef.current.offsetTop;
|
||
const calculatedHeight = dialogHeight - scrollAreaTop;
|
||
setScrollAreaHeight(calculatedHeight);
|
||
} else {
|
||
// PC端:使用PlanCard第一项高度
|
||
const cardHeight = getPlanCardHeight();
|
||
setScrollAreaHeight(cardHeight);
|
||
}
|
||
}
|
||
}, [getPlanCardHeight]);
|
||
|
||
// 使用 useQuery 来管理请求
|
||
const {
|
||
data = [],
|
||
isLoading,
|
||
error,
|
||
refetch,
|
||
} = useQuery({
|
||
queryKey: ['subscription'],
|
||
queryFn: async () => {
|
||
try {
|
||
const response = await getSubscription({ skipErrorHandler: true });
|
||
// 确保返回有效的数组,避免 undefined
|
||
const list = response.data?.data?.list || [];
|
||
return list.filter((v) => v.unit_time === 'Month') as unknown as ProcessedPlanData[];
|
||
} catch (err) {
|
||
// 自定义错误处理
|
||
console.error('获取订阅数据失败:', err);
|
||
// 返回空数组而不是抛出错误,避免 queryFn 返回 undefined
|
||
return [] as ProcessedPlanData[];
|
||
}
|
||
},
|
||
enabled: false, // 初始不执行,手动控制
|
||
retry: 1, // 失败时重试1次
|
||
});
|
||
|
||
// 监听对话框打开
|
||
useEffect(() => {
|
||
if (open) {
|
||
// 等待 DOM 渲染完成
|
||
const timer = setTimeout(calculateScrollAreaHeight, 0);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [open, calculateScrollAreaHeight]);
|
||
|
||
// 监听窗口大小变化
|
||
useEffect(() => {
|
||
if (open) {
|
||
refetch(); // 对话框打开时重新获取数据
|
||
const handleResize = () => {
|
||
calculateScrollAreaHeight();
|
||
};
|
||
|
||
window.addEventListener('resize', handleResize);
|
||
return () => window.removeEventListener('resize', handleResize);
|
||
}
|
||
}, [open, calculateScrollAreaHeight]);
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
show: () => setOpen(true),
|
||
hide: () => setOpen(false),
|
||
}));
|
||
|
||
// 处理订阅点击
|
||
const handleSubscribe = (plan: ProcessedPlanData) => {
|
||
setSelectedPlan(plan);
|
||
console.log('用户选择了套餐:', plan);
|
||
// 这里可以添加订阅逻辑,比如跳转到支付页面或显示确认对话框
|
||
};
|
||
|
||
// 处理套餐数据的工具函数
|
||
const processPlanData = (item: ProcessedPlanData, isYearly: boolean): ProcessedPlanData => {
|
||
if (isYearly) {
|
||
const discountItem = item.discount?.find((v) => v.quantity === 12);
|
||
return {
|
||
...item,
|
||
origin_price: unitConversion('centsToDollars', item.unit_price * 12).toString(), // 原价
|
||
discount_price: unitConversion(
|
||
'centsToDollars',
|
||
item.unit_price * ((discountItem?.discount || 100) / 100) * 12,
|
||
).toString(), // 优惠价格
|
||
};
|
||
} else {
|
||
return {
|
||
...item,
|
||
origin_price: '', // 月付没有原价
|
||
discount_price: unitConversion('centsToDollars', item.unit_price).toString(), // 月付价格
|
||
};
|
||
}
|
||
};
|
||
|
||
// 使用 useMemo 优化数据处理性能
|
||
const yearlyPlans: ProcessedPlanData[] = useMemo(
|
||
() => data.map((item) => processPlanData(item, true)),
|
||
[data],
|
||
);
|
||
|
||
const monthlyPlans: ProcessedPlanData[] = useMemo(
|
||
() => data.map((item) => processPlanData(item, false)),
|
||
[data],
|
||
);
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogContent
|
||
ref={dialogRef}
|
||
className='rounded-0 !container h-full w-full gap-0 px-8 py-8 sm:h-auto sm:!rounded-[32px] sm:px-12 sm:py-12 md:w-[1000px]'
|
||
closeIcon={<Image src={CloseSvg} alt={'close'} />}
|
||
closeClassName={
|
||
'right-6 top-6 font-bold text-black opacity-100 focus:ring-0 focus:ring-offset-0'
|
||
}
|
||
>
|
||
<DialogTitle className={'sr-only'}></DialogTitle>
|
||
<div className={'text-4xl font-bold text-[#0F2C53] md:mb-4 md:text-center md:text-5xl'}>
|
||
选择套餐
|
||
</div>
|
||
<div className={'text-lg font-medium text-[#666666] md:text-center'}>
|
||
选择最适合您的服务套餐
|
||
</div>
|
||
<div>
|
||
<Tabs
|
||
defaultValue='year'
|
||
className={'mt-8 text-center md:mt-16'}
|
||
value={tabValue}
|
||
onValueChange={setTabValue}
|
||
>
|
||
<TabsList className='mb-8 h-[74px] flex-wrap rounded-full bg-[#EAEAEA] p-2.5 md:mb-16'>
|
||
<TabsTrigger
|
||
className={
|
||
'rounded-full px-10 py-3.5 text-xl data-[state=active]:bg-[#0F2C53] data-[state=active]:text-white md:px-12'
|
||
}
|
||
value='year'
|
||
>
|
||
年付套餐
|
||
</TabsTrigger>
|
||
<TabsTrigger
|
||
className={
|
||
'rounded-full px-10 py-3.5 text-xl data-[state=active]:bg-[#0F2C53] data-[state=active]:text-white md:px-12'
|
||
}
|
||
value='month'
|
||
>
|
||
月付套餐
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</Tabs>
|
||
<ScrollArea
|
||
ref={scrollAreaRef}
|
||
className='overflow-y-auto'
|
||
style={{ height: `calc(${scrollAreaHeight}px - 32px)` }}
|
||
>
|
||
<TabContent
|
||
tabValue={tabValue}
|
||
yearlyPlans={yearlyPlans}
|
||
monthlyPlans={monthlyPlans}
|
||
isLoading={isLoading}
|
||
error={error}
|
||
onRetry={refetch}
|
||
onSubscribe={handleSubscribe}
|
||
firstPlanCardRef={firstPlanCardRef}
|
||
/>
|
||
</ScrollArea>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
});
|
||
|
||
export default OfferDialog;
|