diff --git a/controller/analytics.go b/controller/analytics.go new file mode 100644 index 00000000..91aefbc2 --- /dev/null +++ b/controller/analytics.go @@ -0,0 +1,117 @@ +package controller + +import ( + "net/http" + "one-api/model" + "strconv" + + "github.com/gin-gonic/gin" +) + +func GetUserStatistics(c *gin.Context) { + userStatistics, err := model.GetStatisticsUser() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法获取用户统计信息.", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": userStatistics, + }) +} + +func GetChannelStatistics(c *gin.Context) { + channelStatistics, err := model.GetStatisticsChannel() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法获取渠道统计信息.", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": channelStatistics, + }) +} + +func GetRedemptionStatistics(c *gin.Context) { + redemptionStatistics, err := model.GetStatisticsRedemption() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法获取充值卡统计信息.", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": redemptionStatistics, + }) +} + +func GetUserStatisticsByPeriod(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + logStatistics, err := model.GetUserStatisticsByPeriod(startTimestamp, endTimestamp) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法获取用户区间统计信息.", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logStatistics, + }) +} + +func GetChannelExpensesByPeriod(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + logStatistics, err := model.GetChannelExpensesByPeriod(startTimestamp, endTimestamp) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法获取渠道区间统计信息.", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logStatistics, + }) +} + +func GetRedemptionStatisticsByPeriod(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + logStatistics, err := model.GetStatisticsRedemptionByPeriod(startTimestamp, endTimestamp) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法获取充值区间统计信息.", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logStatistics, + }) +} diff --git a/controller/user.go b/controller/user.go index 5b5c4624..e854093f 100644 --- a/controller/user.go +++ b/controller/user.go @@ -253,7 +253,7 @@ func GetUserDashboard(c *gin.Context) { endOfDay := toDay.Add(time.Hour * 24).Add(-time.Second).Unix() startOfDay := toDay.AddDate(0, 0, -7).Unix() - dashboards, err := model.SearchLogsByDayAndModel(id, int(startOfDay), int(endOfDay)) + dashboards, err := model.GetUserModelExpensesByPeriod(id, int(startOfDay), int(endOfDay)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, diff --git a/model/channel.go b/model/channel.go index 1f67034c..98e22c10 100644 --- a/model/channel.go +++ b/model/channel.go @@ -182,3 +182,13 @@ func DeleteDisabledChannel() (int64, error) { result := DB.Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled).Delete(&Channel{}) return result.RowsAffected, result.Error } + +type ChannelStatistics struct { + TotalChannels int `json:"total_channels"` + Status int `json:"status"` +} + +func GetStatisticsChannel() (statistics []*ChannelStatistics, err error) { + err = DB.Table("channels").Select("count(*) as total_channels, status").Group("status").Scan(&statistics).Error + return statistics, err +} diff --git a/model/common.go b/model/common.go new file mode 100644 index 00000000..4257b097 --- /dev/null +++ b/model/common.go @@ -0,0 +1,37 @@ +package model + +import ( + "fmt" + "one-api/common" +) + +func getDateFormat(groupType string) string { + var dateFormat string + if groupType == "day" { + dateFormat = "%Y-%m-%d" + if common.UsingPostgreSQL { + dateFormat = "YYYY-MM-DD" + } + } else { + dateFormat = "%Y-%m" + if common.UsingPostgreSQL { + dateFormat = "YYYY-MM" + } + } + return dateFormat +} + +func getTimestampGroupsSelect(fieldName, groupType, alias string) string { + dateFormat := getDateFormat(groupType) + var groupSelect string + + if common.UsingPostgreSQL { + groupSelect = fmt.Sprintf(`TO_CHAR(date_trunc('%s', to_timestamp(%s)), '%s') as %s`, groupType, fieldName, dateFormat, alias) + } else if common.UsingSQLite { + groupSelect = fmt.Sprintf(`strftime('%s', datetime(%s, 'unixepoch')) as %s`, dateFormat, fieldName, alias) + } else { + groupSelect = fmt.Sprintf(`DATE_FORMAT(FROM_UNIXTIME(%s), '%s') as %s`, fieldName, dateFormat, alias) + } + + return groupSelect +} diff --git a/model/log.go b/model/log.go index dbfb20d4..ca5eb5e9 100644 --- a/model/log.go +++ b/model/log.go @@ -24,15 +24,6 @@ type Log struct { RequestTime int `json:"request_time" gorm:"default:0"` } -type LogStatistic struct { - Day string `gorm:"column:day"` - ModelName string `gorm:"column:model_name"` - RequestCount int `gorm:"column:request_count"` - Quota int `gorm:"column:quota"` - PromptTokens int `gorm:"column:prompt_tokens"` - CompletionTokens int `gorm:"column:completion_tokens"` -} - const ( LogTypeUnknown = iota LogTypeTopup @@ -195,16 +186,22 @@ func DeleteOldLog(targetTimestamp int64) (int64, error) { return result.RowsAffected, result.Error } -func SearchLogsByDayAndModel(user_id, start, end int) (LogStatistics []*LogStatistic, err error) { - groupSelect := "DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d') as day" +type LogStatistic struct { + Date string `gorm:"column:date"` + RequestCount int64 `gorm:"column:request_count"` + Quota int64 `gorm:"column:quota"` + PromptTokens int64 `gorm:"column:prompt_tokens"` + CompletionTokens int64 `gorm:"column:completion_tokens"` + RequestTime int64 `gorm:"column:request_time"` +} - if common.UsingPostgreSQL { - groupSelect = "TO_CHAR(date_trunc('day', to_timestamp(created_at)), 'YYYY-MM-DD') as day" - } +type LogStatisticGroupModel struct { + LogStatistic + ModelName string `gorm:"column:model_name"` +} - if common.UsingSQLite { - groupSelect = "strftime('%Y-%m-%d', datetime(created_at, 'unixepoch')) as day" - } +func GetUserModelExpensesByPeriod(user_id, startTimestamp, endTimestamp int) (LogStatistic []*LogStatisticGroupModel, err error) { + groupSelect := getTimestampGroupsSelect("created_at", "day", "date") err = DB.Raw(` SELECT `+groupSelect+`, @@ -216,11 +213,36 @@ func SearchLogsByDayAndModel(user_id, start, end int) (LogStatistics []*LogStati WHERE type=2 AND user_id= ? AND created_at BETWEEN ? AND ? - GROUP BY day, model_name - ORDER BY day, model_name - `, user_id, start, end).Scan(&LogStatistics).Error + GROUP BY date, model_name + ORDER BY date, model_name + `, user_id, startTimestamp, endTimestamp).Scan(&LogStatistic).Error - fmt.Println(user_id, start, end) + return +} + +type LogStatisticGroupChannel struct { + LogStatistic + Channel string `gorm:"column:channel"` +} + +func GetChannelExpensesByPeriod(startTimestamp, endTimestamp int64) (LogStatistics []*LogStatisticGroupChannel, err error) { + groupSelect := getTimestampGroupsSelect("created_at", "day", "date") + + err = DB.Raw(` + SELECT `+groupSelect+`, + count(1) as request_count, + sum(quota) as quota, + sum(prompt_tokens) as prompt_tokens, + sum(completion_tokens) as completion_tokens, + sum(request_time) as request_time, + channels.name as channel + FROM logs + JOIN channels ON logs.channel_id = channels.id + WHERE logs.type=2 + AND logs.created_at BETWEEN ? AND ? + GROUP BY date, channels.name + ORDER BY date, channels.name + `, startTimestamp, endTimestamp).Scan(&LogStatistics).Error return LogStatistics, err } diff --git a/model/redemption.go b/model/redemption.go index e1a13b8c..6cf6be43 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -115,3 +115,37 @@ func DeleteRedemptionById(id int) (err error) { } return redemption.Delete() } + +type RedemptionStatistics struct { + Count int64 `json:"count"` + Quota int64 `json:"quota"` + Status int `json:"status"` +} + +func GetStatisticsRedemption() (redemptionStatistics []*RedemptionStatistics, err error) { + err = DB.Model(&Redemption{}).Select("status", "count(*) as count", "sum(quota) as quota").Where("status != ?", 2).Group("status").Scan(&redemptionStatistics).Error + return redemptionStatistics, err +} + +type RedemptionStatisticsGroup struct { + Date string `json:"date"` + Quota int64 `json:"quota"` + UserCount int64 `json:"user_count"` +} + +func GetStatisticsRedemptionByPeriod(startTimestamp, endTimestamp int64) (redemptionStatistics []*RedemptionStatisticsGroup, err error) { + groupSelect := getTimestampGroupsSelect("redeemed_time", "day", "date") + + err = DB.Raw(` + SELECT `+groupSelect+`, + sum(quota) as quota, + count(distinct user_id) as user_count + FROM redemptions + WHERE status=3 + AND redeemed_time BETWEEN ? AND ? + GROUP BY date + ORDER BY date + `, startTimestamp, endTimestamp).Scan(&redemptionStatistics).Error + + return redemptionStatistics, err +} diff --git a/model/user.go b/model/user.go index 9eca1cb5..d908ab93 100644 --- a/model/user.go +++ b/model/user.go @@ -29,6 +29,7 @@ type User struct { Group string `json:"group" gorm:"type:varchar(32);default:'default'"` AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` } func GetMaxUserId() int { @@ -90,6 +91,7 @@ func (user *User) Insert(inviterId int) error { user.Quota = common.QuotaForNewUser user.AccessToken = common.GetUUID() user.AffCode = common.GetRandomString(4) + user.CreatedTime = common.GetTimestamp() result := DB.Create(user) if result.Error != nil { return result.Error @@ -365,3 +367,37 @@ func GetUsernameById(id int) (username string) { DB.Model(&User{}).Where("id = ?", id).Select("username").Find(&username) return username } + +type StatisticsUser struct { + TotalQuota int64 `json:"total_quota"` + TotalUsedQuota int64 `json:"total_used_quota"` + TotalUser int64 `json:"total_user"` + TotalInviterUser int64 `json:"total_inviter_user"` +} + +func GetStatisticsUser() (statisticsUser *StatisticsUser, err error) { + err = DB.Model(&User{}).Select("sum(quota) as total_quota, sum(used_quota) as total_used_quota, count(*) as total_user, count(CASE WHEN inviter_id != 0 THEN 1 END) as total_inviter_user").Scan(&statisticsUser).Error + return statisticsUser, err +} + +type UserStatisticsByPeriod struct { + Date string `json:"date"` + UserCount int64 `json:"user_count"` + InviterUserCount int64 `json:"inviter_user_count"` +} + +func GetUserStatisticsByPeriod(startTimestamp, endTimestamp int64) (statistics []*UserStatisticsByPeriod, err error) { + groupSelect := getTimestampGroupsSelect("created_time", "day", "date") + + err = DB.Raw(` + SELECT `+groupSelect+`, + count(*) as user_count, + count(CASE WHEN inviter_id != 0 THEN 1 END) as inviter_user_count + FROM users + WHERE created_time BETWEEN ? AND ? + GROUP BY date + ORDER BY date + `, startTimestamp, endTimestamp).Scan(&statistics).Error + + return statistics, err +} diff --git a/router/api-router.go b/router/api-router.go index 323abe7b..9a1f9b6c 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -112,5 +112,16 @@ func SetApiRouter(router *gin.Engine) { { groupRoute.GET("/", controller.GetGroups) } + + analyticsRoute := apiRouter.Group("/analytics") + analyticsRoute.Use(middleware.AdminAuth()) + { + analyticsRoute.GET("/user_statistics", controller.GetUserStatistics) + analyticsRoute.GET("/channel_statistics", controller.GetChannelStatistics) + analyticsRoute.GET("/redemption_statistics", controller.GetRedemptionStatistics) + analyticsRoute.GET("/users_period", controller.GetUserStatisticsByPeriod) + analyticsRoute.GET("/channel_period", controller.GetChannelExpensesByPeriod) + analyticsRoute.GET("/redemption_period", controller.GetRedemptionStatisticsByPeriod) + } } } diff --git a/web/src/menu-items/panel.js b/web/src/menu-items/panel.js index 15b094c9..c4ee6b28 100644 --- a/web/src/menu-items/panel.js +++ b/web/src/menu-items/panel.js @@ -8,11 +8,23 @@ import { IconKey, IconGardenCart, IconUser, - IconUserScan + IconUserScan, + IconActivity } from '@tabler/icons-react'; // constant -const icons = { IconDashboard, IconSitemap, IconArticle, IconCoin, IconAdjustments, IconKey, IconGardenCart, IconUser, IconUserScan }; +const icons = { + IconDashboard, + IconSitemap, + IconArticle, + IconCoin, + IconAdjustments, + IconKey, + IconGardenCart, + IconUser, + IconUserScan, + IconActivity +}; // ==============================|| DASHBOARD MENU ITEMS ||============================== // @@ -29,6 +41,15 @@ const panel = { breadcrumbs: false, isAdmin: false }, + { + id: 'analytics', + title: '分析', + type: 'item', + url: '/panel/analytics', + icon: icons.IconActivity, + breadcrumbs: false, + isAdmin: true + }, { id: 'channel', title: '渠道', diff --git a/web/src/routes/MainRoutes.js b/web/src/routes/MainRoutes.js index 74f7e4c2..9cd26b0e 100644 --- a/web/src/routes/MainRoutes.js +++ b/web/src/routes/MainRoutes.js @@ -13,6 +13,7 @@ const Topup = Loadable(lazy(() => import('views/Topup'))); const User = Loadable(lazy(() => import('views/User'))); const Profile = Loadable(lazy(() => import('views/Profile'))); const NotFoundView = Loadable(lazy(() => import('views/Error'))); +const Analytics = Loadable(lazy(() => import('views/Analytics'))); // dashboard routing const Dashboard = Loadable(lazy(() => import('views/Dashboard'))); @@ -63,6 +64,10 @@ const MainRoutes = { path: 'profile', element: }, + { + path: 'analytics', + element: + }, { path: '404', element: diff --git a/web/src/ui-component/DateRangePicker.js b/web/src/ui-component/DateRangePicker.js new file mode 100644 index 00000000..91c78cf9 --- /dev/null +++ b/web/src/ui-component/DateRangePicker.js @@ -0,0 +1,104 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Stack, Typography } from '@mui/material'; +import { LocalizationProvider, DatePicker } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +require('dayjs/locale/zh-cn'); + +export default class DateRangePicker extends React.Component { + state = { + startDate: this.props.defaultValue.start, + endDate: this.props.defaultValue.end, + localeText: this.props.localeText, + startOpen: false, + endOpen: false, + views: this.props?.views + }; + + handleStartChange = (date) => { + // 将 date设置当天的 00:00:00 + date = date.startOf('day'); + this.setState({ startDate: date }); + }; + + handleEndChange = (date) => { + // 将 date设置当天的 23:59:59 + date = date.endOf('day'); + this.setState({ endDate: date }); + }; + + handleStartOpen = () => { + this.setState({ startOpen: true }); + }; + + handleStartClose = () => { + this.setState({ startOpen: false, endOpen: true }); + }; + + handleEndClose = () => { + this.setState({ endOpen: false }, () => { + const { startDate, endDate } = this.state; + const { defaultValue, onChange } = this.props; + if (!onChange) return; + if (startDate !== defaultValue.start || endDate !== defaultValue.end) { + onChange({ start: startDate, end: endDate }); + } + }); + }; + + render() { + const { startOpen, endOpen, startDate, endDate, localeText } = this.state; + + return ( + + + + + + + + ); + } +} + +DateRangePicker.propTypes = { + defaultValue: PropTypes.object, + onChange: PropTypes.func, + localeText: PropTypes.object, + views: PropTypes.array +}; diff --git a/web/src/ui-component/cards/DataCard.js b/web/src/ui-component/cards/DataCard.js new file mode 100644 index 00000000..131f02b9 --- /dev/null +++ b/web/src/ui-component/cards/DataCard.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import SubCard from 'ui-component/cards/SubCard'; +import { Typography, Tooltip, Divider } from '@mui/material'; +import SkeletonDataCard from 'ui-component/cards/Skeleton/DataCard'; + +export default function DataCard({ isLoading, title, content, tip, subContent }) { + return ( + <> + {isLoading ? ( + + ) : ( + + + {title} + + + {tip ? ( + + {content} + + ) : ( + content + )} + + + + {subContent} + + + )} + + ); +} + +DataCard.propTypes = { + isLoading: PropTypes.bool, + title: PropTypes.string, + content: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + tip: PropTypes.node, + subContent: PropTypes.node +}; diff --git a/web/src/ui-component/cards/Skeleton/DataCard.js b/web/src/ui-component/cards/Skeleton/DataCard.js new file mode 100644 index 00000000..cdc779ee --- /dev/null +++ b/web/src/ui-component/cards/Skeleton/DataCard.js @@ -0,0 +1,17 @@ +// material-ui +import Skeleton from '@mui/material/Skeleton'; +import SubCard from 'ui-component/cards/SubCard'; +import { Divider, Stack } from '@mui/material'; + +const DataCard = () => ( + + + + + + + + +); + +export default DataCard; diff --git a/web/src/ui-component/chart/ApexCharts.js b/web/src/ui-component/chart/ApexCharts.js new file mode 100644 index 00000000..bf243851 --- /dev/null +++ b/web/src/ui-component/chart/ApexCharts.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; + +// material-ui +import { Grid, Typography } from '@mui/material'; + +// third-party +import Chart from 'react-apexcharts'; + +// project imports +import SkeletonTotalGrowthBarChart from 'ui-component/cards/Skeleton/TotalGrowthBarChart'; +import MainCard from 'ui-component/cards/MainCard'; +import { gridSpacing } from 'store/constant'; +import { Box } from '@mui/material'; + +// ==============================|| DASHBOARD DEFAULT - TOTAL GROWTH BAR CHART ||============================== // + +const ApexCharts = ({ isLoading, chartDatas, title = '统计' }) => { + return ( + <> + {isLoading ? ( + + ) : ( + + + + + + {title} + + + + + {chartDatas.series ? ( + + ) : ( + + + 暂无数据 + + + )} + + + + )} + + ); +}; + +ApexCharts.propTypes = { + isLoading: PropTypes.bool, + chartDatas: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + title: PropTypes.string +}; + +export default ApexCharts; diff --git a/web/src/utils/chart.js b/web/src/utils/chart.js index 4633fe37..c42fea6c 100644 --- a/web/src/utils/chart.js +++ b/web/src/utils/chart.js @@ -18,7 +18,7 @@ export function getTodayDay() { return today.toISOString().slice(0, 10); } -export function generateChartOptions(data, unit) { +export function generateLineChartOptions(data, unit) { const dates = data.map((item) => item.date); const values = data.map((item) => item.value); @@ -94,3 +94,146 @@ export function generateChartOptions(data, unit) { } }; } + +export function generateBarChartOptions(xaxis, data, unit = '', decimal = 0) { + return { + height: 480, + type: 'bar', + options: { + title: { + align: 'left', + style: { + fontSize: '14px', + fontWeight: 'bold', + fontFamily: 'Roboto, sans-serif' + } + }, + colors: [ + '#008FFB', + '#00E396', + '#FEB019', + '#FF4560', + '#775DD0', + '#55efc4', + '#81ecec', + '#74b9ff', + '#a29bfe', + '#00b894', + '#00cec9', + '#0984e3', + '#6c5ce7', + '#ffeaa7', + '#fab1a0', + '#ff7675', + '#fd79a8', + '#fdcb6e', + '#e17055', + '#d63031', + '#e84393' + ], + chart: { + id: 'bar-chart', + stacked: true, + toolbar: { + show: true + }, + zoom: { + enabled: true + } + }, + responsive: [ + { + breakpoint: 480, + options: { + legend: { + position: 'bottom', + offsetX: -10, + offsetY: 0 + } + } + } + ], + plotOptions: { + bar: { + horizontal: false, + columnWidth: '50%', + // borderRadius: 10, + dataLabels: { + total: { + enabled: true, + style: { + fontSize: '13px', + fontWeight: 900 + }, + formatter: function (val) { + return renderChartNumber(val, decimal); + } + } + } + } + }, + xaxis: { + type: 'category', + categories: xaxis + }, + legend: { + show: true, + fontSize: '14px', + fontFamily: `'Roboto', sans-serif`, + position: 'bottom', + offsetX: 20, + labels: { + useSeriesColors: false + }, + markers: { + width: 16, + height: 16, + radius: 5 + }, + itemMargin: { + horizontal: 15, + vertical: 8 + } + }, + fill: { + type: 'solid' + }, + dataLabels: { + enabled: false + }, + grid: { + show: true + }, + tooltip: { + theme: 'dark', + fixed: { + enabled: false + }, + marker: { + show: false + }, + y: { + formatter: function (val) { + return renderChartNumber(val, decimal) + ` ${unit}`; + } + } + } + }, + series: data + }; +} + +// 格式化数值 +export function renderChartNumber(number, decimal = 2) { + number = number.toFixed(decimal); + if (number === Number(0).toFixed(decimal)) { + return 0; + } + + // 如果大于1000,显示为k + if (number >= 1000) { + return (number / 1000).toFixed(1) + 'k'; + } + + return number; +} diff --git a/web/src/views/Dashboard/component/StatisticalBarChart.js b/web/src/views/Analytics/component/BubbleChard.js similarity index 90% rename from web/src/views/Dashboard/component/StatisticalBarChart.js rename to web/src/views/Analytics/component/BubbleChard.js index 3dc7cd0f..f19bb765 100644 --- a/web/src/views/Dashboard/component/StatisticalBarChart.js +++ b/web/src/views/Analytics/component/BubbleChard.js @@ -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 }) => { - 统计 + {title} @@ -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 } diff --git a/web/src/views/Analytics/component/Overview.js b/web/src/views/Analytics/component/Overview.js new file mode 100644 index 00000000..f93c8852 --- /dev/null +++ b/web/src/views/Analytics/component/Overview.js @@ -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 ( + + + + + + + {dateRange.start.format('YYYY-MM-DD')} - {dateRange.end.format('YYYY-MM-DD')} + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +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; +} diff --git a/web/src/views/Analytics/component/Statistics.js b/web/src/views/Analytics/component/Statistics.js new file mode 100644 index 00000000..d5dc7b23 --- /dev/null +++ b/web/src/views/Analytics/component/Statistics.js @@ -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 ( + + + + + + + 直接注册:{userStatistics?.total_direct_user || '0'}
邀请注册:{userStatistics?.total_inviter_user || '0'} + + } + /> +
+ + + 正常:{channelStatistics.active} / 禁用:{channelStatistics.disabled} / 测试禁用:{channelStatistics.test_disabled} + + } + /> + + + + 已使用: {redemptionStatistics.used}
未使用: {redemptionStatistics.unused} + + } + /> +
+
+ ); +} diff --git a/web/src/views/Analytics/index.js b/web/src/views/Analytics/index.js new file mode 100644 index 00000000..20b06db2 --- /dev/null +++ b/web/src/views/Analytics/index.js @@ -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 ( + + + + + + + + + + + ); +} diff --git a/web/src/views/Dashboard/index.js b/web/src/views/Dashboard/index.js index 61dbbc1b..43371361 100644 --- a/web/src/views/Dashboard/index.js +++ b/web/src/views/Dashboard/index.js @@ -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 = () => { - + @@ -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; }