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;
}