@@ -6,7 +6,16 @@ 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 , useEffect , useImperativeHandle , useState } from 'react' ;
import {
forwardRef ,
useCallback ,
useEffect ,
useImperativeHandle ,
useMemo ,
useRef ,
useState ,
} from 'react' ;
// 定义数据类型
interface SubscriptionData {
id : string ;
@@ -14,6 +23,11 @@ interface SubscriptionData {
price : number ;
originalPrice : number ;
duration : string ;
unit_price : number ;
discount : Array < {
quantity : number ;
discount : number ;
} > ;
features : {
traffic : string ;
duration : string ;
@@ -25,119 +39,197 @@ interface SubscriptionData {
} ;
}
// 计算折扣价格的函数
const calculateDiscountPrice = ( unitPrice : number , discountPercent : number ) = > {
// 折扣百分比转换为小数 (80 -> 0.8)
const discountRate = discountPercent / 100 ;
// 计算折扣后的价格
const discountedPrice = unitPrice * discountRate ;
// 使用 unitConversion 避免浮点数精度问题
return unitConversion ( 'dollarsToCents' , discountedPrice ) ;
} ;
// 处理后的套餐数据类型
interface ProcessedPlanData extends SubscriptionData {
origin_price : string ;
discount_price : string ;
}
// 计算原价(未折扣价格)
const calculateOriginalPrice = ( unitPrice : number , discountPercent : number ) = > {
// 原价就是 unitPrice
return unitConversion ( 'dollarsToCents' , unitPrice ) ;
// 加载状态组件
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 = ( { plan } : { plan : SubscriptionData } ) = > {
const PlanCard = ( {
plan ,
onSubscribe ,
isFirstCard = false , // 新增参数标识是否为第一项
} : {
plan : ProcessedPlanData ;
tabValue : string ;
onSubscribe ? : ( plan : ProcessedPlanData ) = > void ;
isFirstCard? : boolean ;
} ) = > {
const cardRef = useRef < HTMLDivElement > ( null ) ;
// 如果是第一项,将ref传递给父组件
useEffect ( ( ) = > {
if ( isFirstCard && cardRef . current ) {
// 可以通过回调函数将高度传递给父组件
// 或者使用ref转发
}
} , [ isFirstCard ] ) ;
const handleSubscribe = ( ) = > {
onSubscribe ? . ( plan ) ;
} ;
return (
< div
className = { ` relative w-full max-w-[345px] cursor-pointer rounded-[20px] border border-[#D9D9D9] bg-white p-4 transition-all duration-300 sm:p-6 md:p-8 ` }
ref = { isFirstCard ? cardRef : undefined }
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 >
{ /* 价格区域 */ }
< div className = 'mb-6 sm:mb-8' >
< div className = 'mb-2 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 >
< p className = 'text-left text-[10px] font-normal text-black' > 年 付 享 受 8 折 优 惠 < / p >
< / div >
< PriceDisplay plan = { plan } / >
{ /* 订阅按钮 */ }
< button
className = { ` h-8 w-full rounded-full bg-[#0F2C53] text-xs font-medium leading-[1.9285714285714286em] text-white shadow-md transition-all duration-300 hover:bg-[#0A2C47] sm:h-10 sm:text-sm md:h-[40px] md:text-[14px] ` }
>
订 阅
< / button >
< SubscribeButton onClick = { handleSubscribe } / >
{ /* 功能列表 */ }
< div className = 'mt-6 space-y-0 sm:mt-8' >
< div className = 'flex items-start justify-between py-1' >
< span className = 'text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
可 用 流 量 :
< / span >
< span className = 'text-right text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
1
< / span >
< / div >
< div className = 'flex items-start justify-between py-1' >
< span className = 'text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
套 餐 时 长 :
< / span >
< span className = 'text-right text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
1
< / span >
< / div >
< div className = 'flex items-start justify-between py-1' >
< span className = 'text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
在 线 IP :
< / span >
< span className = 'text-right text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
2
< / span >
< / div >
< div className = 'flex items-start justify-between py-1' >
< span className = 'text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
在 线 连 接 数 :
< / span >
< span className = 'text-right text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
3
< / span >
< / div >
< div className = 'flex items-start justify-between py-1' >
< span className = 'text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
峰 值 带 宽 :
< / span >
< span className = 'text-right text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
2
< / span >
< / div >
< div className = 'flex items-start justify-between py-1' >
< span className = 'text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
可 用 节 点 :
< / span >
< span className = 'text-right text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
11
< / span >
< / div >
< div className = 'flex items-start justify-between py-1' >
< span className = 'text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
网 络 稳 定 指 数 :
< / span >
< div className = 'text-right text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]' >
{ Array . from ( { length : 4 } , ( _ , i ) = > (
< span key = { i } className = 'text-black' >
✭
< / span >
) ) }
< / div >
< / div >
< / div >
< FeatureList plan = { plan } / >
< / div >
) ;
} ;
// 套餐列表组件
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 > ;
} ) = > {
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 >
) ;
} ;
@@ -149,6 +241,40 @@ export interface OfferDialogRef {
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 {
@@ -175,153 +301,147 @@ const OfferDialog = forwardRef<OfferDialogRef>((props, ref) => {
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 , refetch ] ) ;
} , [ open , calculateScrollAreaHeight ] ) ;
useImperativeHandle ( ref , ( ) = > ( {
show : ( ) = > setOpen ( true ) ,
hide : ( ) = > setOpen ( false ) ,
} ) ) ;
// 按类型分组数据
const yearlyPlans = data . map ( ( item ) = > {
const discountItem = item . discount . find ( ( v ) = > v . quantity === 12 ) ;
return {
. . . item ,
origin_price : unitConversion ( 'centsToDollars' , item . unit_price * 12 ) , // 原价
discount_price : unitConversion (
'centsToDollars' ,
item . unit_price * ( discountItem . discount / 100 ) * 12 ,
) , // 优惠价格
} ;
} ) ;
const monthlyPlans = data . map ( ( item ) = > {
return {
. . . item ,
origin_price : '' , // 原价
discount_price : unitConversion ( 'centsToDollars' , item . unit_price ) , // 优惠价格
} ;
} ) ;
const [ tabValue , setTabValue ] = useState ( 'year' ) ;
// 处理订阅点击
const handleSubscribe = ( plan : ProcessedPlanData ) = > {
setSelectedPlan ( plan ) ;
console . log ( '用户选择了套餐:' , plan ) ;
// 这里可以添加订阅逻辑,比如跳转到支付页面或显示确认对话框
} ;
// 处理套餐数据的工具函数
const processPlanData = ( item : SubscriptionData , 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
className = {
'rounded-0 !container h-full w-full px-8 py-8 md :h-auto md:w-[1000px] md :!rounded-[32px] md :px-12 md :py-12'
}
ref = { dialogRef }
className = 'rounded-0 !container h-full w-full gap-0 px-8 py-8 s m:h-auto s m:!rounded-[32px] s m:px-12 s m: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 = { '' } >
< div className = { 'mb-4 text-center text-4xl font-bold text-[#0F2C53] md:text-5xl' } >
< div className = { 'text-4xl font-bold text-[#0F2C53] md:mb-4 md:text-center md:text-5xl' } >
选 择 套 餐
< / div >
< div className = { 'mb-8 text-center text-lg font-medium text-[#666666]' } >
< div className = { 'text-lg font-medium text-[#666666] md:text-center ' } >
选 择 最 适 合 您 的 服 务 套 餐
< / div >
< / DialogTitle >
< div >
< div className = { 'mt-8' } >
< Tabs
defaultValue = 'year'
className = { 'text-center' }
value = { tabValue }
onValueChange = { setTabValue }
>
< TabsList className = 'mb-8 h-[74px] flex-wrap rounded-full bg-[#EAEAEA] p-2.5' >
< TabsTrigger
className = {
'rounded-full px-12 py-3.5 text-xl data-[state=active]:bg-[#0F2C53] data-[state=active]:text-white'
}
value = 'year'
>
年 付 套 餐
< / TabsTrigger >
< TabsTrigger
className = {
'rounded-full px-12 py-3.5 text-xl data-[state=active]:bg-[#0F2C53] data-[state=active]:text-white'
}
value = 'month'
>
月 付 套 餐
< / TabsTrigger >
< / TabsList >
< / Tabs >
< / div >
< ScrollArea className = { 'h-[600px]' } >
{ tabValue === 'year' && (
< div >
{ isLoading ? (
< 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 >
) : error ? (
< div className = 'py-12 text-center' >
< p className = 'text-lg text-red-500' > 加 载 失 败 , 请 重 试 < / p >
< button
onClick = { ( ) = > refetch ( ) }
className = 'mt-4 rounded-lg bg-[#0F2C53] px-6 py-2 text-white transition-colors hover:bg-[#0A2C47]'
>
重 新 加 载
< / button >
< / div >
) : yearlyPlans . length > 0 ? (
< div className = 'relative mt-8' >
{ /* 卡片容器 */ }
< 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' >
{ yearlyPlans . map ( ( plan , index ) = > (
< PlanCard key = { plan . id } plan = { plan } / >
) ) }
< / div >
< / div >
) : (
< div className = 'py-12 text-center' >
< p className = 'text-lg text-gray-500' > 暂 无 年 付 套 餐 < / p >
< / div >
) }
< / div >
) }
{ tabValue === 'month' && (
< div >
{ isLoading ? (
< 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 >
) : error ? (
< div className = 'py-12 text-center' >
< p className = 'text-lg text-red-500' > 加 载 失 败 , 请 重 试 < / p >
< button
onClick = { ( ) = > refetch ( ) }
className = 'mt-4 rounded-lg bg-[#0F2C53] px-6 py-2 text-white transition-colors hover:bg-[#0A2C47]'
>
重 新 加 载
< / button >
< / div >
) : monthlyPlans . length > 0 ? (
< div className = 'relative' >
{ /* 卡片容器 */ }
< div className = 'mt-8 grid grid-cols-1 justify-items-center gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8' >
{ monthlyPlans . map ( ( plan , index ) = > (
< PlanCard key = { plan . id } plan = { plan } / >
) ) }
< / div >
< / div >
) : (
< div className = 'py-12 text-center' >
< p className = 'text-lg text-gray-500' > 暂 无 月 付 套 餐 < / p >
< / 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) ` } }
>
< div >
{ tabValue === 'year' && (
< PlanList
plans = { yearlyPlans }
isLoading = { isLoading }
tabValue = { tabValue }
error = { error }
onRetry = { refetch }
emptyMessage = '暂无年付套餐'
onSubscribe = { handleSubscribe }
firstPlanCardRef = { firstPlanCardRef } // 传递ref给第一项
/ >
) }
{ tabValue === 'month' && (
< PlanList
plans = { monthlyPlans }
tabValue = { tabValue }
isLoading = { isLoading }
error = { error }
onRetry = { refetch }
emptyMessage = '暂无月付套餐'
onSubscribe = { handleSubscribe }
firstPlanCardRef = { firstPlanCardRef } // 传递ref给第一项
/ >
) }
< / div >
< / ScrollArea >
< / div >
< / DialogContent >