feat: 修改代码结构

This commit is contained in:
speakeloudest 2025-08-14 09:00:22 -07:00
parent 4606412e68
commit f9de5fcb4f
21 changed files with 559 additions and 613 deletions

1
.gitignore vendored
View File

@ -25,6 +25,7 @@ coverage
# Build Outputs # Build Outputs
.next/ .next/
out/ out/
out-dev/
build build
dist dist

View File

@ -31,7 +31,7 @@ export default function Page() {
}} }}
renderItem={(item) => { renderItem={(item) => {
return ( return (
<Card className='rounded-[32px] border border-[#D9D9D9] pb-5 pl-16 pr-3 pt-3 shadow-[0px_0px_4.5px_0px_rgba(0,0,0,0.25)]'> <Card className='rounded-[32px] border border-[#D9D9D9] pb-5 pl-3 pr-3 pt-3 shadow-[0px_0px_4.5px_0px_rgba(0,0,0,0.25)] sm:pl-16'>
<div className={'flex justify-between'}> <div className={'flex justify-between'}>
<div> <div>
<div className={'text-[#666]'}>{t('orderNo')}</div> <div className={'text-[#666]'}>{t('orderNo')}</div>

View File

@ -83,8 +83,8 @@ export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>)
</SidebarMenu> </SidebarMenu>
</SidebarContent> </SidebarContent>
<SidebarFooter className={'mb-6 mt-4 px-0 pb-0'}> <SidebarFooter className={'mb-6 mt-4 gap-0 px-0 pb-0'}>
<div> <div className={'mb-4 ml-[6px]'}>
<LanguageSwitch /> <LanguageSwitch />
</div> </div>
<UserNav from='profile' /> <UserNav from='profile' />

View File

@ -2,21 +2,23 @@
import { querySubscribeList } from '@/services/user/subscribe'; import { querySubscribeList } from '@/services/user/subscribe';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Tabs, TabsList, TabsTrigger } from '@workspace/airo-ui/components/tabs';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useMemo, useRef, useState } from 'react'; import { useState } from 'react';
import { LoginDialogProvider } from '@/app/auth/LoginDialogContext'; import { LoginDialogProvider } from '@/app/auth/LoginDialogContext';
import { TabContent } from '@/components/main/OfferDialog/TabContent'; import TabContent from '@/components/SubscribePlan/PlanContent/index';
import { ProcessedPlanData } from '@/components/main/OfferDialog/types'; import PlanTabs from '@/components/SubscribePlan/PlanTabs/PlanTabs';
import Purchase from '@/components/subscribe/purchase';
import { unitConversion } from '@workspace/airo-ui/utils';
export default function Page() { export default function Page() {
const t = useTranslations('subscribe'); const t = useTranslations('subscribe');
const [tabValue, setTabValue] = useState<'year' | 'month'>('year'); const [tabValue, setTabValue] = useState<'year' | 'month'>('year');
const { data, isLoading, error, refetch } = useQuery({ const {
data = [],
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['querySubscribeList'], queryKey: ['querySubscribeList'],
queryFn: async () => { queryFn: async () => {
const { data } = await querySubscribeList(); const { data } = await querySubscribeList();
@ -24,50 +26,6 @@ export default function Page() {
}, },
}); });
// 处理套餐数据的工具函数
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],
);
// 处理订阅点击
const handleSubscribe = (plan: ProcessedPlanData) => {
console.log('用户选择了套餐:', plan);
// 这里可以添加订阅逻辑,比如跳转到支付页面或显示确认对话框
PurchaseRef.current.show(plan, tabValue);
};
const PurchaseRef = useRef<{
show: (subscribe: API.Subscribe, tabValue: string) => void;
hide: () => void;
}>(null);
return ( return (
<> <>
<LoginDialogProvider> <LoginDialogProvider>
@ -86,52 +44,17 @@ export default function Page() {
{t('description')} {t('description')}
</div> </div>
<div> <div>
<Tabs <div className={'m-auto flex w-full sm:w-[362px]'}>
defaultValue='year' <PlanTabs tabValue={tabValue} setTabValue={setTabValue} discount={20} />
className={'mt-8 text-center md:mt-16'} </div>
value={tabValue}
onValueChange={(value) => setTabValue(value as 'year' | 'month')}
>
<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>
<TabContent <TabContent
tabValue={tabValue} tabValue={tabValue}
yearlyPlans={yearlyPlans} subscribeData={data}
monthlyPlans={monthlyPlans}
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
onRetry={refetch} onRetry={refetch}
onSubscribe={handleSubscribe}
/> />
</div> </div>
<Purchase ref={PurchaseRef} />
</LoginDialogProvider> </LoginDialogProvider>
</> </>
); );

View File

@ -11,10 +11,24 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@workspace/airo-ui/components/alert-dialog'; } from '@workspace/airo-ui/components/alert-dialog';
import CloseSvg from '@workspace/airo-ui/components/close.svg'; import CloseSvg from '@workspace/airo-ui/components/close.svg';
import { useImperativeHandle, useState } from 'react'; import { Ref, useImperativeHandle, useState } from 'react';
interface AlertDialogComponentProps {
ref: Ref<unknown>;
title: string;
description: string;
cancelText?: string;
confirmText?: string;
onConfirm?: () => void;
}
// 定义ref可以访问的方法类型
export interface AlertDialogRef {
show: () => void;
}
// Defining the AlertDialogComponent with internal state and onShow prop // Defining the AlertDialogComponent with internal state and onShow prop
const AlertDialogComponent = ({ const AlertDialogComponent: React.FC<AlertDialogComponentProps> = ({
ref, ref,
title, title,
description, description,
@ -28,9 +42,13 @@ const AlertDialogComponent = ({
setOpen(true); setOpen(true);
} }
useImperativeHandle(ref, () => ({ useImperativeHandle(
ref,
(): AlertDialogRef => ({
show, show,
})); }),
[],
);
return ( return (
<AlertDialog open={open} onOpenChange={setOpen}> <AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogPortal> <AlertDialogPortal>

View File

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

View File

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

View File

@ -19,10 +19,10 @@ export function Display<T extends number | undefined | null>({
}: DisplayProps<T>): string { }: DisplayProps<T>): string {
const t = useTranslations('common'); const t = useTranslations('common');
const { common } = useGlobalStore(); const { common } = useGlobalStore();
// const { currency } = common; const { currency } = common;
if (type === 'currency') { if (type === 'currency') {
const formattedValue = `$ ${unitConversion('centsToDollars', value as number)?.toFixed(2) ?? '0.00'}`; const formattedValue = `${currency?.currency_symbol ?? ''}${unitConversion('centsToDollars', value as number)?.toFixed(2) ?? '0.00'}`;
return formattedValue; return formattedValue;
} }

View File

@ -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>
);
};

View File

@ -1,258 +1,12 @@
import { getSubscription } from '@/services/user/portal'; import { getSubscription } from '@/services/user/portal';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogTitle } from '@workspace/airo-ui/components/dialog'; import { Dialog, DialogContent, DialogTitle } from '@workspace/airo-ui/components/dialog';
import { Tabs, TabsList, TabsTrigger } from '@workspace/airo-ui/components/tabs'; import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { unitConversion } from '@workspace/airo-ui/utils';
import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { TabContent } from './TabContent'; import TabContent from '@/components/SubscribePlan/PlanContent/index';
import { ProcessedPlanData } from './types'; import PlanTabs from '@/components/SubscribePlan/PlanTabs/PlanTabs';
// 加载状态组件
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 { useTranslations } from 'next-intl'; 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 { export interface OfferDialogRef {
show: () => void; show: () => void;
hide: () => void; hide: () => void;
@ -261,7 +15,7 @@ export interface OfferDialogRef {
const OfferDialog = forwardRef<OfferDialogRef>((props, ref) => { const OfferDialog = forwardRef<OfferDialogRef>((props, ref) => {
const t = useTranslations('components.offerDialog'); const t = useTranslations('components.offerDialog');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [tabValue, setTabValue] = useState('year'); const [tabValue, setTabValue] = useState<'year' | 'month'>('year');
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
// 使用 useQuery 来管理请求 // 使用 useQuery 来管理请求
const { const {
@ -295,44 +49,6 @@ const OfferDialog = forwardRef<OfferDialogRef>((props, ref) => {
}, },
hide: () => setOpen(false), 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 ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@ -350,52 +66,17 @@ const OfferDialog = forwardRef<OfferDialogRef>((props, ref) => {
</div> </div>
</div> </div>
<div> <div>
<Tabs <div className={'m-auto flex w-full sm:w-[362px]'}>
defaultValue='year' <PlanTabs tabValue={tabValue} setTabValue={setTabValue} discount={20} />
className={'mt-8 text-center md:mt-16'} </div>
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>
<TabContent <TabContent
tabValue={tabValue} tabValue={tabValue}
yearlyPlans={yearlyPlans} subscribeData={data}
monthlyPlans={monthlyPlans}
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
onRetry={refetch} onRetry={refetch}
onSubscribe={handleSubscribe}
/> />
</div> </div>
<Purchase ref={PurchaseRef} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

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

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import PaymentMethods from '@/components/subscribe/payment-methods'; import PaymentMethods from '@/components/subscribe/payment-methods';
import PlanTabs, { TabValueType } from '@/components/SubscribePlan/PlanTabs/PlanTabs';
import useGlobalStore from '@/config/use-global'; import useGlobalStore from '@/config/use-global';
import { preCreateOrder, purchase } from '@/services/user/order'; import { preCreateOrder, purchase } from '@/services/user/order';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@ -12,7 +13,6 @@ import {
DialogTitle, DialogTitle,
} from '@workspace/airo-ui/components/dialog'; } from '@workspace/airo-ui/components/dialog';
import { Separator } from '@workspace/airo-ui/components/separator'; import { Separator } from '@workspace/airo-ui/components/separator';
import { Tabs, TabsList, TabsTrigger } from '@workspace/airo-ui/components/tabs';
import { LoaderCircle } from 'lucide-react'; import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@ -32,7 +32,7 @@ interface PurchaseProps {
} }
interface PurchaseDialogRef { interface PurchaseDialogRef {
show: (subscribe: API.Subscribe, tabValue: string) => void; show: (subscribe: API.Subscribe, tabValue: TabValueType) => void;
hide: () => void; hide: () => void;
} }
@ -50,10 +50,10 @@ const Purchase = forwardRef<PurchaseDialogRef, PurchaseProps>((props, ref) => {
const [loading, startTransition] = useTransition(); const [loading, startTransition] = useTransition();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const lastSuccessOrderRef = useRef<any>(null); const lastSuccessOrderRef = useRef<any>(null);
const [tabValue, setTabValue] = useState('year'); const [tabValue, setTabValue] = useState<'year' | 'month'>('year');
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
show: (newSubscribe: API.Subscribe, tabValue: string) => { show: (newSubscribe: API.Subscribe, tabValue: TabValueType) => {
setSubscribe(newSubscribe); setSubscribe(newSubscribe);
setParams((prev) => ({ setParams((prev) => ({
...prev, ...prev,
@ -127,12 +127,9 @@ const Purchase = forwardRef<PurchaseDialogRef, PurchaseProps>((props, ref) => {
<div className='pl-4 text-4xl font-bold text-[#0F2C53] sm:mb-8 sm:pl-0 sm:text-center sm:text-4xl'> <div className='pl-4 text-4xl font-bold text-[#0F2C53] sm:mb-8 sm:pl-0 sm:text-center sm:text-4xl'>
{t('purchaseTitle')} {t('purchaseTitle')}
</div> </div>
<div> <PlanTabs
<Tabs tabValue={tabValue}
defaultValue='year' setTabValue={(val) => {
className='mt-8 text-center sm:mt-6'
value={tabValue}
onValueChange={(val) => {
if (val === 'year') { if (val === 'year') {
handleChange('quantity', 12); handleChange('quantity', 12);
} else if (val === 'month') { } else if (val === 'month') {
@ -140,33 +137,9 @@ const Purchase = forwardRef<PurchaseDialogRef, PurchaseProps>((props, ref) => {
} }
setTabValue(val); setTabValue(val);
}} }}
> discount={20}
<TabsList className='relative mb-8 h-[74px] flex-wrap rounded-full bg-[#EAEAEA] p-2.5'> className={'sm:mt-6'}
{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'>
{t('discount20')}
{/* 小三角箭头 */}
<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>
<div className={'px-4 sm:px-0'}> <div className={'px-4 sm:px-0'}>
<SubscribeDetail <SubscribeDetail
subscribe={{ subscribe={{

View File

@ -28,7 +28,7 @@ export function UserNav({ from = '' }: { from?: string }) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
{from === 'profile' ? ( {from === 'profile' ? (
<div className={'pb-3 pt-4'}> <div className={'pb-3'}>
<div className='flex cursor-pointer items-center gap-2 rounded-full bg-[#EAEAEA] p-[3px] pr-6'> <div className='flex cursor-pointer items-center gap-2 rounded-full bg-[#EAEAEA] p-[3px] pr-6'>
<Avatar className='h-[34px] w-[34px]'> <Avatar className='h-[34px] w-[34px]'>
<AvatarImage <AvatarImage

View File

@ -22,7 +22,7 @@
"loadFailed": "Failed to load, please try again", "loadFailed": "Failed to load, please try again",
"reload": "Reload", "reload": "Reload",
"perYear": "/Month", "perYear": "/Month",
"yearlyDiscount": "Enjoy a 20% discount on annual payment", "yearlyDiscount": "Enjoy a {discount} discount on annual payment",
"subscribe": "Subscribe", "subscribe": "Subscribe",
"availableNodes": "Available Nodes", "availableNodes": "Available Nodes",
"networkStabilityIndex": "Network Stability Index:", "networkStabilityIndex": "Network Stability Index:",

View File

@ -22,7 +22,7 @@
"loadFailed": "加载失败,请重试", "loadFailed": "加载失败,请重试",
"reload": "重新加载", "reload": "重新加载",
"perYear": "/月", "perYear": "/月",
"yearlyDiscount": "年付享受8折优惠", "yearlyDiscount": "年付享受{discount}折优惠",
"subscribe": "订阅", "subscribe": "订阅",
"availableNodes": "可用节点", "availableNodes": "可用节点",
"networkStabilityIndex": "网络稳定指数:", "networkStabilityIndex": "网络稳定指数:",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,15 +1,4 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1583_1546)"> <path d="M144.356 133.814C137.967 133.814 132.684 136.005 128.174 140.497C123.665 145.012 121.499 150.301 121.499 156.697C121.499 163.092 123.687 168.381 128.174 172.896C132.662 177.411 137.967 179.579 144.356 179.579C150.744 179.579 156.027 177.388 160.537 172.896C165.024 168.404 167.213 163.092 167.213 156.697C167.213 150.301 165.024 145.012 160.537 140.497C156.049 136.005 150.744 133.814 144.356 133.814Z" fill="#0F2C53"/>
<path d="M97.2435 256H83.0516V250.119C77.5634 254.04 71.2606 256 64.1289 256C54.982 256 47.1786 252.766 40.7043 246.285C34.2443 239.818 31 231.991 31 222.834C31 213.677 34.23 205.864 40.7043 199.383C47.1786 192.901 54.982 189.668 64.1289 189.668C73.2758 189.668 81.0793 192.901 87.5536 199.383C94.0135 205.85 97.2578 213.677 97.2578 222.834V256H97.2435ZM77.5063 236.24C81.1936 232.549 83.0373 228.071 83.0373 222.834C83.0373 217.597 81.1936 213.119 77.5063 209.427C73.8189 205.736 69.3455 203.89 64.1146 203.89C58.8837 203.89 54.4103 205.736 50.723 209.427C47.0357 213.119 45.192 217.597 45.192 222.834C45.192 228.071 47.0357 232.549 50.723 236.24C54.4103 239.932 58.8837 241.778 64.1146 241.778C69.3455 241.778 73.8189 239.932 77.5063 236.24Z" fill="#0F2C53"/> <path d="M216.198 38.145L106.821 2.42675C81.1119 -5.96062 53.4802 8.09209 45.1022 33.8074L9.42403 143.286C1.04607 169.023 15.083 196.686 40.7695 205.073L196.657 255.995L247.522 99.9326C255.9 74.1951 241.863 46.5323 216.176 38.145H216.198ZM191.109 203.502H167.19V197.682C160.382 201.555 152.711 203.502 144.333 203.502C131.468 203.502 120.349 198.899 111.264 189.781C102.178 180.686 97.5584 169.554 97.5584 156.675C97.5584 143.795 102.156 132.663 111.264 123.568C120.349 114.472 131.468 109.847 144.333 109.847C157.199 109.847 168.318 114.45 177.403 123.568C186.489 132.663 191.109 143.817 191.109 156.675V203.502Z" fill="#0F2C53"/>
<path d="M116.166 175.46V189.668H101.974V175.46H116.166ZM116.166 194.418V256H101.974V194.418H116.166Z" fill="#0F2C53"/>
<path d="M120.897 222.834C120.897 213.677 124.127 205.864 130.601 199.383C137.075 192.901 144.879 189.668 154.026 189.668V203.876C148.795 203.876 144.321 205.721 140.634 209.413C136.947 213.104 135.103 217.583 135.103 222.82V255.986H120.911V222.82L120.897 222.834Z" fill="#0F2C53"/>
<path d="M215.296 199.383C221.756 205.85 225 213.677 225 222.834C225 231.991 221.77 239.803 215.296 246.285C208.821 252.766 201.018 256 191.871 256C182.724 256 174.921 252.766 168.446 246.285C161.986 239.818 158.742 231.991 158.742 222.834C158.742 213.677 161.972 205.864 168.446 199.383C174.921 192.901 182.724 189.668 191.871 189.668C201.018 189.668 208.821 192.901 215.296 199.383ZM191.871 203.89C186.64 203.89 182.167 205.736 178.479 209.427C174.792 213.119 172.948 217.597 172.948 222.834C172.948 228.071 174.792 232.549 178.479 236.24C182.167 239.932 186.64 241.778 191.871 241.778C197.102 241.778 201.575 239.932 205.263 236.24C208.95 232.549 210.794 228.071 210.794 222.834C210.794 217.597 208.95 213.119 205.263 209.427C201.575 205.736 197.102 203.89 191.871 203.89Z" fill="#0F2C53"/>
<path d="M141.42 86.5209C137.29 86.5209 133.874 87.9374 130.958 90.8419C128.043 93.7608 126.642 97.1804 126.642 101.315C126.642 105.45 128.057 108.87 130.958 111.789C133.86 114.708 137.29 116.11 141.42 116.11C145.551 116.11 148.966 114.693 151.882 111.789C154.783 108.884 156.198 105.45 156.198 101.315C156.198 97.1804 154.783 93.7608 151.882 90.8419C148.981 87.9374 145.551 86.5209 141.42 86.5209Z" fill="#0F2C53"/>
<path d="M187.869 24.6671L117.152 1.57387C100.531 -3.84889 82.6657 5.23674 77.249 21.8627L54.1817 92.6447C48.765 109.285 57.8404 127.17 74.4478 132.593L175.235 165.516L208.121 64.6152C213.538 47.9749 204.462 30.0899 187.855 24.6671H187.869ZM171.648 131.577H156.184V127.814C151.782 130.318 146.823 131.577 141.406 131.577C133.088 131.577 125.899 128.601 120.025 122.706C114.151 116.825 111.164 109.628 111.164 101.301C111.164 92.9738 114.137 85.7769 120.025 79.8962C125.899 74.0156 133.088 71.0252 141.406 71.0252C149.724 71.0252 156.913 74.0013 162.787 79.8962C168.661 85.7769 171.648 92.9881 171.648 101.301V131.577Z" fill="#0F2C53"/>
</g>
<defs>
<clipPath id="clip0_1583_1546">
<rect width="194" height="256" fill="white" transform="translate(31)"/>
</clipPath>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -756,6 +756,7 @@ declare namespace API {
quota: number; quota: number;
group_id: number; group_id: number;
server_group: number[]; server_group: number[];
server_count: number;
server: number[]; server: number[];
show: boolean; show: boolean;
sell: boolean; sell: boolean;

View File

@ -41,15 +41,16 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-0 container fixed left-[50%] top-[50%] z-50 h-full w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 overflow-auto border p-6 px-8 py-8 shadow-lg duration-200 sm:h-auto sm:!rounded-[32px] sm:px-12 sm:py-12', 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-0 container fixed left-[50%] top-[50%] z-50 flex h-full w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 px-8 py-8 shadow-lg duration-200 sm:h-auto sm:!rounded-[32px] sm:px-12 sm:py-12',
className, className,
)} )}
{...props} {...props}
> >
{children} <div className={'flex-1 overflow-auto'}>{children}</div>
<DialogPrimitive.Close <DialogPrimitive.Close
className={cn( className={cn(
'ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-6 rounded-sm font-bold text-black opacity-100 transition-opacity hover:opacity-100 focus:outline-none focus:ring-0 focus:ring-offset-0 disabled:pointer-events-none', 'ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-6 z-[100] rounded-sm font-bold text-black opacity-100 transition-opacity hover:opacity-100 focus:outline-none focus:ring-0 focus:ring-offset-0 disabled:pointer-events-none',
)} )}
> >
<div> <div>

View File

@ -26,12 +26,12 @@ interface PaginationProps<TData> {
export function Pagination<TData>({ table, text }: PaginationProps<TData>) { export function Pagination<TData>({ table, text }: PaginationProps<TData>) {
return ( return (
<div className='flex flex-wrap items-center justify-center gap-2'> <div className='grid grid-cols-1 items-center justify-center gap-2 sm:flex sm:gap-[34px]'>
<div className='whitespace-nowrap text-center text-[15px] text-[#666]'> <div className='whitespace-nowrap text-center text-[15px] text-[#666]'>
{text?.textPageOf?.(table.getState().pagination.pageIndex + 1, table.getPageCount()) || {text?.textPageOf?.(table.getState().pagination.pageIndex + 1, table.getPageCount()) ||
`Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`} `Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
</div> </div>
<div className='ml-[34px] flex items-center justify-center gap-3'> <div className='flex items-center justify-center gap-3'>
{/*<div className='flex items-center space-x-2'> {/*<div className='flex items-center space-x-2'>
<p className='font-medium'>{text?.textRowsPerPage || 'Rows per page'}</p> <p className='font-medium'>{text?.textRowsPerPage || 'Rows per page'}</p>
<Select <Select

69
scripts/publish-dev.sh Executable file
View File

@ -0,0 +1,69 @@
#!/bin/bash
# Set up directories
OUT_DIR="out-dev"
# Clean up any existing build artifacts
rm -rf $OUT_DIR
mkdir -p $OUT_DIR
# Declare an array of projects to build
PROJECTS=(
"ppanel-admin-web:apps/admin:3004"
"ppanel-user-web:apps/user:3003"
)
# Step 1: Install dependencies
#bun install || {
# echo "Dependency installation failed"
# exit 1
#}
# Step 2: Build each project using Turbo
for ITEM in "${PROJECTS[@]}"; do
IFS=":" read -r PROJECT PROJECT_PATH DEFAULT_PORT <<< "$ITEM"
echo "Building project: $PROJECT (Path: $PROJECT_PATH)"
bun run build --filter=$PROJECT || {
echo "Build failed for $PROJECT"
exit 1
}
# Copy build output and static resources to the build directory
PROJECT_BUILD_DIR=$OUT_DIR/${PROJECT}-dev
cp -r $PROJECT_PATH/.next/standalone/. $PROJECT_BUILD_DIR/
cp -r $PROJECT_PATH/.next/static $PROJECT_BUILD_DIR/$PROJECT_PATH/.next/
cp -r $PROJECT_PATH/public $PROJECT_BUILD_DIR/$PROJECT_PATH/
cp -r $PROJECT_PATH/.env.template $PROJECT_BUILD_DIR/$PROJECT_PATH/.env.template
cp -r $PROJECT_PATH/.env $PROJECT_BUILD_DIR/$PROJECT_PATH/.env
# Generate ecosystem.config.js for the project
ECOSYSTEM_CONFIG="$PROJECT_BUILD_DIR/ecosystem.config.js"
cat > $ECOSYSTEM_CONFIG << EOL
module.exports = {
apps: [
{
name: "${PROJECT}-dev",
script: "$PROJECT_PATH/server.js",
interpreter: "bun",
watch: true,
instances: "max",
exec_mode: "cluster",
env: {
PORT: $DEFAULT_PORT
}
}
]
};
EOL
echo "PM2 configuration created: $ECOSYSTEM_CONFIG"
# Create a tar.gz archive for each project
ARCHIVE_NAME="$OUT_DIR/${PROJECT}-dev.tar.gz"
tar -czvf $ARCHIVE_NAME -C $OUT_DIR ${PROJECT}-dev || {
echo "Archiving failed for $PROJECT"
exit 1
}
echo "Archive created: $ARCHIVE_NAME"
done
# Final output
echo "All projects have been built and archived successfully."