✨ feat(api): Add an interface to obtain user subscription details, update related type definitions and localized text
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@workspace/ui/components/accordion';
|
||||
import { Badge } from '@workspace/ui/components/badge';
|
||||
import { Progress } from '@workspace/ui/components/progress';
|
||||
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
||||
@@ -11,6 +17,8 @@ import {
|
||||
} from '@workspace/ui/components/tooltip';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { UserSubscribeDetail } from '../user/user-detail';
|
||||
|
||||
export function formatPercentage(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
@@ -18,25 +26,19 @@ export function formatPercentage(value: number): string {
|
||||
|
||||
export function NodeStatusCell({ status }: { status: API.NodeStatus }) {
|
||||
const t = useTranslations('server.node');
|
||||
const [openItem, setOpenItem] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
last_at,
|
||||
online_users,
|
||||
status: serverStatus,
|
||||
} = status || {
|
||||
online_users: [],
|
||||
status: {
|
||||
cpu: 0,
|
||||
mem: 0,
|
||||
disk: 0,
|
||||
updated_at: 0,
|
||||
},
|
||||
last_at: 0,
|
||||
const { online, cpu, mem, disk, updated_at } = status || {
|
||||
online: {},
|
||||
cpu: 0,
|
||||
mem: 0,
|
||||
disk: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
const isOnline = last_at > 0;
|
||||
const isOnline = updated_at > 0;
|
||||
const badgeVariant = isOnline ? 'default' : 'destructive';
|
||||
const badgeText = isOnline ? t('normal') : t('abnormal');
|
||||
const onlineCount = Array.isArray(online_users) ? online_users?.length : 0;
|
||||
const onlineCount = Object.keys(online).length || 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -52,45 +54,56 @@ export function NodeStatusCell({ status }: { status: API.NodeStatus }) {
|
||||
<div className='flex flex-col space-y-1'>
|
||||
<div className='flex justify-between'>
|
||||
<span>CPU</span>
|
||||
<span>{formatPercentage(serverStatus?.cpu ?? 0)}</span>
|
||||
<span>{formatPercentage(cpu ?? 0)}</span>
|
||||
</div>
|
||||
<Progress value={serverStatus?.cpu ?? 0} className='h-2' max={100} />
|
||||
<Progress value={cpu ?? 0} className='h-2' max={100} />
|
||||
</div>
|
||||
<div className='flex flex-col space-y-1'>
|
||||
<div className='flex justify-between'>
|
||||
<span>{t('memory')}</span>
|
||||
<span>{formatPercentage(serverStatus?.mem ?? 0)}</span>
|
||||
<span>{formatPercentage(mem ?? 0)}</span>
|
||||
</div>
|
||||
<Progress value={serverStatus?.mem ?? 0} className='h-2' max={100} />
|
||||
<Progress value={mem ?? 0} className='h-2' max={100} />
|
||||
</div>
|
||||
<div className='flex flex-col space-y-1'>
|
||||
<div className='flex justify-between'>
|
||||
<span>{t('disk')}</span>
|
||||
<span>{formatPercentage(serverStatus?.disk ?? 0)}</span>
|
||||
<span>{formatPercentage(disk ?? 0)}</span>
|
||||
</div>
|
||||
<Progress value={serverStatus?.disk ?? 0} className='h-2' max={100} />
|
||||
<Progress value={disk ?? 0} className='h-2' max={100} />
|
||||
</div>
|
||||
{isOnline && (
|
||||
<div>
|
||||
{t('lastUpdated')}: {formatDate(serverStatus?.updated_at ?? 0)}
|
||||
{t('lastUpdated')}: {formatDate(updated_at ?? 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{isOnline && onlineCount > 0 && (
|
||||
<TooltipContent className='bg-muted text-foreground w-80'>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<h4 className='text-sm font-semibold'>{t('onlineUsers')}</h4>
|
||||
<ScrollArea className='h-[400px] rounded-md border p-2'>
|
||||
{online_users.map((user, index) => (
|
||||
<div key={user.uid} className='py-1 text-xs'>
|
||||
{user.ip} (UID: {user.uid})
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipContent className='bg-card text-foreground w-96'>
|
||||
<ScrollArea className='h-[540px] rounded-md border px-4 py-2'>
|
||||
<h4 className='py-1 text-sm font-semibold'>{t('onlineUsers')}</h4>
|
||||
<Accordion
|
||||
type='single'
|
||||
collapsible
|
||||
className='w-full'
|
||||
onValueChange={(value) => setOpenItem(value)}
|
||||
>
|
||||
{Object.entries(online).map(([uid, ips]) => (
|
||||
<AccordionItem key={uid} value={uid}>
|
||||
<AccordionTrigger>{`[UID: ${uid}] - ${ips[0]}`}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul>
|
||||
{ips.map((ip: string) => (
|
||||
<li key={ip}>{ip}</li>
|
||||
))}
|
||||
</ul>
|
||||
<UserSubscribeDetail id={Number(uid)} enabled={openItem === uid} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,15 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { getUserDetail } from '@/services/admin/user';
|
||||
import { getUserDetail, getUserSubscribeById } from '@/services/admin/user';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@workspace/ui/components/hover-card';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
import { formatBytes, formatDate } from '@workspace/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function UserSubscribeDetail({ id, enabled }: { id: number; enabled: boolean }) {
|
||||
const t = useTranslations('user');
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: id !== 0 && enabled,
|
||||
queryKey: ['getUserSubscribeById', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserSubscribeById({ id });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (!id) return '--';
|
||||
|
||||
const usedTraffic = data ? data.upload + data.download : 0;
|
||||
const totalTraffic = data?.traffic || 0;
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<h3 className='mb-2 text-sm font-medium'>{t('subscriptionInfo')}</h3>
|
||||
<div className='bg-muted/30 rounded-lg p-3'>
|
||||
<ul className='grid gap-3'>
|
||||
<li className='flex items-center justify-between font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('subscriptionId')}</span>
|
||||
<span>{data?.id || '--'}</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('subscriptionName')}</span>
|
||||
<span>{data?.subscribe?.name || '--'}</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('token')}</span>
|
||||
<div className='font-mono text-xs' title={data?.token || ''}>
|
||||
{data?.token || '--'}
|
||||
</div>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('trafficUsage')}</span>
|
||||
<span>
|
||||
{data
|
||||
? totalTraffic === 0
|
||||
? `${formatBytes(usedTraffic)} / ${t('unlimited')}`
|
||||
: `${formatBytes(usedTraffic)} / ${formatBytes(totalTraffic)}`
|
||||
: '--'}
|
||||
</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('startTime')}</span>
|
||||
<span>{data?.start_time ? formatDate(data.start_time) : '--'}</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('expireTime')}</span>
|
||||
<span>{data?.expire_time ? formatDate(data.expire_time) : '--'}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className='mb-2 text-sm font-medium'>
|
||||
{t('userInfo')}
|
||||
{data?.user_id && (
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='text-primary ml-2 h-auto p-0 text-xs'
|
||||
asChild
|
||||
>
|
||||
<Link href={`/dashboard/user/${data.user_id}`}>{t('viewDetails')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</h3>
|
||||
<ul className='grid gap-3'>
|
||||
<li className='flex items-center justify-between font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('userId')}</span>
|
||||
<span>{data?.user_id}</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between font-semibold'>
|
||||
<span className='text-muted-foreground'>{t('balance')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={data?.user.balance} />
|
||||
</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('giftAmount')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={data?.user?.gift_amount} />
|
||||
</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('commission')}</span>
|
||||
<span>
|
||||
<Display type='currency' value={data?.user?.commission} />
|
||||
</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>{t('createdAt')}</span>
|
||||
<span>{data?.user?.created_at && formatDate(data?.user?.created_at)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserDetail({ id }: { id: number }) {
|
||||
const t = useTranslations('user');
|
||||
const [shouldFetch, setShouldFetch] = useState(false);
|
||||
|
||||
Reference in New Issue
Block a user