feat: 完成代码编写

This commit is contained in:
2025-08-08 03:03:59 -07:00
parent 19fe24660e
commit aead2ff68f
26 changed files with 793 additions and 328 deletions
@@ -0,0 +1,158 @@
'use client';
import { queryAnnouncement } from '@/services/user/announcement';
import { useQuery } from '@tanstack/react-query';
import {
ColumnFiltersState,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
PaginationState,
useReactTable,
} from '@tanstack/react-table';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@workspace/airo-ui/components/dialog';
import { Pagination } from '@workspace/airo-ui/custom-components/pro-table/pagination';
import { useImperativeHandle, useRef, useState } from 'react';
import { Popup, PopupData, PopupRef } from './Popup';
export interface AnnouncementItem {
id: number;
title: string;
content: string;
pinned?: boolean;
}
export interface DialogRef {
open: () => void;
close: () => void;
}
interface DialogProps {
ref?: React.Ref<DialogRef>;
}
export const AnnouncementDialog = ({ ref }: DialogProps) => {
const [open, setOpen] = useState(false);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const popupRef = useRef<PopupRef>(null);
const { data: announcementData, isLoading } = useQuery({
queryKey: ['queryAnnouncement', pagination.pageIndex + 1, pagination.pageSize],
queryFn: async () => {
const { data } = await queryAnnouncement({
page: pagination.pageIndex + 1,
size: pagination.pageSize,
pinned: true,
popup: true,
});
return {
list: data.data?.announcements || [],
total: data.data?.total || 0,
};
},
enabled: open, // 只在弹窗打开时查询数据
});
const table = useReactTable({
data: announcementData?.list || [],
columns: [],
onPaginationChange: setPagination,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: {
columnFilters,
pagination,
},
manualPagination: true,
manualFiltering: true,
rowCount: announcementData?.total || 0,
});
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
},
close: () => {
setOpen(false);
},
}));
const handleOpenPopup = (item: AnnouncementItem) => {
const popupData: PopupData = {
title: item.title,
content: item.content,
};
popupRef.current?.open(popupData);
};
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className='flex flex-col sm:grid sm:max-w-[600px]'>
<DialogHeader>
<DialogTitle className={'text-left text-2xl sm:text-4xl'}></DialogTitle>
</DialogHeader>
<div className='space-y-4'>
{isLoading ? (
<div className='py-8 text-center'>...</div>
) : (
<>
{announcementData?.list?.map((item: AnnouncementItem) => {
return (
<div
key={item.id}
className='flex items-center rounded-[20px] bg-[#B5C9E2] px-4 py-2 sm:p-4'
>
<p className='line-clamp-2 flex-1 text-[10px] text-[#225BA9] sm:text-sm'>
{item.pinned && '【置顶公告】'}{' '}
<span className={`${item.pinned ? 'text-white' : 'text-[#4D4D4D]'}`}>
{item.content}
</span>
</p>
<div className='ml-2 w-[65px] text-right'>
<span
className='cursor-pointer text-xs text-[#225BA9] sm:text-sm'
onClick={() => handleOpenPopup(item)}
>
</span>
</div>
</div>
);
})}
{/* 使用 table 的分页器组件 */}
{announcementData && announcementData.list && announcementData.list.length > 0 && (
<div className='mt-6'>
<Pagination
table={table}
text={{
textRowsPerPage: '每页显示',
textPageOf: (pageIndex, pageCount) =>
`${pageIndex} 页,共 ${pageCount}`,
}}
/>
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
<Popup ref={popupRef} />
</>
);
};
@@ -0,0 +1,53 @@
'use client';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@workspace/airo-ui/components/dialog';
import { Markdown } from '@workspace/ui/custom-components/markdown';
import { useImperativeHandle, useState } from 'react';
export interface PopupData {
title: string;
content: string;
}
export interface PopupRef {
open: (data: PopupData) => void;
close: () => void;
}
interface PopupProps {
ref?: React.Ref<PopupRef>;
}
export const Popup = ({ ref }: PopupProps) => {
const [open, setOpen] = useState(false);
const [data, setData] = useState<PopupData>({ title: '', content: '' });
useImperativeHandle(ref, () => ({
open: (newData: PopupData) => {
setData(newData);
setOpen(true);
},
close: () => {
setOpen(false);
},
}));
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className='max-h-[350px] min-h-[220px] max-w-[80%] rounded-[25px] sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle className={'sr-only'}>{data.title}</DialogTitle>
</DialogHeader>
<div className={'pt-[30%]'}>
<div className={'mb-4 text-xl'}>{data.title}</div>
<Markdown>{data.content}</Markdown>
</div>
</DialogContent>
</Dialog>
);
};
@@ -18,10 +18,20 @@ import {
} from '@workspace/ui/components/select';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
AnnouncementDialog,
DialogRef,
} from '@/app/(main)/(content)/(user)/dashboard/components/Announcement/Dialog';
import {
Popup,
PopupRef,
} from '@/app/(main)/(content)/(user)/dashboard/components/Announcement/Popup';
import { Empty } from '@/components/empty';
import { queryAnnouncement } from '@/services/user/announcement';
import { Popover, PopoverContent, PopoverTrigger } from '@workspace/airo-ui/components/popover';
import {
AlertDialog,
AlertDialogAction,
@@ -35,6 +45,7 @@ import {
} from '@workspace/ui/components/alert-dialog';
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { differenceInDays, formatDate } from '@workspace/ui/utils';
import { QRCodeCanvas } from 'qrcode.react';
const platforms: (keyof API.ApplicationPlatform)[] = [
'windows',
@@ -81,6 +92,19 @@ export default function Content() {
protocol: ['vmess', 'vless'],
};
const { data: announcementData } = useQuery({
queryKey: ['queryAnnouncement'],
queryFn: async () => {
const { data } = await queryAnnouncement({
page: 1,
size: 4,
pinned: true,
popup: true,
});
return data.data?.announcements || [];
},
});
useEffect(() => {
if (data && userSubscribe?.length > 0 && !userSubscribeProtocol.length) {
const list = getUserSubscribe(userSubscribe[0]?.token, data.protocol);
@@ -97,20 +121,31 @@ export default function Content() {
const { user } = useGlobalStore();
const totalAssets = (user?.balance || 0) + (user?.commission || 0) + (user?.gift_amount || 0);
// 获取当前选中项的显示标签
const getCurrentLabel = () => {
const currentIndex = userSubscribeProtocol.findIndex(
(url) => url === userSubscribeProtocolCurrent,
);
return currentIndex !== -1 ? `${t('subscriptionUrl')}${currentIndex + 1}` : '地址1';
};
const popupRef = useRef<PopupRef>(null);
const dialogRef = useRef<DialogRef>(null);
return (
<>
<div className={'grid grid-cols-1 gap-6 lg:grid-cols-2'}>
<div className={'grid grid-cols-1 gap-[10px] sm:gap-6 lg:grid-cols-2'}>
{/* 账户概况 Card */}
<Card className='rounded-[20px] border border-[#D9D9D9] p-6 shadow-[0px_0px_52.6px_1px_rgba(15,44,83,0.05)]'>
<div className='mb-4'>
<h3 className='text-xl font-medium text-[#666666]'></h3>
<p className='mt-1 text-sm text-[#666666]'>
<div className='mb-1 sm:mb-4'>
<h3 className='text-base font-medium text-[#666666] sm:text-xl'></h3>
<p className='mt-1 text-xs text-[#666666] sm:text-sm'>
{user?.auth_methods?.[0]?.auth_identifier}
</p>
</div>
<div className='mb-6'>
<span className='text-3xl font-medium text-[#091B33]'></span>
<div className='mb-3 sm:mb-6'>
<span className='text-2xl font-medium text-[#091B33] sm:text-3xl'></span>
</div>
<div className='rounded-[20px] bg-[#EAEAEA] px-4 py-[10px]'>
@@ -122,7 +157,7 @@ export default function Content() {
}
/>
</div>
<div className='text-4xl font-medium text-[#225BA9]'>
<div className='text-xl font-medium text-[#225BA9] sm:text-4xl'>
<Display type='currency' value={totalAssets} />
</div>
</div>
@@ -133,7 +168,7 @@ export default function Content() {
<div className='mb-4'>
<h3 className='flex items-center justify-between text-[#666666]'>
<div className={'flex items-center justify-between'}>
<span className={'text-xl font-medium'}></span>
<span className={'text-base font-medium sm:text-xl'}></span>
<span className={'ml-2.5 rounded-full bg-[#A8D4ED] px-2 text-[8px] text-white'}>
</span>
@@ -146,11 +181,11 @@ export default function Content() {
replacement={userSubscribe?.[0]?.subscribe.replacement}
/>
</h3>
<div className='mb-[22px] mt-1 text-sm text-[#666666]'>
<div className='mb-2 text-sm text-[#666666] sm:mb-[22px] sm:mt-1'>
{formatDate(userSubscribe?.[0]?.expire_time, false)}
</div>
<div className='mb-6'>
<span className='text-3xl font-medium text-[#091B33]'>
<div className='mb-3 sm:mb-6'>
<span className='text-2xl font-medium text-[#091B33] sm:text-3xl'>
{userSubscribe?.[0]?.subscribe.name}
</span>
</div>
@@ -158,7 +193,7 @@ export default function Content() {
<div className='mb-4 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-sm'></span>
<span className='text-xs sm:text-sm'></span>
<div className='flex gap-2'>
<div className='h-4 w-4 rounded-full bg-[#225BA9]'></div>
<div className='h-4 w-4 rounded-full bg-[#D9D9D9]'></div>
@@ -168,13 +203,13 @@ export default function Content() {
<div className='h-4 w-4 rounded-full bg-[#D9D9D9]'></div>
</div>
</div>
<span className='text-sm'>
线undefined/{userSubscribe?.[0]?.subscribe.device_limit}
<span className='text-xs sm:text-sm'>
线uu/{userSubscribe?.[0]?.subscribe.device_limit}
</span>
</div>
<div>
<div className='mb-1 flex items-center justify-between'>
<span className='text-sm'>
<span className='text-xs sm:text-sm'>
使/
<Display
type='traffic'
@@ -188,7 +223,7 @@ export default function Content() {
unlimited={!userSubscribe?.[0]?.traffic}
/>
</span>
<span className='text-sm'>
<span className='text-xs sm:text-sm'>
{100 -
(
@@ -209,78 +244,73 @@ export default function Content() {
</div>
</Card>
{/* 网站公告 Card */}
<Card className='relative rounded-[20px] border border-[#EAEAEA] bg-gradient-to-b from-white to-[#EAEAEA] p-6 pb-0'>
<Card className='relative order-4 rounded-[20px] border border-[#EAEAEA] bg-gradient-to-b from-white to-[#EAEAEA] p-6 pb-0 sm:order-none'>
<div
className={'absolute bottom-0 left-0 right-0 h-[60px] bg-white/30 backdrop-blur-[1px]'}
></div>
<div className='mb-4 flex items-center justify-between'>
<h3 className='text-xl font-medium text-[#666666]'></h3>
<Button className='border-0 bg-transparent p-0 text-sm font-normal text-[#225BA9] shadow-none outline-0 hover:bg-transparent'>
</Button>
<div className='mb-3 flex items-center justify-between sm:mb-4'>
<h3 className='text-base font-medium text-[#666666] sm:text-xl'></h3>
{announcementData?.length ? (
<Button
className='border-0 bg-transparent p-0 text-sm font-normal text-[#225BA9] shadow-none outline-0 hover:bg-transparent'
onClick={() => dialogRef.current.open()}
>
</Button>
) : null}
</div>
<div className='space-y-4'>
<div className='space-y-3 sm:space-y-4'>
{/* 置顶公告 */}
<div className='flex items-center rounded-[20px] bg-[#B5C9E2] p-4'>
<p className='mb-2 line-clamp-2 flex-1 text-sm text-[#225BA9]'>
Airo
Port提供IPLC/IEPL专线或BGP隧道中继线...
</p>
<div className='ml-2 w-[65px] text-right'>
<span className='text-sm text-[#225BA9]'></span>
</div>
</div>
{/* 系统通知列表 */}
<div className='space-y-3'>
<div className='flex items-center gap-2 rounded-[20px] border bg-white p-4'>
<p className='mb-2 line-clamp-2 flex-1 text-sm text-[#225BA9]'>
<br />
IDR20250729115302USDT ...
</p>
<div className='text-right'>
<span className='text-sm text-[#225BA9]'></span>
{announcementData?.map((item) => {
return (
<div className='flex items-center rounded-[20px] bg-[#B5C9E2] px-4 py-2 sm:p-4'>
<p className='line-clamp-2 flex-1 text-[10px] text-[#225BA9] sm:text-sm'>
{item.pinned && '【置顶公告】'}{' '}
<span className={`${item.pinned ? 'text-white' : 'text-[#4D4D4D]'}`}>
{item.content}
</span>
</p>
<div className='ml-2 w-[65px] text-right'>
<span
className='cursor-pointer text-xs text-[#225BA9] sm:text-sm'
onClick={() => popupRef.current.open(item)}
>
</span>
</div>
</div>
</div>
<div className='rounded-[20px] border bg-white p-4'>
<p className='mb-2 line-clamp-2 flex-1 text-sm text-[#225BA9]'>
<br />
IDR20250729115302USDT ...
</p>
<div className='text-right'>
<span className='text-sm text-[#225BA9]'></span>
</div>
</div>
</div>
);
})}
</div>
<Popup ref={popupRef} />
<AnnouncementDialog ref={dialogRef} />
</Card>
{/* 我的订阅 Card */}
<Card className='rounded-[20px] border border-[#D9D9D9] p-6 shadow-[0px_0px_52.6px_1px_rgba(15,44,83,0.05)]'>
<div className='mb-4 flex items-center justify-between'>
<h3 className='text-xl font-medium text-[#666666]'></h3>
<div className='flex items-center justify-between sm:mb-4'>
<h3 className='text-base font-medium text-[#666666] sm:text-xl'></h3>
<Link
href={'/document'}
className='border-0 bg-transparent p-0 text-sm font-normal text-[#225BA9] shadow-none outline-0 hover:bg-transparent'
className='border-0 bg-transparent p-0 text-sm font-semibold text-[#225BA9] shadow-none outline-0 hover:bg-transparent sm:font-normal'
>
</Link>
</div>
{userSubscribe?.[0] && data.protocol ? (
<div className='space-y-4'>
<p className='text-sm text-[#666666]'></p>
<div className='space-y-2 sm:space-y-4'>
<p className='text-xs font-light text-[#666666] sm:text-sm sm:font-normal'>
</p>
{/* 统计信息 */}
<div className='rounded-[20px] bg-[#EAEAEA] p-4'>
<div className='grid grid-cols-3 gap-4 text-center'>
<div>
<p className='text-xs text-[rgba(132,132,132,0.7)]'></p>
<p className='text-lg font-medium text-[#0F2C53]'>
<p className='text-[10px] text-[rgba(132,132,132,0.7)] sm:text-xs'></p>
<p className='text-xs font-medium text-[#0F2C53] sm:text-lg'>
<Display
type='traffic'
value={userSubscribe?.[0]?.traffic}
@@ -289,16 +319,20 @@ export default function Content() {
</p>
</div>
<div>
<p className='text-xs text-[rgba(132,132,132,0.7)]'>{t('nextResetDays')}</p>
<p className='text-lg font-medium text-[#0F2C53]'>
<p className='text-[10px] text-[rgba(132,132,132,0.7)] sm:text-xs'>
{t('nextResetDays')}
</p>
<p className='text-xs font-medium text-[#0F2C53] sm:text-lg'>
{userSubscribe?.[0]
? differenceInDays(new Date(userSubscribe?.[0].reset_time), new Date())
: t('noReset')}
</p>
</div>
<div>
<p className='text-xs text-[rgba(132,132,132,0.7)]'>{t('expirationDays')}</p>
<p className='text-lg font-medium text-[#0F2C53]'>
<p className='text-[10px] text-[rgba(132,132,132,0.7)] sm:text-xs'>
{t('expirationDays')}
</p>
<p className='text-xs font-medium text-[#0F2C53] sm:text-lg'>
{userSubscribe?.[0]?.expire_time
? differenceInDays(new Date(userSubscribe?.[0].expire_time), new Date()) ||
t('unknown')
@@ -309,7 +343,7 @@ export default function Content() {
</div>
{/* 订阅链接 */}
<div className='rounded-[26px] bg-[#EAEAEA] p-4'>
<div className='rounded-[26px] bg-[#EAEAEA] p-2 sm:p-4'>
<div className='mb-3 flex flex-wrap justify-between gap-4'>
{data?.protocol && data?.protocol.length > 1 && (
<Tabs
@@ -332,16 +366,20 @@ export default function Content() {
)}
</div>
<div className={'mb-3 flex items-center justify-center gap-1'}>
<div className={'flex items-center gap-2 rounded-[16px] bg-[#BABABA] pl-2'}>
<div
className={
'flex items-center gap-1 rounded-full bg-[#BABABA] pl-1 sm:gap-2 sm:rounded-[16px] sm:pl-2'
}
>
<Select
value={userSubscribeProtocolCurrent}
onValueChange={setUserSubscribeProtocolCurrent}
>
<SelectTrigger className='h-[35px] w-20 flex-shrink-0 rounded-[8px] border-none bg-[#D9D9D9] bg-transparent p-2 text-[13px] text-sm font-medium text-white shadow-none hover:bg-[#848484] focus:ring-0 [&>svg]:hidden'>
<SelectTrigger className='h-auto w-auto flex-shrink-0 rounded-[16px] border-none bg-[#D9D9D9] px-2.5 py-0.5 text-[13px] text-sm font-medium text-white shadow-none hover:bg-[#848484] focus:ring-0 sm:h-[35px] sm:rounded-[8px] sm:p-2 [&>svg]:hidden'>
<SelectValue>
<div className='flex flex-col items-center justify-center'>
<div>{t('subscriptionUrl')}1</div>
<div className='h-0 w-0 border-l-[5px] border-r-[5px] border-t-[5px] border-l-transparent border-r-transparent border-t-white'></div>
<div className='flex flex-col items-center justify-center text-[10px] sm:text-sm'>
<div>{getCurrentLabel()}</div>
<div className='-mt-0.5 h-0 w-0 scale-50 border-l-[5px] border-r-[5px] border-t-[5px] border-l-transparent border-r-transparent border-t-white sm:scale-100'></div>
</div>
</SelectValue>
</SelectTrigger>
@@ -361,19 +399,42 @@ export default function Content() {
</SelectContent>
</Select>
<div className='flex-1 rounded-[16px] bg-white p-3 text-xs text-[#225BA9] shadow-[inset_0px_0px_7.6px_0px_rgba(0,0,0,0.25)]'>
<div className={'line-clamp-2 break-all'}>{userSubscribeProtocolCurrent}</div>
<div className='flex-1 rounded-full bg-white px-3 py-1 text-[10px] leading-tight text-[#225BA9] shadow-[inset_0px_0px_7.6px_0px_rgba(0,0,0,0.25)] sm:rounded-[16px] sm:p-3 sm:text-xs'>
<div className={'line-clamp-2 break-all text-[10px] sm:text-base'}>
{userSubscribeProtocolCurrent}
</div>
</div>
</div>
<div className={'ml-3 h-[40px] w-[40px] flex-shrink-0 rounded-lg bg-black'}></div>
<Popover>
<PopoverTrigger asChild>
<div
className={
'ml-3 h-[40px] w-[40px] flex-shrink-0 cursor-pointer rounded-lg bg-black'
}
></div>
</PopoverTrigger>
<PopoverContent className='w-auto rounded-xl p-4' align='end'>
<div className='flex flex-col items-center gap-2'>
<p className='text-muted-foreground text-center text-xs'></p>
<QRCodeCanvas
value={userSubscribeProtocolCurrent}
size={120}
bgColor='transparent'
fgColor='rgb(34, 91, 169)'
/>
</div>
</PopoverContent>
</Popover>
</div>
<div className='flex justify-between gap-2'>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size='sm'
className={'rounded-full bg-[#E22C2E] px-3 py-1 text-xs text-white'}
className={
'h-fit rounded-full bg-[#E22C2E] px-3 py-1 text-[10px] text-white sm:h-9 sm:text-xs'
}
variant='destructive'
>
{t('resetSubscription')}
@@ -403,7 +464,7 @@ export default function Content() {
</AlertDialogContent>
</AlertDialog>
<Renewal
className='rounded-full bg-[#A8D4ED] px-3 py-1 text-xs text-white'
className='h-fit rounded-full bg-[#A8D4ED] px-3 py-1 text-[10px] text-white sm:h-9 sm:text-xs'
id={userSubscribe?.[0]?.id}
subscribe={userSubscribe?.[0]?.subscribe}
/>