feat: 修改代码结构

This commit is contained in:
2025-08-14 09:00:22 -07:00
parent 4606412e68
commit f9de5fcb4f
21 changed files with 559 additions and 613 deletions
@@ -0,0 +1,351 @@
// 加载状态组件
import { unitConversion } from '@workspace/airo-ui/utils';
import { useMemo, useRef } from 'react';
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>
);
import { useLocale } from 'next-intl';
interface PriceDisplayProps {
plan: PlanProps;
}
interface PlanProps extends API.Subscribe {
origin_price: string;
discount_price: string;
}
// 价格显示组件
const PriceDisplay: React.FC<PriceDisplayProps> = ({ plan }) => {
const t = useTranslations('components.offerDialog');
const locale = useLocale(); // 获取当前语言环境
const { common } = useGlobalStore();
const discountItem = plan.discount.find((v) => v.quantity === 12) ?? { discount: 0 };
const discount =
locale === 'zh-CN' ? discountItem?.discount / 10 : `${100 - discountItem?.discount}%`;
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'>
{common?.currency?.currency_symbol}
{plan.origin_price}
</span>
)}
<span className='text-2xl font-bold leading-[1.125em] text-[#091B33]'>
{common?.currency?.currency_symbol}
{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', { discount })}
</p>
)}
</div>
</div>
);
};
import { useLoginDialog } from '@/app/auth/LoginDialogContext';
import { Display } from '@/components/display';
import Modal, { AlertDialogRef } from '@/components/Modal';
import Purchase from '@/components/subscribe/purchase';
import useGlobalStore from '@/config/use-global';
import { queryUserSubscribe } from '@/services/user/user';
import { useTranslations } from 'next-intl';
import React from 'react';
// 星级评分组件
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: API.Subscribe }) => {
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: React.FC<{
plan: PlanProps;
tabValue: string;
onSubscribe?: (plan: API.Subscribe) => void;
isFirstCard?: boolean;
}> = ({ plan, onSubscribe }) => {
const { user } = useGlobalStore();
const { openLoginDialog } = useLoginDialog();
const t = useTranslations('components.offerDialog');
const ModalRef = useRef<AlertDialogRef>(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 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: PlanProps[];
tabValue: string;
isLoading: boolean;
error: any;
onRetry: () => void;
emptyMessage: string;
onSubscribe?: (plan: API.Subscribe) => 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>
);
};
interface TabContentProps {
tabValue: string;
subscribeData: API.Subscribe[];
isLoading: boolean;
error: Error | null;
onRetry: () => void;
firstPlanCardRef?: React.RefObject<HTMLDivElement | null>;
}
const TabContent: React.FC<TabContentProps> = ({
tabValue,
subscribeData,
isLoading,
error,
onRetry,
}) => {
const t = useTranslations('components.offerDialog');
// 处理套餐数据的工具函数
const processPlanData = (item: API.Subscribe, isYearly: boolean): PlanProps => {
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: PlanProps[] = useMemo(
() => (subscribeData || []).map((item) => processPlanData(item, true)),
[subscribeData],
);
const monthlyPlans: PlanProps[] = useMemo(
() => (subscribeData || []).map((item) => processPlanData(item, false)),
[subscribeData],
);
const PurchaseRef = useRef<{
show: (subscribe: API.Subscribe, tabValue: string) => void;
hide: () => void;
}>(null);
// 处理订阅点击
const handleSubscribe = (plan: API.Subscribe) => {
// 这里可以添加订阅逻辑,比如跳转到支付页面或显示确认对话框
PurchaseRef.current?.show(plan, tabValue);
};
return (
<div>
{tabValue === 'year' && (
<PlanList
plans={yearlyPlans}
isLoading={isLoading}
tabValue={tabValue}
error={error}
onRetry={onRetry}
emptyMessage={t('noYearlyPlan')}
onSubscribe={handleSubscribe}
/>
)}
{tabValue === 'month' && (
<PlanList
plans={monthlyPlans}
tabValue={tabValue}
isLoading={isLoading}
error={error}
onRetry={onRetry}
emptyMessage={t('noMonthlyPlan')}
onSubscribe={handleSubscribe}
/>
)}
<Purchase ref={PurchaseRef} />
</div>
);
};
export default TabContent;
@@ -0,0 +1,60 @@
import { Tabs, TabsList, TabsTrigger } from '@workspace/airo-ui/components/tabs';
import { cn } from '@workspace/airo-ui/lib/utils';
import { useTranslations } from 'next-intl';
import React from 'react';
export type TabValueType = 'year' | 'month';
interface PlanTabProps {
tabValue: TabValueType;
setTabValue: (val: TabValueType) => void;
discount: number;
className?: string;
}
const PlanTabs: React.FC<PlanTabProps> = (props) => {
const t = useTranslations('components.offerDialog');
const { tabValue, className } = props;
const TAB_LIST: { label: string; value: TabValueType }[] = [
{
label: t('yearlyPlan'),
value: 'year',
},
{
label: t('monthlyPlan'),
value: 'month',
},
];
return (
<Tabs
defaultValue='year'
className={cn('mt-8 w-full text-center md:mt-16', className)}
value={tabValue}
onValueChange={(value) => props.setTabValue(value as 'year' | 'month')}
>
<TabsList className='relative mb-8 grid h-[74px] grid-cols-2 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'>
-{props.discount}%{/* 小三角箭头 */}
<span
className='absolute right-0 top-[80%] h-10 w-2 bg-[#E22C2E]'
style={{ clipPath: 'polygon(100% 0, 100% 100%, 0 0)' }}
/>
</span>
) : null}
{TAB_LIST.map((val, key) => (
<TabsTrigger
key={val.value}
className={
'rounded-full py-3.5 text-xl data-[state=active]:bg-[#0F2C53] data-[state=active]:text-white md:px-12'
}
value={val.value}
>
{val.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
);
};
export default PlanTabs;