2025-07-30 23:55:34 -07:00

401 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 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;