✨ feat(dashboard): Statistics
This commit is contained in:
parent
1836980691
commit
2926abc99e
294
apps/admin/components/dashboard/revenue-statistics-card.tsx
Normal file
294
apps/admin/components/dashboard/revenue-statistics-card.tsx
Normal file
@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
|
||||
import { queryRevenueStatistics } from '@/services/admin/console';
|
||||
import { unitConversion } from '@repo/ui/utils';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@shadcn/ui/chart';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart,
|
||||
XAxis,
|
||||
} from '@shadcn/ui/lib/recharts';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Display } from '../display';
|
||||
import { Empty } from '../empty';
|
||||
|
||||
export function RevenueStatisticsCard() {
|
||||
const t = useTranslations('index');
|
||||
|
||||
const IncomeStatisticsConfig = {
|
||||
new_purchase: {
|
||||
label: t('newPurchase'),
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
repurchase: {
|
||||
label: t('repurchase'),
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
};
|
||||
|
||||
const locale = useLocale();
|
||||
const { data: RevenueStatistics } = useQuery({
|
||||
queryKey: ['queryRevenueStatistics'],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryRevenueStatistics();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs defaultValue='today'>
|
||||
<Card>
|
||||
<CardHeader className='flex !flex-row items-center justify-between'>
|
||||
<CardTitle>{t('revenueTitle')}</CardTitle>
|
||||
<TabsList>
|
||||
<TabsTrigger value='today'>{t('today')}</TabsTrigger>
|
||||
<TabsTrigger value='month'>{t('month')}</TabsTrigger>
|
||||
<TabsTrigger value='total'>{t('total')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
<TabsContent value='today'>
|
||||
<CardContent>
|
||||
{RevenueStatistics?.today.new_order_amount ||
|
||||
RevenueStatistics?.today.renewal_order_amount ? (
|
||||
<ChartContainer config={IncomeStatisticsConfig} className='mx-auto max-h-80'>
|
||||
<PieChart>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
type: 'new_purchase',
|
||||
value: unitConversion(
|
||||
'centsToDollars',
|
||||
RevenueStatistics?.today.new_order_amount,
|
||||
),
|
||||
fill: 'var(--color-new_purchase)',
|
||||
},
|
||||
{
|
||||
type: 'repurchase',
|
||||
value: unitConversion(
|
||||
'centsToDollars',
|
||||
RevenueStatistics?.today.renewal_order_amount,
|
||||
),
|
||||
fill: 'var(--color-repurchase)',
|
||||
},
|
||||
]}
|
||||
dataKey='value'
|
||||
nameKey='type'
|
||||
innerRadius={50}
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor='middle'
|
||||
dominantBaseline='middle'
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className='fill-foreground text-2xl font-bold'
|
||||
>
|
||||
{RevenueStatistics?.today.amount_total}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>{t('totalIncome')}</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
<Display value={RevenueStatistics?.today.amount_total} type='currency' />
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
<Display value={RevenueStatistics?.today.new_order_amount} type='currency' />
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
<Display value={RevenueStatistics?.today.renewal_order_amount} type='currency' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='month'>
|
||||
<CardContent>
|
||||
{RevenueStatistics?.monthly.list && RevenueStatistics?.monthly.list.length > 0 ? (
|
||||
<ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={
|
||||
RevenueStatistics?.monthly.list?.map((item) => ({
|
||||
date: item.date,
|
||||
new_purchase: unitConversion('centsToDollars', item.new_order_amount),
|
||||
repurchase: unitConversion('centsToDollars', item.renewal_order_amount),
|
||||
})) || []
|
||||
}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey='new_purchase' fill='var(--color-new_purchase)' radius={4} />
|
||||
<Bar dataKey='repurchase' fill='var(--color-repurchase)' radius={4} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>{t('totalIncome')}</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
<Display value={RevenueStatistics?.monthly.amount_total} type='currency' />
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
<Display value={RevenueStatistics?.monthly.new_order_amount} type='currency' />
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
<Display
|
||||
value={RevenueStatistics?.monthly.renewal_order_amount}
|
||||
type='currency'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='total'>
|
||||
<CardContent>
|
||||
{RevenueStatistics?.all.list && RevenueStatistics?.all.list.length > 0 ? (
|
||||
<ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={
|
||||
RevenueStatistics?.all.list?.map((item) => ({
|
||||
date: item.date,
|
||||
new_purchase: unitConversion('centsToDollars', item.new_order_amount),
|
||||
repurchase: unitConversion('centsToDollars', item.renewal_order_amount),
|
||||
})) || []
|
||||
}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator='dot' />} />
|
||||
<Area
|
||||
dataKey='new_purchase'
|
||||
type='natural'
|
||||
fill='var(--color-new_purchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-new_purchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='repurchase'
|
||||
type='natural'
|
||||
fill='var(--color-repurchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-repurchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>{t('totalIncome')}</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
<Display value={RevenueStatistics?.all.amount_total} type='currency' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@ -1,692 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { queryServerTotalData, queryTicketWaitReply } from '@/services/admin/console';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { formatBytes, unitConversion } from '@repo/ui/utils';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@shadcn/ui/chart';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Label,
|
||||
LabelList,
|
||||
Pie,
|
||||
PieChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from '@shadcn/ui/lib/recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@shadcn/ui/chart';
|
||||
import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from '@shadcn/ui/lib/recharts';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@shadcn/ui/select';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
const UserStatisticsConfig = {
|
||||
register: {
|
||||
label: '注册',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
new_purchase: {
|
||||
label: '新购',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
repurchase: {
|
||||
label: '复购',
|
||||
color: 'hsl(var(--chart-3))',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const IncomeStatisticsConfig = {
|
||||
new_purchase: {
|
||||
label: '新购',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
repurchase: {
|
||||
label: '复购',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
};
|
||||
|
||||
// Sample data - replace with actual data
|
||||
const trafficData = {
|
||||
nodes: {
|
||||
today: [
|
||||
{ name: 'Node 1', traffic: 1000, type: 'Trojan', address: '127.0.0.1:443' },
|
||||
{ name: 'Node 2', traffic: 800, type: 'Trojan', address: '127.0.0.1:444' },
|
||||
{ name: 'Node 3', traffic: 600, type: 'Trojan', address: '127.0.0.1:445' },
|
||||
{ name: 'Node 4', traffic: 400, type: 'Trojan', address: '127.0.0.1:446' },
|
||||
{ name: 'Node 5', traffic: 200, type: 'Trojan', address: '127.0.0.1:447' },
|
||||
{ name: 'Node 6', traffic: 1000, type: 'Trojan', address: '127.0.0.1:443' },
|
||||
{ name: 'Node 7', traffic: 800, type: 'Trojan', address: '127.0.0.1:444' },
|
||||
{ name: 'Node 8', traffic: 600, type: 'Trojan', address: '127.0.0.1:445' },
|
||||
{ name: 'Node 9', traffic: 400, type: 'Trojan', address: '127.0.0.1:446' },
|
||||
{ name: 'Node 10', traffic: 200, type: 'Trojan', address: '127.0.0.1:447' },
|
||||
],
|
||||
yesterday: [
|
||||
{ name: 'Node 1', traffic: 900, type: 'Trojan', address: '127.0.0.1:443' },
|
||||
{ name: 'Node 2', traffic: 750, type: 'Trojan', address: '127.0.0.1:444' },
|
||||
{ name: 'Node 3', traffic: 550, type: 'Trojan', address: '127.0.0.1:445' },
|
||||
{ name: 'Node 4', traffic: 350, type: 'Trojan', address: '127.0.0.1:446' },
|
||||
{ name: 'Node 5', traffic: 150, type: 'Trojan', address: '127.0.0.1:447' },
|
||||
],
|
||||
},
|
||||
users: {
|
||||
today: [
|
||||
{ name: 'olivia.martin@email.com', traffic: 100, email: 'olivia.martin@email.com' },
|
||||
{ name: 'jackson.lee@email.com', traffic: 90, email: 'jackson.lee@email.com' },
|
||||
{ name: 'isabella.nguyen@email.com', traffic: 80, email: 'isabella.nguyen@email.com' },
|
||||
{ name: 'william.chen@email.com', traffic: 70, email: 'william.chen@email.com' },
|
||||
{ name: 'sophia.rodriguez@email.com', traffic: 60, email: 'sophia.rodriguez@email.com' },
|
||||
{ name: 'olivia.martin@email.com', traffic: 100, email: 'olivia.martin@email.com' },
|
||||
{ name: 'jackson.lee@email.com', traffic: 90, email: 'jackson.lee@email.com' },
|
||||
{ name: 'isabella.nguyen@email.com', traffic: 80, email: 'isabella.nguyen@email.com' },
|
||||
{ name: 'william.chen@email.com', traffic: 70, email: 'william.chen@email.com' },
|
||||
{ name: 'sophia.rodriguez@email.com', traffic: 60, email: 'sophia.rodriguez@email.com' },
|
||||
],
|
||||
yesterday: [
|
||||
{ name: 'olivia.martin@email.com', traffic: 95, email: 'olivia.martin@email.com' },
|
||||
{ name: 'jackson.lee@email.com', traffic: 85, email: 'jackson.lee@email.com' },
|
||||
{ name: 'isabella.nguyen@email.com', traffic: 75, email: 'isabella.nguyen@email.com' },
|
||||
{ name: 'william.chen@email.com', traffic: 65, email: 'william.chen@email.com' },
|
||||
{ name: 'sophia.rodriguez@email.com', traffic: 55, email: 'sophia.rodriguez@email.com' },
|
||||
],
|
||||
},
|
||||
};
|
||||
import { Empty } from '../empty';
|
||||
import { RevenueStatisticsCard } from './revenue-statistics-card';
|
||||
import { UserStatisticsCard } from './user-statistics-card';
|
||||
|
||||
export default function Statistics() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('index');
|
||||
|
||||
const { data: TicketTotal } = useQuery({
|
||||
queryKey: ['queryTicketWaitReply'],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryTicketWaitReply();
|
||||
return data.data?.count;
|
||||
},
|
||||
});
|
||||
const { data: ServerTotal } = useQuery({
|
||||
queryKey: ['queryServerTotalData'],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryServerTotalData();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const [dataType, setDataType] = useState<string | 'nodes' | 'users'>('nodes');
|
||||
const [timeFrame, setTimeFrame] = useState<string | 'today' | 'yesterday'>('today');
|
||||
|
||||
const trafficData = {
|
||||
nodes: {
|
||||
today:
|
||||
ServerTotal?.server_traffic_ranking_today?.map((item) => ({
|
||||
name: item.name,
|
||||
traffic: item.download + item.upload,
|
||||
})) || [],
|
||||
yesterday:
|
||||
ServerTotal?.server_traffic_ranking_yesterday?.map((item) => ({
|
||||
name: item.name,
|
||||
traffic: item.download + item.upload,
|
||||
})) || [],
|
||||
},
|
||||
users: {
|
||||
today:
|
||||
ServerTotal?.user_traffic_ranking_today?.map((item) => ({
|
||||
name: item.user_id,
|
||||
traffic: item.download + item.upload,
|
||||
email: item.email,
|
||||
})) || [],
|
||||
yesterday:
|
||||
ServerTotal?.user_traffic_ranking_yesterday?.map((item) => ({
|
||||
name: item.user_id,
|
||||
traffic: item.download + item.upload,
|
||||
email: item.email,
|
||||
})) || [],
|
||||
},
|
||||
};
|
||||
|
||||
const currentData = trafficData[dataType][timeFrame];
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className='text-lg font-semibold'>统计</h1>
|
||||
<h1 className='text-lg font-semibold'>{t('statisticsTitle')}</h1>
|
||||
<div className='grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-8'>
|
||||
{[
|
||||
{
|
||||
title: '在线IP数',
|
||||
value: '666',
|
||||
title: t('onlineIPCount'),
|
||||
value: ServerTotal?.online_user_ips || 0,
|
||||
icon: 'uil:users-alt',
|
||||
onClick: () => console.log('在线IP数 clicked'),
|
||||
href: '/dashboard/server',
|
||||
},
|
||||
{
|
||||
title: '在线节点数',
|
||||
value: '99',
|
||||
title: t('onlineNodeCount'),
|
||||
value: ServerTotal?.online_servers || 0,
|
||||
icon: 'uil:server-network',
|
||||
onClick: () => console.log('在线节点数 clicked'),
|
||||
href: '/dashboard/server',
|
||||
},
|
||||
{
|
||||
title: '离线节点数',
|
||||
value: '1',
|
||||
title: t('offlineNodeCount'),
|
||||
value: ServerTotal?.offline_servers || 0,
|
||||
icon: 'uil:server-network-alt',
|
||||
onClick: () => console.log('离线节点数 clicked'),
|
||||
href: '/dashboard/server',
|
||||
},
|
||||
{
|
||||
title: '待处理工单',
|
||||
value: '1',
|
||||
title: t('pendingTickets'),
|
||||
value: TicketTotal || 0,
|
||||
icon: 'uil:clipboard-notes',
|
||||
onClick: () => console.log('待处理工单 clicked'),
|
||||
href: '/dashboard/ticket',
|
||||
},
|
||||
{
|
||||
title: '今日上传流量',
|
||||
value: formatBytes(99999999999999),
|
||||
title: t('todayUploadTraffic'),
|
||||
value: formatBytes(ServerTotal?.upload_traffic_today || 0),
|
||||
icon: 'uil:arrow-up',
|
||||
onClick: () => console.log('今日上传流量 clicked'),
|
||||
},
|
||||
{
|
||||
title: '今日下载流量',
|
||||
value: formatBytes(99999999999999),
|
||||
title: t('todayDownloadTraffic'),
|
||||
value: formatBytes(ServerTotal?.download_traffic_today || 0),
|
||||
icon: 'uil:arrow-down',
|
||||
onClick: () => console.log('今日下载流量 clicked'),
|
||||
},
|
||||
{
|
||||
title: '总上传流量',
|
||||
value: formatBytes(99999999999999),
|
||||
title: t('monthUploadTraffic'),
|
||||
value: formatBytes(ServerTotal?.upload_traffic_month || 0),
|
||||
icon: 'uil:cloud-upload',
|
||||
onClick: () => console.log('总上传流量 clicked'),
|
||||
},
|
||||
{
|
||||
title: '总下载流量',
|
||||
value: formatBytes(99999999999999),
|
||||
title: t('monthDownloadTraffic'),
|
||||
value: formatBytes(ServerTotal?.download_traffic_month || 0),
|
||||
icon: 'uil:cloud-download',
|
||||
onClick: () => console.log('总下载流量 clicked'),
|
||||
},
|
||||
].map((item, index) => (
|
||||
<Card key={index} onClick={item.onClick} className='cursor-pointer'>
|
||||
<CardHeader className='p-4'>
|
||||
<CardTitle>{item.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='flex justify-between p-4 text-xl'>
|
||||
<Icon icon={item.icon} className='text-muted-foreground' />
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>{item.value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link href={item.href || '#'} key={index}>
|
||||
<Card className='cursor-pointer'>
|
||||
<CardHeader className='p-4'>
|
||||
<CardTitle>{item.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='flex justify-between p-4 text-xl'>
|
||||
<Icon icon={item.icon} className='text-muted-foreground' />
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>{item.value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<Tabs defaultValue='today'>
|
||||
<Card>
|
||||
<CardHeader className='flex !flex-row items-center justify-between'>
|
||||
<CardTitle>收入统计</CardTitle>
|
||||
<TabsList>
|
||||
<TabsTrigger value='today'>今日</TabsTrigger>
|
||||
<TabsTrigger value='month'>本月</TabsTrigger>
|
||||
<TabsTrigger value='total'>总计</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
<TabsContent value='today'>
|
||||
<CardContent>
|
||||
<ChartContainer config={IncomeStatisticsConfig} className='mx-auto max-h-80'>
|
||||
<PieChart>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
type: 'new_purchase',
|
||||
value: 200,
|
||||
fill: 'var(--color-new_purchase)',
|
||||
},
|
||||
{
|
||||
type: 'repurchase',
|
||||
value: 187,
|
||||
fill: 'var(--color-repurchase)',
|
||||
},
|
||||
]}
|
||||
dataKey='value'
|
||||
nameKey='type'
|
||||
innerRadius={50}
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor='middle'
|
||||
dominantBaseline='middle'
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className='fill-foreground text-2xl font-bold'
|
||||
>
|
||||
6,666
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>总收入</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>6,666</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>123</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>456</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
<TabsContent value='month'>
|
||||
<CardContent>
|
||||
<ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={[
|
||||
{ date: '2024-10-01', new_purchase: 98, repurchase: 125 },
|
||||
{ date: '2024-10-02', new_purchase: 110, repurchase: 140 },
|
||||
{ date: '2024-10-03', new_purchase: 100, repurchase: 130 },
|
||||
{ date: '2024-10-04', new_purchase: 115, repurchase: 145 },
|
||||
{ date: '2024-10-05', new_purchase: 108, repurchase: 138 },
|
||||
{ date: '2024-10-06', new_purchase: 120, repurchase: 150 },
|
||||
]}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey='new_purchase' fill='var(--color-new_purchase)' radius={4} />
|
||||
<Bar dataKey='repurchase' fill='var(--color-repurchase)' radius={4} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>总收入</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>654,321</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>123</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{IncomeStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>456</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
<TabsContent value='total'>
|
||||
<CardContent>
|
||||
<ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={[
|
||||
{ date: '2024-07', new_purchase: 98, repurchase: 125 },
|
||||
{ date: '2024-08', new_purchase: 110, repurchase: 140 },
|
||||
{ date: '2024-09', new_purchase: 100, repurchase: 130 },
|
||||
{ date: '2024-10', new_purchase: 115, repurchase: 145 },
|
||||
{ date: '2024-11', new_purchase: 108, repurchase: 138 },
|
||||
{ date: '2024-12', new_purchase: 120, repurchase: 150 },
|
||||
]}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator='dot' />}
|
||||
/>
|
||||
<Area
|
||||
dataKey='new_purchase'
|
||||
type='natural'
|
||||
fill='var(--color-new_purchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-new_purchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='repurchase'
|
||||
type='natural'
|
||||
fill='var(--color-repurchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-repurchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>总收入</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>987,654,321</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
<Tabs defaultValue='today'>
|
||||
<Card>
|
||||
<CardHeader className='flex !flex-row items-center justify-between'>
|
||||
<CardTitle>用户统计</CardTitle>
|
||||
<TabsList>
|
||||
<TabsTrigger value='today'>今日</TabsTrigger>
|
||||
<TabsTrigger value='month'>本月</TabsTrigger>
|
||||
<TabsTrigger value='total'>总计</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
<TabsContent value='today'>
|
||||
<CardContent>
|
||||
<ChartContainer config={UserStatisticsConfig} className='mx-auto max-h-80'>
|
||||
<PieChart>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
type: 'register',
|
||||
value: 275,
|
||||
fill: 'var(--color-register)',
|
||||
},
|
||||
{
|
||||
type: 'new_purchase',
|
||||
value: 200,
|
||||
fill: 'var(--color-new_purchase)',
|
||||
},
|
||||
{
|
||||
type: 'repurchase',
|
||||
value: 187,
|
||||
fill: 'var(--color-repurchase)',
|
||||
},
|
||||
]}
|
||||
dataKey='value'
|
||||
nameKey='type'
|
||||
innerRadius={50}
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor='middle'
|
||||
dominantBaseline='middle'
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className='fill-foreground text-3xl font-bold'
|
||||
>
|
||||
275
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>789</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>123</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>456</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
<TabsContent value='month'>
|
||||
<CardContent>
|
||||
<ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={[
|
||||
{ date: '2024-10-01', register: 215, new_purchase: 98, repurchase: 125 },
|
||||
{ date: '2024-10-02', register: 240, new_purchase: 110, repurchase: 140 },
|
||||
{ date: '2024-10-03', register: 225, new_purchase: 100, repurchase: 130 },
|
||||
{ date: '2024-10-04', register: 250, new_purchase: 115, repurchase: 145 },
|
||||
{ date: '2024-10-05', register: 235, new_purchase: 108, repurchase: 138 },
|
||||
{ date: '2024-10-06', register: 260, new_purchase: 120, repurchase: 150 },
|
||||
]}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey='register' fill='var(--color-register)' radius={4} />
|
||||
<Bar dataKey='new_purchase' fill='var(--color-new_purchase)' radius={4} />
|
||||
<Bar dataKey='repurchase' fill='var(--color-repurchase)' radius={4} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>789</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>123</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>456</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
<TabsContent value='total'>
|
||||
<CardContent>
|
||||
<ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={[
|
||||
{ date: '2024-07', register: 215, new_purchase: 98, repurchase: 125 },
|
||||
{ date: '2024-08', register: 240, new_purchase: 110, repurchase: 140 },
|
||||
{ date: '2024-09', register: 225, new_purchase: 100, repurchase: 130 },
|
||||
{ date: '2024-10', register: 250, new_purchase: 115, repurchase: 145 },
|
||||
{ date: '2024-11', register: 235, new_purchase: 108, repurchase: 138 },
|
||||
{ date: '2024-12', register: 260, new_purchase: 120, repurchase: 150 },
|
||||
]}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator='dot' />}
|
||||
/>
|
||||
<Area
|
||||
dataKey='register'
|
||||
type='natural'
|
||||
fill='var(--color-register)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-register)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='new_purchase'
|
||||
type='natural'
|
||||
fill='var(--color-new_purchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-new_purchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='repurchase'
|
||||
type='natural'
|
||||
fill='var(--color-repurchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-repurchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>987,654,321</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
<RevenueStatisticsCard />
|
||||
<UserStatisticsCard />
|
||||
<Card>
|
||||
<CardHeader className='flex !flex-row items-center justify-between'>
|
||||
<CardTitle>流量排行</CardTitle>
|
||||
<CardTitle>{t('trafficRank')}</CardTitle>
|
||||
<Tabs value={timeFrame} onValueChange={setTimeFrame}>
|
||||
<TabsList>
|
||||
<TabsTrigger value='today'>今日</TabsTrigger>
|
||||
<TabsTrigger value='yesterday'>昨日</TabsTrigger>
|
||||
<TabsTrigger value='today'>{t('today')}</TabsTrigger>
|
||||
<TabsTrigger value='yesterday'>{t('yesterday')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<h4 className='font-semibold'>{dataType === 'nodes' ? '节点流量' : '用户流量'}</h4>
|
||||
<h4 className='font-semibold'>
|
||||
{dataType === 'nodes' ? t('nodeTraffic') : t('userTraffic')}
|
||||
</h4>
|
||||
<Select onValueChange={setDataType} defaultValue='nodes'>
|
||||
<SelectTrigger className='w-28'>
|
||||
<SelectValue placeholder='选择类型' />
|
||||
<SelectValue placeholder={t('selectTypePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='nodes'>节点</SelectItem>
|
||||
<SelectItem value='users'>用户</SelectItem>
|
||||
<SelectItem value='nodes'>{t('nodes')}</SelectItem>
|
||||
<SelectItem value='users'>{t('users')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ChartContainer
|
||||
config={{
|
||||
traffic: {
|
||||
label: '流量',
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
type: {
|
||||
label: '类型',
|
||||
color: 'hsl(var(--muted))',
|
||||
},
|
||||
email: {
|
||||
label: '邮箱',
|
||||
color: 'hsl(var(--muted))',
|
||||
},
|
||||
label: {
|
||||
color: 'hsl(var(--foreground))',
|
||||
},
|
||||
}}
|
||||
className='max-h-80'
|
||||
>
|
||||
<BarChart data={currentData} layout='vertical' height={400}>
|
||||
<CartesianGrid strokeDasharray='3 3' />
|
||||
<XAxis
|
||||
type='number'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(unitConversion('gbToBytes', value) || 0)}
|
||||
/>
|
||||
<YAxis
|
||||
type='category'
|
||||
dataKey='name'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
tickMargin={0}
|
||||
width={15}
|
||||
tickFormatter={(value, index) => String(index + 1)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
label={false}
|
||||
labelFormatter={(label) =>
|
||||
dataType === 'nodes' ? `节点: ${label}` : `用户: ${label}`
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey='traffic' fill='hsl(var(--primary))' radius={[0, 4, 4, 0]}>
|
||||
<LabelList
|
||||
dataKey='name'
|
||||
position='insideLeft'
|
||||
offset={8}
|
||||
className='fill-[--color-label]'
|
||||
fontSize={12}
|
||||
{currentData.length > 0 ? (
|
||||
<ChartContainer
|
||||
config={{
|
||||
traffic: {
|
||||
label: t('todayUploadTraffic'),
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
type: {
|
||||
label: t('type'),
|
||||
color: 'hsl(var(--muted))',
|
||||
},
|
||||
email: {
|
||||
label: t('email'),
|
||||
color: 'hsl(var(--muted))',
|
||||
},
|
||||
label: {
|
||||
color: 'hsl(var(--foreground))',
|
||||
},
|
||||
}}
|
||||
className='max-h-80'
|
||||
>
|
||||
<BarChart data={currentData} layout='vertical' height={400}>
|
||||
<CartesianGrid strokeDasharray='3 3' />
|
||||
<XAxis
|
||||
type='number'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(unitConversion('gbToBytes', value) || 0)}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<YAxis
|
||||
type='category'
|
||||
dataKey='name'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
tickMargin={0}
|
||||
width={15}
|
||||
tickFormatter={(value, index) => String(index + 1)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
label={false}
|
||||
labelFormatter={(label) =>
|
||||
dataType === 'nodes'
|
||||
? `${t('nodes')}: ${label}`
|
||||
: `${t('users')}: ${label}`
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey='traffic' fill='hsl(var(--primary))' radius={[0, 4, 4, 0]}>
|
||||
<LabelList
|
||||
dataKey='name'
|
||||
position='insideLeft'
|
||||
offset={8}
|
||||
className='fill-[--color-label]'
|
||||
fontSize={12}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
330
apps/admin/components/dashboard/user-statistics-card.tsx
Normal file
330
apps/admin/components/dashboard/user-statistics-card.tsx
Normal file
@ -0,0 +1,330 @@
|
||||
'use client';
|
||||
|
||||
import { queryUserStatistics } from '@/services/admin/console';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@shadcn/ui/card';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@shadcn/ui/chart';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart,
|
||||
XAxis,
|
||||
} from '@shadcn/ui/lib/recharts';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Empty } from '../empty';
|
||||
|
||||
const UserStatisticsConfig = {
|
||||
register: {
|
||||
label: '注册',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
new_purchase: {
|
||||
label: '新购',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
repurchase: {
|
||||
label: '复购',
|
||||
color: 'hsl(var(--chart-3))',
|
||||
},
|
||||
};
|
||||
|
||||
export function UserStatisticsCard() {
|
||||
const t = useTranslations('index');
|
||||
|
||||
const UserStatisticsConfig = {
|
||||
register: {
|
||||
label: t('register'),
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
new_purchase: {
|
||||
label: t('newPurchase'),
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
repurchase: {
|
||||
label: t('repurchase'),
|
||||
color: 'hsl(var(--chart-3))',
|
||||
},
|
||||
};
|
||||
const locale = useLocale();
|
||||
|
||||
const { data: UserStatistics } = useQuery({
|
||||
queryKey: ['queryUserStatistics'],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryUserStatistics();
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs defaultValue='today'>
|
||||
<Card>
|
||||
<CardHeader className='flex !flex-row items-center justify-between'>
|
||||
<CardTitle>{t('userTitle')}</CardTitle>
|
||||
<TabsList>
|
||||
<TabsTrigger value='today'>{t('today')}</TabsTrigger>
|
||||
<TabsTrigger value='month'>{t('month')}</TabsTrigger>
|
||||
<TabsTrigger value='total'>{t('total')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
|
||||
<TabsContent value='today'>
|
||||
<CardContent>
|
||||
{UserStatistics?.today.register ||
|
||||
UserStatistics?.today.new_order_users ||
|
||||
UserStatistics?.today.renewal_order_users ? (
|
||||
<ChartContainer config={UserStatisticsConfig} className='mx-auto max-h-80'>
|
||||
<PieChart>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
type: 'register',
|
||||
value: UserStatistics?.today.register || 0,
|
||||
fill: 'var(--color-register)',
|
||||
},
|
||||
{
|
||||
type: 'new_purchase',
|
||||
value: UserStatistics?.today.new_order_users || 0,
|
||||
fill: 'var(--color-new_purchase)',
|
||||
},
|
||||
{
|
||||
type: 'repurchase',
|
||||
value: UserStatistics?.today.renewal_order_users || 0,
|
||||
fill: 'var(--color-repurchase)',
|
||||
},
|
||||
]}
|
||||
dataKey='value'
|
||||
nameKey='type'
|
||||
innerRadius={50}
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
const total =
|
||||
(UserStatistics?.today.register || 0) +
|
||||
(UserStatistics?.today.new_order_users || 0) +
|
||||
(UserStatistics?.today.renewal_order_users || 0);
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor='middle'
|
||||
dominantBaseline='middle'
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className='fill-foreground text-3xl font-bold'
|
||||
>
|
||||
{total}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{UserStatistics?.today.register}
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{UserStatistics?.today.new_order_users}
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{UserStatistics?.today.renewal_order_users}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='month'>
|
||||
<CardContent>
|
||||
{UserStatistics?.monthly.list && UserStatistics?.monthly.list.length > 0 ? (
|
||||
<ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={
|
||||
UserStatistics?.monthly.list?.map((item) => ({
|
||||
date: item.date,
|
||||
register: item.register,
|
||||
new_purchase: item.new_order_users,
|
||||
repurchase: item.renewal_order_users,
|
||||
})) || []
|
||||
}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey='register' fill='var(--color-register)' radius={4} />
|
||||
<Bar dataKey='new_purchase' fill='var(--color-new_purchase)' radius={4} />
|
||||
<Bar dataKey='repurchase' fill='var(--color-repurchase)' radius={4} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{UserStatistics?.monthly.register}
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.new_purchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{UserStatistics?.monthly.new_order_users}
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='mx-2 h-10 w-px' />
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.repurchase.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{UserStatistics?.monthly.renewal_order_users}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='total'>
|
||||
<CardContent>
|
||||
{UserStatistics?.all.list && UserStatistics?.all.list.length > 0 ? (
|
||||
<ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={
|
||||
UserStatistics?.all.list?.map((item) => ({
|
||||
date: item.date,
|
||||
register: item.register,
|
||||
new_purchase: item.new_order_users,
|
||||
repurchase: item.renewal_order_users,
|
||||
})) || []
|
||||
}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='date'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator='dot' />} />
|
||||
<Area
|
||||
dataKey='register'
|
||||
type='natural'
|
||||
fill='var(--color-register)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-register)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='new_purchase'
|
||||
type='natural'
|
||||
fill='var(--color-new_purchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-new_purchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='repurchase'
|
||||
type='natural'
|
||||
fill='var(--color-repurchase)'
|
||||
fillOpacity={0.4}
|
||||
stroke='var(--color-repurchase)'
|
||||
stackId='a'
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className='flex h-20 flex-row border-t p-4'>
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<div className='grid flex-1 auto-rows-min gap-0.5'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{UserStatisticsConfig.register.label}
|
||||
</div>
|
||||
<div className='text-xl font-bold tabular-nums leading-none'>
|
||||
{UserStatistics?.all.register}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
18
apps/admin/components/empty.tsx
Normal file
18
apps/admin/components/empty.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { default as _Empty } from '@repo/ui/empty';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function Empty() {
|
||||
const t = useTranslations('common');
|
||||
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const random = Math.floor(Math.random() * 10);
|
||||
setDescription(t(`empty.${random}`));
|
||||
}, [t]);
|
||||
|
||||
return <_Empty description={description} />;
|
||||
}
|
||||
@ -1 +1,29 @@
|
||||
{}
|
||||
{
|
||||
"email": "Email",
|
||||
"month": "Month",
|
||||
"monthDownloadTraffic": "This Month's Download Traffic",
|
||||
"monthUploadTraffic": "This Month's Upload Traffic",
|
||||
"newPurchase": "New Purchase",
|
||||
"nodeTraffic": "Node Traffic",
|
||||
"nodes": "Nodes",
|
||||
"offlineNodeCount": "Offline Nodes",
|
||||
"onlineIPCount": "Online IPs",
|
||||
"onlineNodeCount": "Online Nodes",
|
||||
"pendingTickets": "Pending Tickets",
|
||||
"register": "Register",
|
||||
"repurchase": "Repurchase",
|
||||
"revenueTitle": "Revenue Statistics",
|
||||
"selectTypePlaceholder": "Select Type",
|
||||
"statisticsTitle": "Statistics",
|
||||
"today": "Today",
|
||||
"todayDownloadTraffic": "Today's Download Traffic",
|
||||
"todayUploadTraffic": "Today's Upload Traffic",
|
||||
"total": "Total",
|
||||
"totalIncome": "Total Income",
|
||||
"trafficRank": "Traffic Ranking",
|
||||
"type": "Type",
|
||||
"userTitle": "User Statistics",
|
||||
"userTraffic": "User Traffic",
|
||||
"users": "Users",
|
||||
"yesterday": "Yesterday"
|
||||
}
|
||||
|
||||
@ -1 +1,29 @@
|
||||
{}
|
||||
{
|
||||
"email": "邮箱",
|
||||
"month": "本月",
|
||||
"monthDownloadTraffic": "本月下载流量",
|
||||
"monthUploadTraffic": "本月上传流量",
|
||||
"newPurchase": "新购",
|
||||
"nodeTraffic": "节点流量",
|
||||
"nodes": "节点",
|
||||
"offlineNodeCount": "离线节点数",
|
||||
"onlineIPCount": "在线IP数",
|
||||
"onlineNodeCount": "在线节点数",
|
||||
"pendingTickets": "待处理工单",
|
||||
"register": "注册",
|
||||
"repurchase": "复购",
|
||||
"revenueTitle": "收入统计",
|
||||
"selectTypePlaceholder": "选择类型",
|
||||
"statisticsTitle": "统计",
|
||||
"today": "今日",
|
||||
"todayDownloadTraffic": "今日下载流量",
|
||||
"todayUploadTraffic": "今日上传流量",
|
||||
"total": "总计",
|
||||
"totalIncome": "总收入",
|
||||
"trafficRank": "流量排行",
|
||||
"type": "类型",
|
||||
"userTitle": "用户统计",
|
||||
"userTraffic": "用户流量",
|
||||
"users": "用户",
|
||||
"yesterday": "昨日"
|
||||
}
|
||||
|
||||
44
apps/admin/services/admin/console.ts
Normal file
44
apps/admin/services/admin/console.ts
Normal file
@ -0,0 +1,44 @@
|
||||
// @ts-ignore
|
||||
/* eslint-disable */
|
||||
import request from '@/utils/request';
|
||||
|
||||
/** Query revenue statistics GET /v1/admin/console/revenue */
|
||||
export async function queryRevenueStatistics(options?: { [key: string]: any }) {
|
||||
return request<API.Response & { data?: API.RevenueStatisticsResponse }>(
|
||||
'/v1/admin/console/revenue',
|
||||
{
|
||||
method: 'GET',
|
||||
...(options || {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Query server total data GET /v1/admin/console/server */
|
||||
export async function queryServerTotalData(options?: { [key: string]: any }) {
|
||||
return request<API.Response & { data?: API.ServerTotalDataResponse }>(
|
||||
'/v1/admin/console/server',
|
||||
{
|
||||
method: 'GET',
|
||||
...(options || {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Query ticket wait reply GET /v1/admin/console/ticket */
|
||||
export async function queryTicketWaitReply(options?: { [key: string]: any }) {
|
||||
return request<API.Response & { data?: API.TicketWaitRelpyResponse }>(
|
||||
'/v1/admin/console/ticket',
|
||||
{
|
||||
method: 'GET',
|
||||
...(options || {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Query user statistics GET /v1/admin/console/user */
|
||||
export async function queryUserStatistics(options?: { [key: string]: any }) {
|
||||
return request<API.Response & { data?: API.UserStatisticsResponse }>('/v1/admin/console/user', {
|
||||
method: 'GET',
|
||||
...(options || {}),
|
||||
});
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
// API 更新时间:
|
||||
// API 唯一标识:
|
||||
import * as announcement from './announcement';
|
||||
import * as console from './console';
|
||||
import * as coupon from './coupon';
|
||||
import * as document from './document';
|
||||
import * as order from './order';
|
||||
@ -15,6 +16,7 @@ import * as tool from './tool';
|
||||
import * as user from './user';
|
||||
export default {
|
||||
announcement,
|
||||
console,
|
||||
coupon,
|
||||
document,
|
||||
order,
|
||||
|
||||
61
apps/admin/services/admin/typings.d.ts
vendored
61
apps/admin/services/admin/typings.d.ts
vendored
@ -556,6 +556,14 @@ declare namespace API {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type OrdersStatistics = {
|
||||
date?: string;
|
||||
amount_total: number;
|
||||
new_order_amount: number;
|
||||
renewal_order_amount: number;
|
||||
list?: OrdersStatistics[];
|
||||
};
|
||||
|
||||
type PaymentConfig = {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -589,6 +597,12 @@ declare namespace API {
|
||||
data?: Record<string, any>;
|
||||
};
|
||||
|
||||
type RevenueStatisticsResponse = {
|
||||
today: OrdersStatistics;
|
||||
monthly: OrdersStatistics;
|
||||
all: OrdersStatistics;
|
||||
};
|
||||
|
||||
type SecurityConfig = {
|
||||
sni: string;
|
||||
allow_insecure: boolean;
|
||||
@ -633,6 +647,28 @@ declare namespace API {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type ServerTotalDataResponse = {
|
||||
online_user_ips: number;
|
||||
online_servers: number;
|
||||
offline_servers: number;
|
||||
today_upload: number;
|
||||
today_download: number;
|
||||
monthly_upload: number;
|
||||
monthly_download: number;
|
||||
updated_at: number;
|
||||
server_traffic_ranking_today: ServerTrafficData[];
|
||||
server_traffic_ranking_yesterday: ServerTrafficData[];
|
||||
user_traffic_ranking_today: UserTrafficData[];
|
||||
user_traffic_ranking_yesterday: UserTrafficData[];
|
||||
};
|
||||
|
||||
type ServerTrafficData = {
|
||||
server_id: number;
|
||||
name: string;
|
||||
upload: number;
|
||||
download: number;
|
||||
};
|
||||
|
||||
type Shadowsocks = {
|
||||
method: string;
|
||||
port: number;
|
||||
@ -719,6 +755,10 @@ declare namespace API {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type TicketWaitRelpyResponse = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
type TosConfig = {
|
||||
tos_content: string;
|
||||
};
|
||||
@ -942,6 +982,20 @@ declare namespace API {
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type UserStatistics = {
|
||||
date?: string;
|
||||
register: number;
|
||||
new_order_users: number;
|
||||
renewal_order_users: number;
|
||||
list?: UserStatistics[];
|
||||
};
|
||||
|
||||
type UserStatisticsResponse = {
|
||||
today: UserStatistics;
|
||||
monthly: UserStatistics;
|
||||
all: UserStatistics;
|
||||
};
|
||||
|
||||
type UserSubscribe = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
@ -959,6 +1013,13 @@ declare namespace API {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type UserTrafficData = {
|
||||
user_id: number;
|
||||
email: string;
|
||||
upload: number;
|
||||
download: number;
|
||||
};
|
||||
|
||||
type VerifyConfig = {
|
||||
turnstile_site_key: string;
|
||||
turnstile_secret: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user