feat: add admin statistics (#59)

This commit is contained in:
Buer
2024-02-01 18:45:53 +08:00
committed by GitHub
parent f2aafab0d9
commit 332b6fd397
20 changed files with 1225 additions and 65 deletions

View File

@@ -14,7 +14,7 @@ import { Box } from '@mui/material';
// ==============================|| DASHBOARD DEFAULT - TOTAL GROWTH BAR CHART ||============================== //
const StatisticalBarChart = ({ isLoading, chartDatas }) => {
const BubbleChard = ({ isLoading, chartDatas, title = '统计' }) => {
chartData.options.xaxis.categories = chartDatas.xaxis;
chartData.series = chartDatas.data;
@@ -28,7 +28,7 @@ const StatisticalBarChart = ({ isLoading, chartDatas }) => {
<Grid item xs={12}>
<Grid container alignItems="center" justifyContent="space-between">
<Grid item>
<Typography variant="h3">统计</Typography>
<Typography variant="h3">{title}</Typography>
</Grid>
</Grid>
</Grid>
@@ -57,16 +57,17 @@ const StatisticalBarChart = ({ isLoading, chartDatas }) => {
);
};
StatisticalBarChart.propTypes = {
BubbleChard.propTypes = {
isLoading: PropTypes.bool,
chartDatas: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
chartDatas: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
title: PropTypes.string
};
export default StatisticalBarChart;
export default BubbleChard;
const chartData = {
height: 480,
type: 'bar',
type: 'bubble',
options: {
colors: [
'#008FFB',
@@ -92,7 +93,7 @@ const chartData = {
'#e84393'
],
chart: {
id: 'bar-chart',
id: 'bubble',
stacked: true,
toolbar: {
show: true
@@ -156,11 +157,6 @@ const chartData = {
fixed: {
enabled: false
},
y: {
formatter: function (val) {
return '$' + val;
}
},
marker: {
show: false
}

View File

@@ -0,0 +1,334 @@
import { useState, useEffect, useCallback } from 'react';
import { Grid, Typography, Divider } from '@mui/material';
import { gridSpacing } from 'store/constant';
import DateRangePicker from 'ui-component/DateRangePicker';
import ApexCharts from 'ui-component/chart/ApexCharts';
import { showError, calculateQuota } from 'utils/common';
import dayjs from 'dayjs';
import { API } from 'utils/api';
import { generateBarChartOptions, renderChartNumber } from 'utils/chart';
export default function Overview() {
const [channelLoading, setChannelLoading] = useState(true);
const [redemptionLoading, setRedemptionLoading] = useState(true);
const [usersLoading, setUsersLoading] = useState(true);
const [channelData, setChannelData] = useState([]);
const [redemptionData, setRedemptionData] = useState([]);
const [usersData, setUsersData] = useState([]);
const [dateRange, setDateRange] = useState({ start: dayjs().subtract(6, 'day').startOf('day'), end: dayjs().endOf('day') });
const handleDateRangeChange = (value) => {
setDateRange(value);
};
const channelChart = useCallback(async () => {
setChannelLoading(true);
try {
const res = await API.get('/api/analytics/channel_period', {
params: {
start_timestamp: dateRange.start.unix(),
end_timestamp: dateRange.end.unix()
}
});
const { success, message, data } = res.data;
if (success) {
if (data) {
setChannelData(getBarChartOptions(data, dateRange));
}
} else {
showError(message);
}
setChannelLoading(false);
} catch (error) {
return;
}
}, [dateRange]);
const redemptionChart = useCallback(async () => {
setRedemptionLoading(true);
try {
const res = await API.get('/api/analytics/redemption_period', {
params: {
start_timestamp: dateRange.start.unix(),
end_timestamp: dateRange.end.unix()
}
});
const { success, message, data } = res.data;
if (success) {
if (data) {
let chartData = getRedemptionData(data, dateRange);
setRedemptionData(chartData);
}
} else {
showError(message);
}
setRedemptionLoading(false);
} catch (error) {
return;
}
}, [dateRange]);
const usersChart = useCallback(async () => {
setUsersLoading(true);
try {
const res = await API.get('/api/analytics/users_period', {
params: {
start_timestamp: dateRange.start.unix(),
end_timestamp: dateRange.end.unix()
}
});
const { success, message, data } = res.data;
if (success) {
if (data) {
setUsersData(getUsersData(data, dateRange));
}
} else {
showError(message);
}
setUsersLoading(false);
} catch (error) {
return;
}
}, [dateRange]);
useEffect(() => {
channelChart();
redemptionChart();
usersChart();
}, [dateRange, channelChart, redemptionChart, usersChart]);
return (
<Grid container spacing={gridSpacing}>
<Grid item lg={8} xs={12}>
<DateRangePicker defaultValue={dateRange} onChange={handleDateRangeChange} localeText={{ start: '开始时间', end: '结束时间' }} />
</Grid>
<Grid item xs={12}>
<Typography variant="h3">
{dateRange.start.format('YYYY-MM-DD')} - {dateRange.end.format('YYYY-MM-DD')}
</Typography>
</Grid>
<Grid item xs={12}>
<Divider />
</Grid>
<Grid item xs={12}>
<ApexCharts id="cost" isLoading={channelLoading} chartDatas={channelData?.costs || {}} title="消费统计" decimal={3} />
</Grid>
<Grid item xs={12}>
<ApexCharts id="token" isLoading={channelLoading} chartDatas={channelData?.tokens || {}} title="Tokens统计" unit="" />
</Grid>
<Grid item xs={12}>
<ApexCharts id="latency" isLoading={channelLoading} chartDatas={channelData?.latency || {}} title="平均延迟" unit="" />
</Grid>
<Grid item xs={12}>
<ApexCharts id="requests" isLoading={channelLoading} chartDatas={channelData?.requests || {}} title="请求数" unit="" />
</Grid>
<Grid item xs={12}>
<ApexCharts isLoading={redemptionLoading} chartDatas={redemptionData} title="兑换统计" />
</Grid>
<Grid item xs={12}>
<ApexCharts isLoading={usersLoading} chartDatas={usersData} title="注册统计" />
</Grid>
</Grid>
);
}
function getDates(start, end) {
var dates = [];
var current = start;
while (current.isBefore(end) || current.isSame(end)) {
dates.push(current.format('YYYY-MM-DD'));
current = current.add(1, 'day');
}
return dates;
}
function calculateDailyData(item, dateMap) {
const index = dateMap.get(item.Date);
if (index === undefined) return null;
return {
name: item.Channel,
costs: calculateQuota(item.Quota, 3),
tokens: item.PromptTokens + item.CompletionTokens,
requests: item.RequestCount,
latency: Number(item.RequestTime / 1000 / item.RequestCount).toFixed(3),
index: index
};
}
function getBarDataGroup(data, dates) {
const dateMap = new Map(dates.map((date, index) => [date, index]));
const result = {
costs: { total: 0, data: new Map() },
tokens: { total: 0, data: new Map() },
requests: { total: 0, data: new Map() },
latency: { total: 0, data: new Map() }
};
for (const item of data) {
const dailyData = calculateDailyData(item, dateMap);
if (!dailyData) continue;
for (let key in result) {
if (!result[key].data.has(dailyData.name)) {
result[key].data.set(dailyData.name, { name: dailyData.name, data: new Array(dates.length).fill(0) });
}
const channelDailyData = result[key].data.get(dailyData.name);
channelDailyData.data[dailyData.index] = dailyData[key];
result[key].total += Number(dailyData[key]);
}
}
return result;
}
function getBarChartOptions(data, dateRange) {
const dates = getDates(dateRange.start, dateRange.end);
const result = getBarDataGroup(data, dates);
let channelData = {};
channelData.costs = generateBarChartOptions(dates, Array.from(result.costs.data.values()), '美元', 3);
channelData.costs.options.title.text = '总消费:$' + renderChartNumber(result.costs.total, 3);
channelData.tokens = generateBarChartOptions(dates, Array.from(result.tokens.data.values()), '', 0);
channelData.tokens.options.title.text = '总Tokens' + renderChartNumber(result.tokens.total, 0);
channelData.requests = generateBarChartOptions(dates, Array.from(result.requests.data.values()), '次', 0);
channelData.requests.options.title.text = '总请求数:' + renderChartNumber(result.requests.total, 0);
// 获取每天所有渠道的平均延迟
let latency = Array.from(result.latency.data.values());
let sums = [];
let counts = [];
for (let obj of latency) {
for (let i = 0; i < obj.data.length; i++) {
let value = parseFloat(obj.data[i]);
sums[i] = sums[i] || 0;
counts[i] = counts[i] || 0;
if (value !== 0) {
sums[i] = (sums[i] || 0) + value;
counts[i] = (counts[i] || 0) + 1;
}
}
}
// 追加latency列表后面
latency[latency.length] = {
name: '平均延迟',
data: sums.map((sum, i) => Number(counts[i] ? sum / counts[i] : 0).toFixed(3))
};
let dashArray = new Array(latency.length - 1).fill(0);
dashArray.push(5);
channelData.latency = generateBarChartOptions(dates, latency, '秒', 3);
channelData.latency.type = 'line';
channelData.latency.options.chart = {
type: 'line',
zoom: {
enabled: false
}
};
channelData.latency.options.stroke = {
curve: 'smooth',
dashArray: dashArray
};
return channelData;
}
function getRedemptionData(data, dateRange) {
const dates = getDates(dateRange.start, dateRange.end);
const result = [
{
name: '兑换金额($)',
type: 'column',
data: new Array(dates.length).fill(0)
},
{
name: '独立用户(人)',
type: 'line',
data: new Array(dates.length).fill(0)
}
];
for (const item of data) {
const index = dates.indexOf(item.date);
if (index !== -1) {
result[0].data[index] = calculateQuota(item.quota, 3);
result[1].data[index] = item.user_count;
}
}
let chartData = {
height: 480,
options: {
chart: {
type: 'line'
},
stroke: {
width: [0, 4]
},
dataLabels: {
enabled: true,
enabledOnSeries: [1]
},
xaxis: {
type: 'category',
categories: dates
},
yaxis: [
{
title: {
text: '兑换金额($)'
}
},
{
opposite: true,
title: {
text: '独立用户(人)'
}
}
],
tooltip: {
theme: 'dark'
}
},
series: result
};
return chartData;
}
function getUsersData(data, dateRange) {
const dates = getDates(dateRange.start, dateRange.end);
const result = [
{
name: '直接注册',
data: new Array(dates.length).fill(0)
},
{
name: '邀请注册',
data: new Array(dates.length).fill(0)
}
];
let total = 0;
for (const item of data) {
const index = dates.indexOf(item.date);
if (index !== -1) {
result[0].data[index] = item.user_count - item.inviter_user_count;
result[1].data[index] = item.inviter_user_count;
total += item.user_count;
}
}
let chartData = generateBarChartOptions(dates, result, '人', 0);
chartData.options.title.text = '总注册人数:' + total;
return chartData;
}

View File

@@ -0,0 +1,151 @@
import { useState, useEffect, useCallback } from 'react';
import { Grid } from '@mui/material';
import DataCard from 'ui-component/cards/DataCard';
import { gridSpacing } from 'store/constant';
import { showError, renderQuota } from 'utils/common';
import { API } from 'utils/api';
export default function Overview() {
const [userLoading, setUserLoading] = useState(true);
const [channelLoading, setChannelLoading] = useState(true);
const [redemptionLoading, setRedemptionLoading] = useState(true);
const [userStatistics, setUserStatistics] = useState({});
const [channelStatistics, setChannelStatistics] = useState({
active: 0,
disabled: 0,
test_disabled: 0,
total: 0
});
const [redemptionStatistics, setRedemptionStatistics] = useState({
total: 0,
used: 0,
unused: 0
});
const userStatisticsData = useCallback(async () => {
try {
const res = await API.get('/api/analytics/user_statistics');
const { success, message, data } = res.data;
if (success) {
data.total_quota = renderQuota(data.total_quota);
data.total_used_quota = renderQuota(data.total_used_quota);
data.total_direct_user = data.total_user - data.total_inviter_user;
setUserStatistics(data);
setUserLoading(false);
} else {
showError(message);
}
} catch (error) {
return;
}
}, []);
const channelStatisticsData = useCallback(async () => {
try {
const res = await API.get('/api/analytics/channel_statistics');
const { success, message, data } = res.data;
if (success) {
let channelData = channelStatistics;
channelData.total = 0;
data.forEach((item) => {
if (item.status === 1) {
channelData.active = item.total_channels;
} else if (item.status === 2) {
channelData.disabled = item.total_channels;
} else if (item.status === 3) {
channelData.test_disabled = item.total_channels;
}
channelData.total += item.total_channels;
});
setChannelStatistics(channelData);
setChannelLoading(false);
} else {
showError(message);
}
} catch (error) {
return;
}
}, [channelStatistics]);
const redemptionStatisticsData = useCallback(async () => {
try {
const res = await API.get('/api/analytics/redemption_statistics');
const { success, message, data } = res.data;
if (success) {
let redemptionData = redemptionStatistics;
redemptionData.total = 0;
data.forEach((item) => {
if (item.status === 1) {
redemptionData.unused = renderQuota(item.quota);
} else if (item.status === 3) {
redemptionData.used = renderQuota(item.quota);
}
redemptionData.total += item.quota;
});
redemptionData.total = renderQuota(redemptionData.total);
setRedemptionStatistics(redemptionData);
setRedemptionLoading(false);
} else {
showError(message);
}
} catch (error) {
return;
}
}, [redemptionStatistics]);
useEffect(() => {
userStatisticsData();
channelStatisticsData();
redemptionStatisticsData();
}, [userStatisticsData, channelStatisticsData, redemptionStatisticsData]);
return (
<Grid container spacing={gridSpacing}>
<Grid item lg={3} xs={12}>
<DataCard
isLoading={userLoading}
title="用户总消费金额"
content={userStatistics?.total_used_quota || '0'}
subContent={'用户总余额:' + (userStatistics?.total_quota || '0')}
/>
</Grid>
<Grid item lg={3} xs={12}>
<DataCard
isLoading={userLoading}
title="用户总数"
content={userStatistics?.total_user || '0'}
subContent={
<>
直接注册{userStatistics?.total_direct_user || '0'} <br /> 邀请注册{userStatistics?.total_inviter_user || '0'}
</>
}
/>
</Grid>
<Grid item lg={3} xs={12}>
<DataCard
isLoading={channelLoading}
title="渠道数量"
content={channelStatistics.total}
subContent={
<>
正常{channelStatistics.active} / 禁用{channelStatistics.disabled} / 测试禁用{channelStatistics.test_disabled}
</>
}
/>
</Grid>
<Grid item lg={3} xs={12}>
<DataCard
isLoading={redemptionLoading}
title="兑换码发行量"
content={redemptionStatistics.total}
subContent={
<>
已使用: {redemptionStatistics.used} <br /> 未使用: {redemptionStatistics.unused}
</>
}
/>
</Grid>
</Grid>
);
}

View File

@@ -0,0 +1,20 @@
import { gridSpacing } from 'store/constant';
import { Grid } from '@mui/material';
import MainCard from 'ui-component/cards/MainCard';
import Statistics from './component/Statistics';
import Overview from './component/Overview';
export default function MarketingData() {
return (
<Grid container spacing={gridSpacing}>
<Grid item xs={12}>
<Statistics />
</Grid>
<Grid item xs={12}>
<MainCard>
<Overview />
</MainCard>
</Grid>
</Grid>
);
}

View File

@@ -2,9 +2,9 @@ import { useEffect, useState } from 'react';
import { Grid, Typography } from '@mui/material';
import { gridSpacing } from 'store/constant';
import StatisticalLineChartCard from './component/StatisticalLineChartCard';
import StatisticalBarChart from './component/StatisticalBarChart';
import ApexCharts from 'ui-component/chart/ApexCharts';
import SupportModels from './component/SupportModels';
import { generateChartOptions, getLastSevenDays } from 'utils/chart';
import { generateLineChartOptions, getLastSevenDays, generateBarChartOptions, renderChartNumber } from 'utils/chart';
import { API } from 'utils/api';
import { showError, calculateQuota, renderNumber } from 'utils/common';
import UserCard from 'ui-component/cards/UserCard';
@@ -94,7 +94,7 @@ const Dashboard = () => {
<Grid item xs={12}>
<Grid container spacing={gridSpacing}>
<Grid item lg={8} xs={12}>
<StatisticalBarChart isLoading={isLoading} chartDatas={statisticalData} />
<ApexCharts isLoading={isLoading} chartDatas={statisticalData} />
</Grid>
<Grid item lg={4} xs={12}>
<UserCard>
@@ -129,33 +129,33 @@ export default Dashboard;
function getLineDataGroup(statisticalData) {
let groupedData = statisticalData.reduce((acc, cur) => {
if (!acc[cur.Day]) {
acc[cur.Day] = {
date: cur.Day,
if (!acc[cur.Date]) {
acc[cur.Date] = {
date: cur.Date,
RequestCount: 0,
Quota: 0,
PromptTokens: 0,
CompletionTokens: 0
};
}
acc[cur.Day].RequestCount += cur.RequestCount;
acc[cur.Day].Quota += cur.Quota;
acc[cur.Day].PromptTokens += cur.PromptTokens;
acc[cur.Day].CompletionTokens += cur.CompletionTokens;
acc[cur.Date].RequestCount += cur.RequestCount;
acc[cur.Date].Quota += cur.Quota;
acc[cur.Date].PromptTokens += cur.PromptTokens;
acc[cur.Date].CompletionTokens += cur.CompletionTokens;
return acc;
}, {});
let lastSevenDays = getLastSevenDays();
return lastSevenDays.map((day) => {
if (!groupedData[day]) {
return lastSevenDays.map((Date) => {
if (!groupedData[Date]) {
return {
date: day,
date: Date,
RequestCount: 0,
Quota: 0,
PromptTokens: 0,
CompletionTokens: 0
};
} else {
return groupedData[day];
return groupedData[Date];
}
});
}
@@ -164,28 +164,26 @@ function getBarDataGroup(data) {
const lastSevenDays = getLastSevenDays();
const result = [];
const map = new Map();
let totalCosts = 0;
for (const item of data) {
if (!map.has(item.ModelName)) {
const newData = { name: item.ModelName, data: new Array(7) };
const newData = { name: item.ModelName, data: new Array(7).fill(0) };
map.set(item.ModelName, newData);
result.push(newData);
}
const index = lastSevenDays.indexOf(item.Day);
const index = lastSevenDays.indexOf(item.Date);
if (index !== -1) {
map.get(item.ModelName).data[index] = calculateQuota(item.Quota, 3);
let costs = Number(calculateQuota(item.Quota, 3));
map.get(item.ModelName).data[index] = costs;
totalCosts += parseFloat(costs.toFixed(3));
}
}
for (const item of result) {
for (let i = 0; i < 7; i++) {
if (item.data[i] === undefined) {
item.data[i] = 0;
}
}
}
let chartData = generateBarChartOptions(lastSevenDays, result, '美元', 3);
chartData.options.title.text = '7日总消费$' + renderChartNumber(totalCosts, 3);
return { data: result, xaxis: lastSevenDays };
return chartData;
}
function getLineCardOption(lineDataGroup, field) {
@@ -214,15 +212,15 @@ function getLineCardOption(lineDataGroup, field) {
switch (field) {
case 'RequestCount':
chartData = generateChartOptions(lineData, '次');
chartData = generateLineChartOptions(lineData, '次');
todayValue = renderNumber(todayValue);
break;
case 'Quota':
chartData = generateChartOptions(lineData, '美元');
chartData = generateLineChartOptions(lineData, '美元');
todayValue = '$' + renderNumber(todayValue);
break;
case 'PromptTokens':
chartData = generateChartOptions(lineData, '');
chartData = generateLineChartOptions(lineData, '');
todayValue = renderNumber(todayValue);
break;
}