feat: 修改代码结构
This commit is contained in:
@@ -1,56 +0,0 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import React from 'react';
|
||||
import { PlanList } from './index';
|
||||
import { ProcessedPlanData } from './types';
|
||||
|
||||
interface TabContentProps {
|
||||
tabValue: string;
|
||||
yearlyPlans: ProcessedPlanData[];
|
||||
monthlyPlans: ProcessedPlanData[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
onRetry: () => void;
|
||||
onSubscribe: (plan: ProcessedPlanData) => void;
|
||||
firstPlanCardRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const TabContent: React.FC<TabContentProps> = ({
|
||||
tabValue,
|
||||
yearlyPlans,
|
||||
monthlyPlans,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
onSubscribe,
|
||||
firstPlanCardRef,
|
||||
}) => {
|
||||
const t = useTranslations('components.offerDialog');
|
||||
return (
|
||||
<div>
|
||||
{tabValue === 'year' && (
|
||||
<PlanList
|
||||
plans={yearlyPlans}
|
||||
isLoading={isLoading}
|
||||
tabValue={tabValue}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
emptyMessage={t('noYearlyPlan')}
|
||||
onSubscribe={onSubscribe}
|
||||
firstPlanCardRef={firstPlanCardRef}
|
||||
/>
|
||||
)}
|
||||
{tabValue === 'month' && (
|
||||
<PlanList
|
||||
plans={monthlyPlans}
|
||||
tabValue={tabValue}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
emptyMessage={t('noMonthlyPlan')}
|
||||
onSubscribe={onSubscribe}
|
||||
firstPlanCardRef={firstPlanCardRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,258 +1,12 @@
|
||||
import { getSubscription } from '@/services/user/portal';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@workspace/airo-ui/components/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@workspace/airo-ui/components/tabs';
|
||||
import { unitConversion } from '@workspace/airo-ui/utils';
|
||||
import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
|
||||
|
||||
import { TabContent } from './TabContent';
|
||||
import { ProcessedPlanData } from './types';
|
||||
|
||||
// 加载状态组件
|
||||
const LoadingState = () => {
|
||||
const t = useTranslations('components.offerDialog');
|
||||
return (
|
||||
<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'>{t('loading')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 错误状态组件
|
||||
const ErrorState = ({ onRetry }: { onRetry: () => void }) => {
|
||||
const t = useTranslations('components.offerDialog');
|
||||
return (
|
||||
<div className='py-12 text-center'>
|
||||
<p className='text-lg text-red-500'>{t('loadFailed')}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className='mt-4 rounded-lg bg-[#0F2C53] px-6 py-2 text-white transition-colors hover:bg-[#0A2C47]'
|
||||
>
|
||||
{t('reload')}
|
||||
</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 }) => {
|
||||
const t = useTranslations('components.offerDialog');
|
||||
return (
|
||||
<div className='mb-2 sm:mb-4'>
|
||||
<div className='mb-1 flex items-baseline gap-2'>
|
||||
{plan.origin_price && (
|
||||
<span className='text-2xl font-bold leading-[1.125em] text-[#666666] line-through'>
|
||||
${plan.origin_price}
|
||||
</span>
|
||||
)}
|
||||
<span className='text-2xl font-bold leading-[1.125em] text-[#091B33]'>
|
||||
${plan.discount_price}
|
||||
</span>
|
||||
<span className='text-sm font-normal leading-[1.8em] text-[#4D4D4D] sm:text-[15px]'>
|
||||
{t('perYear')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={'h-[15px]'}>
|
||||
{plan.origin_price && (
|
||||
<p className='text-left text-[10px] font-normal text-black'>{t('yearlyDiscount')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
import { useLoginDialog } from '@/app/auth/LoginDialogContext';
|
||||
import { Display } from '@/components/display';
|
||||
import Modal from '@/components/Modal';
|
||||
import Purchase from '@/components/subscribe/purchase';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { queryUserSubscribe } from '@/services/user/user';
|
||||
import TabContent from '@/components/SubscribePlan/PlanContent/index';
|
||||
import PlanTabs from '@/components/SubscribePlan/PlanTabs/PlanTabs';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
// 星级评分组件
|
||||
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 t = useTranslations('subscribe.detail');
|
||||
const tOffer = useTranslations('components.offerDialog');
|
||||
const features = [{ label: tOffer('availableNodes'), value: plan?.server_count }];
|
||||
|
||||
return (
|
||||
<div className='mt-6 space-y-0 sm:mt-6'>
|
||||
<ul className='list-disc space-y-1 pl-5'>
|
||||
<li className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]'>
|
||||
<div className={'flex items-start justify-between'}>
|
||||
<span className=''>{t('availableTraffic')}</span>
|
||||
<span>
|
||||
<Display type='traffic' value={plan?.traffic} unlimited />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]'>
|
||||
<div className={'flex items-start justify-between'}>
|
||||
<span className=''>{t('connectionSpeed')}</span>
|
||||
<span>
|
||||
<Display type='trafficSpeed' value={plan?.speed_limit} unlimited />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]'>
|
||||
<div className={'flex items-start justify-between'}>
|
||||
<span className=''>{t('connectedDevices')}</span>
|
||||
<span>
|
||||
<Display value={plan?.device_limit} type='number' unlimited />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{features.map((feature) => (
|
||||
<li
|
||||
key={feature.label}
|
||||
className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]'
|
||||
>
|
||||
<div className={'flex items-start justify-between'}>
|
||||
<span className=''>{feature.label}:</span>
|
||||
<span>{feature.value}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<li className='py-1'>
|
||||
<div className={'flex items-start justify-between'}>
|
||||
<span className='text-xs font-light leading-[1.8461538461538463em] text-black sm:text-[13px]'>
|
||||
{tOffer('networkStabilityIndex')}
|
||||
</span>
|
||||
<StarRating rating={5} />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 套餐卡片组件
|
||||
const PlanCard = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
plan: ProcessedPlanData;
|
||||
tabValue: string;
|
||||
onSubscribe?: (plan: ProcessedPlanData) => void;
|
||||
isFirstCard?: boolean;
|
||||
}
|
||||
>(({ plan, onSubscribe }, ref) => {
|
||||
const { user } = useGlobalStore();
|
||||
const { openLoginDialog } = useLoginDialog();
|
||||
const t = useTranslations('components.offerDialog');
|
||||
const ModalRef = useRef(null);
|
||||
async function handleSubscribe() {
|
||||
if (!user) {
|
||||
// 强制登陆
|
||||
openLoginDialog(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 有生效套餐进行弹窗提示
|
||||
const { data } = await queryUserSubscribe();
|
||||
if (data?.data?.list?.[0]?.status === 1) {
|
||||
ModalRef.current.show();
|
||||
return;
|
||||
}
|
||||
|
||||
onSubscribe?.(plan);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className='relative w-full min-w-[300px] cursor-pointer rounded-[20px] border border-[#D9D9D9] bg-white p-8 shadow-[0_0_52.6px_1px_rgba(15,44,83,0.05)] transition-all duration-300 sm:w-[345px] sm:p-10'
|
||||
>
|
||||
{/* 套餐名称 */}
|
||||
<h3 className='mb-4 text-left text-xl font-normal sm:mb-6 sm:text-base'>{plan.name}</h3>
|
||||
|
||||
{/* 价格区域 */}
|
||||
<PriceDisplay plan={plan} />
|
||||
|
||||
{/* 订阅按钮 */}
|
||||
<button
|
||||
onClick={handleSubscribe}
|
||||
className='h-10 w-full rounded-full bg-[#0F2C53] text-sm font-medium text-white shadow-md transition-all duration-300 hover:bg-[#225BA9] sm:h-10 sm:text-sm md:h-[40px] md:text-[14px]'
|
||||
>
|
||||
{t('subscribe')}
|
||||
</button>
|
||||
|
||||
{/* 功能列表 */}
|
||||
<FeatureList plan={plan} />
|
||||
|
||||
<Modal
|
||||
ref={ModalRef}
|
||||
title={'【重要提示】'}
|
||||
description={
|
||||
'您已有正在生效的套餐,如继续购买新的套餐,原套餐将自动失效。账户套餐将自动转为最新套餐。未使用部分不支持退款或叠加。'
|
||||
}
|
||||
confirmText={'同意'}
|
||||
cancelText={'取消'}
|
||||
onConfirm={() => onSubscribe?.(plan)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PlanCard.displayName = 'PlanCard';
|
||||
|
||||
// 套餐列表组件
|
||||
export const PlanList = ({
|
||||
plans,
|
||||
tabValue,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
emptyMessage,
|
||||
onSubscribe,
|
||||
}: {
|
||||
plans: ProcessedPlanData[];
|
||||
tabValue: string;
|
||||
isLoading: boolean;
|
||||
error: any;
|
||||
onRetry: () => void;
|
||||
emptyMessage: string;
|
||||
onSubscribe?: (plan: ProcessedPlanData) => void;
|
||||
}) => {
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (error) return <ErrorState onRetry={onRetry} />;
|
||||
if (plans.length === 0) return <EmptyState message={emptyMessage} />;
|
||||
|
||||
return (
|
||||
<div className={'flex w-full justify-center px-[6px] sm:px-0'}>
|
||||
<div className='flex w-full flex-col flex-wrap gap-6 sm:w-auto md:flex-row'>
|
||||
{plans.map((plan, index) => (
|
||||
<PlanCard
|
||||
key={`${plan.id}-${plan.name}`}
|
||||
tabValue={tabValue}
|
||||
plan={plan}
|
||||
onSubscribe={onSubscribe}
|
||||
isFirstCard={index === 0} // 标识第一项
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface OfferDialogRef {
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
@@ -261,7 +15,7 @@ export interface OfferDialogRef {
|
||||
const OfferDialog = forwardRef<OfferDialogRef>((props, ref) => {
|
||||
const t = useTranslations('components.offerDialog');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tabValue, setTabValue] = useState('year');
|
||||
const [tabValue, setTabValue] = useState<'year' | 'month'>('year');
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
// 使用 useQuery 来管理请求
|
||||
const {
|
||||
@@ -295,44 +49,6 @@ const OfferDialog = forwardRef<OfferDialogRef>((props, ref) => {
|
||||
},
|
||||
hide: () => setOpen(false),
|
||||
}));
|
||||
const PurchaseRef = useRef<{ show: (subscribe: API.Subscribe) => void; hide: () => void }>(null);
|
||||
// 处理订阅点击
|
||||
const handleSubscribe = (plan: ProcessedPlanData) => {
|
||||
// 这里可以添加订阅逻辑,比如跳转到支付页面或显示确认对话框
|
||||
PurchaseRef.current.show(plan, tabValue);
|
||||
};
|
||||
|
||||
// 处理套餐数据的工具函数
|
||||
const processPlanData = (item: API.Subscribe, isYearly: boolean): ProcessedPlanData => {
|
||||
if (isYearly) {
|
||||
const discountItem = item.discount?.find((v) => v.quantity === 12);
|
||||
return {
|
||||
...item,
|
||||
origin_price: unitConversion('centsToDollars', item.unit_price).toString(), // 原价
|
||||
discount_price: unitConversion(
|
||||
'centsToDollars',
|
||||
item.unit_price * ((discountItem?.discount || 100) / 100),
|
||||
).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}>
|
||||
@@ -350,52 +66,17 @@ const OfferDialog = forwardRef<OfferDialogRef>((props, ref) => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Tabs
|
||||
defaultValue='year'
|
||||
className={'mt-8 text-center md:mt-16'}
|
||||
value={tabValue}
|
||||
onValueChange={setTabValue}
|
||||
>
|
||||
<TabsList className='relative mb-8 h-[74px] flex-wrap rounded-full bg-[#EAEAEA] p-2.5 md:mb-16'>
|
||||
{tabValue === 'year' ? (
|
||||
<span className='absolute -top-8 left-16 z-10 rounded-md bg-[#E22C2E] px-2 py-0.5 text-[10px] font-bold leading-none text-white shadow sm:text-xs'>
|
||||
-20%
|
||||
{/* 小三角箭头 */}
|
||||
<span
|
||||
className='absolute right-0 top-[80%] h-10 w-2 bg-[#E22C2E]'
|
||||
style={{ clipPath: 'polygon(100% 0, 100% 100%, 0 0)' }}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
<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'
|
||||
>
|
||||
{t('yearlyPlan')}
|
||||
</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'
|
||||
>
|
||||
{t('monthlyPlan')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className={'m-auto flex w-full sm:w-[362px]'}>
|
||||
<PlanTabs tabValue={tabValue} setTabValue={setTabValue} discount={20} />
|
||||
</div>
|
||||
<TabContent
|
||||
tabValue={tabValue}
|
||||
yearlyPlans={yearlyPlans}
|
||||
monthlyPlans={monthlyPlans}
|
||||
subscribeData={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
onSubscribe={handleSubscribe}
|
||||
/>
|
||||
</div>
|
||||
<Purchase ref={PurchaseRef} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
export interface SubscriptionData {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
originalPrice: number;
|
||||
duration: string;
|
||||
unit_price: number;
|
||||
discount: Array<{
|
||||
quantity: number;
|
||||
discount: number;
|
||||
}>;
|
||||
features: {
|
||||
traffic: string;
|
||||
duration: string;
|
||||
onlineIPs: string;
|
||||
connections: string;
|
||||
bandwidth: string;
|
||||
nodes: string;
|
||||
stability: number; // 星级 1-5
|
||||
};
|
||||
}
|
||||
|
||||
// 以 API.Subscribe 为准的类型定义
|
||||
export interface ProcessedPlanData {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
unit_price: number;
|
||||
unit_time: string;
|
||||
discount: Array<{
|
||||
quantity: number;
|
||||
discount: number;
|
||||
}>;
|
||||
replacement: number;
|
||||
inventory: number;
|
||||
traffic: number;
|
||||
speed_limit: number;
|
||||
device_limit: number;
|
||||
quota: number;
|
||||
group_id: number;
|
||||
server_group: number[];
|
||||
server: number[];
|
||||
show: boolean;
|
||||
sell: boolean;
|
||||
sort: number;
|
||||
deduction_ratio: number;
|
||||
allow_deduction: boolean;
|
||||
reset_cycle: number;
|
||||
renewal_reset: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
// 处理后的价格字段
|
||||
origin_price: string;
|
||||
discount_price: string;
|
||||
// 添加features属性以兼容现有代码
|
||||
features?: {
|
||||
traffic: string;
|
||||
duration: string;
|
||||
onlineIPs: string;
|
||||
connections: string;
|
||||
bandwidth: string;
|
||||
nodes: string;
|
||||
stability: number;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user