Просмотр исходного кода

增加数据看板展示更多统计,增加令牌控制

增加仪表盘看板的按令牌统计、按用户统计和按渠道统计;
令牌中选择渠道组,该令牌只有渠道组下面的渠道可用,已经可以结合选择渠道组,选择模型达到限制需求;
API令牌中增加控制使用总次数限制,当达到总次数限制时返回额度已用完, 快捷选择到期日期1小时改为1周;
aiprodcoder 3 месяцев назад
Родитель
Сommit
4ed8f74f67

+ 13 - 0
bin/add_token_total_usage_limit_field.sql

@@ -0,0 +1,13 @@
+-- Token总使用次数限制字段迁移脚本
+-- 添加日期: 2025-09-11
+-- 功能: 为tokens表添加总使用次数限制字段
+
+-- MySQL 语法
+ALTER TABLE tokens ADD COLUMN total_usage_limit INT DEFAULT NULL COMMENT '总使用次数限制,NULL表示不限制';
+
+-- PostgreSQL 语法 (如果使用PostgreSQL数据库)
+-- ALTER TABLE tokens ADD COLUMN total_usage_limit INTEGER DEFAULT NULL;
+-- COMMENT ON COLUMN tokens.total_usage_limit IS '总使用次数限制,NULL表示不限制';
+
+-- SQLite 语法 (如果使用SQLite数据库)
+-- ALTER TABLE tokens ADD COLUMN total_usage_limit INTEGER DEFAULT NULL;

+ 1 - 1
common/init.go

@@ -19,7 +19,7 @@ var (
 )
 
 func printHelp() {
-	fmt.Println("New API " + Version + " - All in one API service for OpenAI API.")
+	fmt.Println("MIX API " + Version + " - All in one API service for OpenAI API.")
 	fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.")
 	fmt.Println("GitHub: https://github.com/songquanpeng/one-api")
 	fmt.Println("Usage: one-api [--port <port>] [--log-dir <log directory>] [--version] [--help]")

+ 1 - 0
constant/context_key.go

@@ -15,6 +15,7 @@ const (
 	ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id"
 	ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
 	ContextKeyTokenModelLimit        ContextKey = "token_model_limit"
+	ContextKeyTokenChannelTag        ContextKey = "token_channel_tag" // 添加渠道标签上下文键
 
 	/* channel related keys */
 	ContextKeyChannelId                ContextKey = "channel_id"

+ 116 - 0
controller/statistics_charts.go

@@ -0,0 +1,116 @@
+package controller
+
+import (
+	"net/http"
+	"one-api/common"
+	"one-api/model"
+	"strconv"
+	"time"
+
+	"github.com/gin-gonic/gin"
+)
+
+// GetChannelStatistics 获取按渠道统计的数据
+func GetChannelStatistics(c *gin.Context) {
+	startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+	endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
+	username := c.Query("username")
+	tokenName := c.Query("token_name")
+	modelName := c.Query("model_name")
+	channel, _ := strconv.Atoi(c.Query("channel"))
+	group := c.Query("group")
+	defaultTime := c.Query("default_time")
+
+	// 设置默认时间范围为最近7天
+	if startTimestamp == 0 && endTimestamp == 0 {
+		endTimestamp = time.Now().Unix()
+		startTimestamp = endTimestamp - 7*24*3600
+	}
+
+	// 设置默认时间粒度
+	if defaultTime == "" {
+		defaultTime = "day"
+	}
+
+	statistics, err := model.GetChannelStatistics(int(startTimestamp), int(endTimestamp), username, tokenName, modelName, channel, group, defaultTime)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    statistics,
+	})
+}
+
+// GetTokenStatistics 获取按令牌统计的数据
+func GetTokenStatistics(c *gin.Context) {
+	startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+	endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
+	username := c.Query("username")
+	tokenName := c.Query("token_name")
+	modelName := c.Query("model_name")
+	channel, _ := strconv.Atoi(c.Query("channel"))
+	group := c.Query("group")
+	defaultTime := c.Query("default_time")
+
+	// 设置默认时间范围为最近7天
+	if startTimestamp == 0 && endTimestamp == 0 {
+		endTimestamp = time.Now().Unix()
+		startTimestamp = endTimestamp - 7*24*3600
+	}
+
+	// 设置默认时间粒度
+	if defaultTime == "" {
+		defaultTime = "day"
+	}
+
+	statistics, err := model.GetTokenStatistics(int(startTimestamp), int(endTimestamp), username, tokenName, modelName, channel, group, defaultTime)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    statistics,
+	})
+}
+
+// GetUserStatistics 获取按用户统计的数据
+func GetUserStatistics(c *gin.Context) {
+	startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+	endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
+	username := c.Query("username")
+	tokenName := c.Query("token_name")
+	modelName := c.Query("model_name")
+	channel, _ := strconv.Atoi(c.Query("channel"))
+	group := c.Query("group")
+	defaultTime := c.Query("default_time")
+
+	// 设置默认时间范围为最近7天
+	if startTimestamp == 0 && endTimestamp == 0 {
+		endTimestamp = time.Now().Unix()
+		startTimestamp = endTimestamp - 7*24*3600
+	}
+
+	// 设置默认时间粒度
+	if defaultTime == "" {
+		defaultTime = "day"
+	}
+
+	statistics, err := model.GetUserStatistics(int(startTimestamp), int(endTimestamp), username, tokenName, modelName, channel, group, defaultTime)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    statistics,
+	})
+}

+ 27 - 0
controller/token.go

@@ -82,6 +82,29 @@ func GetTokenStatus(c *gin.Context) {
 	})
 }
 
+func GetTokenTags(c *gin.Context) {
+	tags, err := model.GetPaginatedTags(0, 1000) // 获取所有标签
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	// 过滤掉空标签
+	filteredTags := make([]string, 0)
+	for _, tag := range tags {
+		if tag != nil && *tag != "" {
+			filteredTags = append(filteredTags, *tag)
+		}
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    filteredTags,
+	})
+	return
+}
+
 func AddToken(c *gin.Context) {
 	token := model.Token{}
 	err := c.ShouldBindJSON(&token)
@@ -121,6 +144,8 @@ func AddToken(c *gin.Context) {
 		RateLimitPerMinute: token.RateLimitPerMinute,
 		RateLimitPerDay:    token.RateLimitPerDay,
 		LastRateLimitReset: 0,
+		ChannelTag:         token.ChannelTag,
+		TotalUsageLimit:    token.TotalUsageLimit,
 	}
 	err = cleanToken.Insert()
 	if err != nil {
@@ -200,6 +225,8 @@ func UpdateToken(c *gin.Context) {
 		cleanToken.Group = token.Group
 		cleanToken.RateLimitPerMinute = token.RateLimitPerMinute
 		cleanToken.RateLimitPerDay = token.RateLimitPerDay
+		cleanToken.ChannelTag = token.ChannelTag
+		cleanToken.TotalUsageLimit = token.TotalUsageLimit
 	}
 	err = cleanToken.Update()
 	if err != nil {

+ 1 - 1
i18n/zh-cn.json

@@ -522,7 +522,7 @@
   "消息已更新": "消息已更新",
   "加载关于内容失败...": "加载关于内容失败...",
   "可在设置页面设置关于内容,支持 HTML & Markdown": "可在设置页面设置关于内容,支持 HTML & Markdown",
-  "New API项目仓库地址:": "New API项目仓库地址:",
+  "MIX API项目仓库地址:": "MIX API项目仓库地址:",
   "| 基于": "| 基于",
   "本项目根据": "本项目根据",
   "MIT许可证": "MIT许可证",

+ 7 - 0
middleware/auth.go

@@ -5,6 +5,7 @@ import (
 	"log"
 	"net/http"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/model"
 	"strconv"
 	"strings"
@@ -292,6 +293,12 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
 	}
 	c.Set("allow_ips", token.GetIpLimitsMap())
 	c.Set("token_group", token.Group)
+
+	// 设置令牌渠道标签到上下文中
+	if token.ChannelTag != nil && *token.ChannelTag != "" {
+		c.Set(string(constant.ContextKeyTokenChannelTag), *token.ChannelTag)
+	}
+
 	if len(parts) > 1 {
 		if model.IsAdmin(token.UserId) {
 			c.Set("specific_channel_id", parts[1])

+ 49 - 13
model/ability.go

@@ -102,7 +102,8 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
 	return channelQuery, nil
 }
 
-func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
+// 修改GetRandomSatisfiedChannel函数,添加渠道标签过滤参数
+func GetRandomSatisfiedChannel(group string, model string, retry int, channelTag *string) (*Channel, error) {
 	var abilities []Ability
 
 	var err error = nil
@@ -110,6 +111,12 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
 	if err != nil {
 		return nil, err
 	}
+
+	// 如果提供了渠道标签,则添加标签过滤条件
+	if channelTag != nil && *channelTag != "" {
+		channelQuery = channelQuery.Where("tag = ? OR tag IS NULL", *channelTag)
+	}
+
 	if common.UsingSQLite || common.UsingPostgreSQL {
 		err = channelQuery.Order("weight DESC").Find(&abilities).Error
 	} else {
@@ -118,21 +125,50 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
 	if err != nil {
 		return nil, err
 	}
+
+	// 过滤符合标签要求的渠道
+	var filteredAbilities []Ability
+	for _, ability := range abilities {
+		// 如果提供了渠道标签,只考虑匹配标签的渠道
+		if channelTag != nil && *channelTag != "" {
+			// 如果渠道标签不匹配,则跳过
+			if ability.Tag != nil && *ability.Tag != "" && *ability.Tag != *channelTag {
+				continue
+			}
+		}
+		filteredAbilities = append(filteredAbilities, ability)
+	}
+
+	// 如果没有符合条件的渠道,返回错误
+	if len(filteredAbilities) == 0 {
+		if channelTag != nil && *channelTag != "" {
+			return nil, fmt.Errorf("没有找到标签为 '%s' 的可用渠道", *channelTag)
+		}
+		return nil, errors.New("channel not found")
+	}
+
 	channel := Channel{}
-	if len(abilities) > 0 {
-		// Randomly choose one
+	if len(filteredAbilities) > 0 {
+		// Randomly choose one based on weight
 		weightSum := uint(0)
-		for _, ability_ := range abilities {
-			weightSum += ability_.Weight + 10
+		for _, ability := range filteredAbilities {
+			weightSum += ability.Weight + 10 // 平滑系数
 		}
-		// Randomly choose one
-		weight := common.GetRandomInt(int(weightSum))
-		for _, ability_ := range abilities {
-			weight -= int(ability_.Weight) + 10
-			//log.Printf("weight: %d, ability weight: %d", weight, *ability_.Weight)
-			if weight <= 0 {
-				channel.Id = ability_.ChannelId
-				break
+
+		// 如果总权重为0,则平均分配权重
+		if weightSum == 0 {
+			// 随机选择一个渠道
+			randomIndex := common.GetRandomInt(len(filteredAbilities))
+			channel.Id = filteredAbilities[randomIndex].ChannelId
+		} else {
+			// 按权重随机选择
+			randomWeight := common.GetRandomInt(int(weightSum))
+			for _, ability := range filteredAbilities {
+				randomWeight -= int(ability.Weight) + 10
+				if randomWeight < 0 {
+					channel.Id = ability.ChannelId
+					break
+				}
 			}
 		}
 	} else {

+ 131 - 2
model/channel_cache.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"math/rand"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/setting"
 	"sort"
 	"strings"
@@ -83,6 +84,13 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string,
 	var channel *Channel
 	var err error
 	selectGroup := group
+	// 获取令牌渠道标签
+	tokenChannelTag := common.GetContextKeyString(c, constant.ContextKeyTokenChannelTag)
+	var channelTag *string = nil
+	if tokenChannelTag != "" {
+		channelTag = &tokenChannelTag
+	}
+
 	if group == "auto" {
 		if len(setting.AutoGroups) == 0 {
 			return nil, selectGroup, errors.New("auto groups is not enabled")
@@ -104,7 +112,8 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string,
 			}
 		}
 	} else {
-		channel, err = getRandomSatisfiedChannel(group, model, retry)
+		// 传递channelTag参数给getRandomSatisfiedChannel
+		channel, err = getRandomSatisfiedChannelWithTag(group, model, retry, channelTag)
 		if err != nil {
 			return nil, group, err
 		}
@@ -115,6 +124,126 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string,
 	return channel, selectGroup, nil
 }
 
+// 新增带标签过滤的渠道选择函数
+func getRandomSatisfiedChannelWithTag(group string, model string, retry int, channelTag *string) (*Channel, error) {
+	if strings.HasPrefix(model, "gpt-4-gizmo") {
+		model = "gpt-4-gizmo-*"
+	}
+	if strings.HasPrefix(model, "gpt-4o-gizmo") {
+		model = "gpt-4o-gizmo-*"
+	}
+
+	// if memory cache is disabled, get channel directly from database
+	if !common.MemoryCacheEnabled {
+		return GetRandomSatisfiedChannel(group, model, retry, channelTag)
+	}
+
+	channelSyncLock.RLock()
+	defer channelSyncLock.RUnlock()
+	channels := group2model2channels[group][model]
+
+	if len(channels) == 0 {
+		return nil, errors.New("channel not found")
+	}
+
+	// 过滤符合标签要求的渠道
+	var filteredChannels []int
+	for _, channelId := range channels {
+		if channel, ok := channelsIDM[channelId]; ok {
+			// 如果没有指定标签要求,则所有渠道都符合
+			if channelTag == nil || *channelTag == "" {
+				filteredChannels = append(filteredChannels, channelId)
+			} else {
+				// 如果指定了标签要求,则只选择匹配标签的渠道
+				channelTagStr := channel.GetTag()
+				if channelTagStr == *channelTag {
+					filteredChannels = append(filteredChannels, channelId)
+				}
+			}
+		}
+	}
+
+	// 如果没有符合标签要求的渠道,返回错误
+	if len(filteredChannels) == 0 {
+		if channelTag != nil && *channelTag != "" {
+			return nil, fmt.Errorf("没有找到标签为 '%s' 的可用渠道", *channelTag)
+		}
+		return nil, errors.New("channel not found")
+	}
+
+	if len(filteredChannels) == 1 {
+		if channel, ok := channelsIDM[filteredChannels[0]]; ok {
+			return channel, nil
+		}
+		return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", filteredChannels[0])
+	}
+
+	uniquePriorities := make(map[int]bool)
+	for _, channelId := range filteredChannels {
+		if channel, ok := channelsIDM[channelId]; ok {
+			uniquePriorities[int(channel.GetPriority())] = true
+		} else {
+			return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
+		}
+	}
+	var sortedUniquePriorities []int
+	for priority := range uniquePriorities {
+		sortedUniquePriorities = append(sortedUniquePriorities, priority)
+	}
+	sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities)))
+
+	if retry >= len(uniquePriorities) {
+		retry = len(uniquePriorities) - 1
+	}
+	targetPriority := int64(sortedUniquePriorities[retry])
+
+	// get the priority for the given retry number
+	var targetChannels []*Channel
+	for _, channelId := range filteredChannels {
+		if channel, ok := channelsIDM[channelId]; ok {
+			if channel.GetPriority() == targetPriority {
+				targetChannels = append(targetChannels, channel)
+			}
+		} else {
+			return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
+		}
+	}
+
+	// 如果只有一个符合条件的渠道,直接返回
+	if len(targetChannels) == 1 {
+		return targetChannels[0], nil
+	}
+
+	// 平滑系数
+	smoothingFactor := 10
+	// Calculate the total weight of all channels up to endIdx
+	totalWeight := 0
+	for _, channel := range targetChannels {
+		totalWeight += channel.GetWeight() + smoothingFactor
+	}
+
+	// 如果总权重为0,则平均分配权重
+	if totalWeight == 0 {
+		// 随机选择一个渠道
+		randomIndex := common.GetRandomInt(len(targetChannels))
+		return targetChannels[randomIndex], nil
+	}
+
+	// Generate a random value in the range [0, totalWeight)
+	randomWeight := common.GetRandomInt(totalWeight)
+
+	// Find a channel based on its weight
+	for _, channel := range targetChannels {
+		randomWeight -= channel.GetWeight() + smoothingFactor
+		if randomWeight < 0 {
+			return channel, nil
+		}
+	}
+
+	// 如果循环结束还没有找到,则返回第一个渠道(兜底)
+	return targetChannels[0], nil
+}
+
 func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
 	if strings.HasPrefix(model, "gpt-4-gizmo") {
 		model = "gpt-4-gizmo-*"
@@ -125,7 +254,7 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
 
 	// if memory cache is disabled, get channel directly from database
 	if !common.MemoryCacheEnabled {
-		return GetRandomSatisfiedChannel(group, model, retry)
+		return GetRandomSatisfiedChannel(group, model, retry, nil)
 	}
 
 	channelSyncLock.RLock()

+ 386 - 0
model/statistics_charts.go

@@ -0,0 +1,386 @@
+package model
+
+import (
+	"one-api/common"
+	"strconv"
+	"time"
+)
+
+// ChannelStatistics 按渠道统计的数据结构
+type ChannelStatistics struct {
+	Time       string `json:"time"`
+	ChannelId  int    `json:"channel_id"`
+	ChannelName string `json:"channel_name"`
+	Quota      int    `json:"quota"`
+	Count      int    `json:"count"`
+}
+
+// TokenStatistics 按令牌统计的数据结构
+type TokenStatistics struct {
+	Time      string `json:"time"`
+	TokenName string `json:"token_name"`
+	Quota     int    `json:"quota"`
+	Count     int    `json:"count"`
+}
+
+// UserStatistics 按用户统计的数据结构
+type UserStatistics struct {
+	Time     string `json:"time"`
+	Username string `json:"username"`
+	Quota    int    `json:"quota"`
+	Count    int    `json:"count"`
+}
+
+// GetChannelStatistics 获取按渠道统计的数据
+func GetChannelStatistics(startTimestamp, endTimestamp int, username, tokenName, modelName string, channel int, group, defaultTime string) ([]ChannelStatistics, error) {
+	var statistics []ChannelStatistics
+
+	// 构建查询条件
+	tx := LOG_DB.Table("logs").Select(`
+		created_at,
+		channel_id,
+		COUNT(*) as count,
+		SUM(quota) as quota
+	`).Where("type = ?", 2) // LogTypeConsume = 2
+
+	if username != "" {
+		tx = tx.Where("username = ?", username)
+	}
+	if tokenName != "" {
+		tx = tx.Where("token_name = ?", tokenName)
+	}
+	if modelName != "" {
+		tx = tx.Where("model_name LIKE ?", "%"+modelName+"%")
+	}
+	if channel != 0 {
+		tx = tx.Where("channel_id = ?", channel)
+	}
+	if group != "" {
+		tx = tx.Where("`group` = ?", group)
+	}
+	if startTimestamp != 0 {
+		tx = tx.Where("created_at >= ?", startTimestamp)
+	}
+	if endTimestamp != 0 {
+		tx = tx.Where("created_at <= ?", endTimestamp)
+	}
+
+	// 按时间粒度分组 - 使用数据库无关的函数
+	var groupBy string
+	switch defaultTime {
+	case "hour":
+		if common.UsingMySQL {
+			groupBy = "channel_id, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d %H:00:00')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "channel_id, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-MM-DD HH24:00:00')"
+		} else {
+			// SQLite
+			groupBy = "channel_id, strftime('%Y-%m-%d %H:00:00', datetime(created_at, 'unixepoch'))"
+		}
+	case "day":
+		if common.UsingMySQL {
+			groupBy = "channel_id, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "channel_id, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-MM-DD')"
+		} else {
+			// SQLite
+			groupBy = "channel_id, strftime('%Y-%m-%d', datetime(created_at, 'unixepoch'))"
+		}
+	case "week":
+		if common.UsingMySQL {
+			groupBy = "channel_id, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%U')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "channel_id, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-IW')"
+		} else {
+			// SQLite
+			groupBy = "channel_id, strftime('%Y-%W', datetime(created_at, 'unixepoch'))"
+		}
+	default:
+		if common.UsingMySQL {
+			groupBy = "channel_id, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "channel_id, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-MM-DD')"
+		} else {
+			// SQLite
+			groupBy = "channel_id, strftime('%Y-%m-%d', datetime(created_at, 'unixepoch'))"
+		}
+	}
+
+	// 按渠道和时间分组
+	tx = tx.Group(groupBy).Order("MIN(created_at) ASC")
+
+	// 执行查询
+	var results []struct {
+		CreatedAt  int64 `json:"created_at"`
+		ChannelId  int   `json:"channel_id"`
+		Count      int   `json:"count"`
+		Quota      int   `json:"quota"`
+	}
+
+	err := tx.Scan(&results).Error
+	if err != nil {
+		return nil, err
+	}
+
+	// 获取渠道名称映射
+	channelIds := make([]int, 0)
+	channelMap := make(map[int]string)
+	for _, result := range results {
+		if result.ChannelId != 0 {
+			channelIds = append(channelIds, result.ChannelId)
+		}
+	}
+
+	if len(channelIds) > 0 {
+		var channels []struct {
+			Id   int    `gorm:"column:id"`
+			Name string `gorm:"column:name"`
+		}
+		if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds).Find(&channels).Error; err != nil {
+			return nil, err
+		}
+		for _, channel := range channels {
+			channelMap[channel.Id] = channel.Name
+		}
+	}
+
+	// 格式化时间并构建返回结果
+	for _, result := range results {
+		timeStr := formatTime(result.CreatedAt, defaultTime)
+		statistics = append(statistics, ChannelStatistics{
+			Time:        timeStr,
+			ChannelId:   result.ChannelId,
+			ChannelName: channelMap[result.ChannelId],
+			Quota:       result.Quota,
+			Count:       result.Count,
+		})
+	}
+
+	return statistics, nil
+}
+
+// GetTokenStatistics 获取按令牌统计的数据
+func GetTokenStatistics(startTimestamp, endTimestamp int, username, tokenName, modelName string, channel int, group, defaultTime string) ([]TokenStatistics, error) {
+	var statistics []TokenStatistics
+
+	// 构建查询条件
+	tx := LOG_DB.Table("logs").Select(`
+		created_at,
+		token_name,
+		COUNT(*) as count,
+		SUM(quota) as quota
+	`).Where("type = ?", 2) // LogTypeConsume = 2
+
+	if username != "" {
+		tx = tx.Where("username = ?", username)
+	}
+	if tokenName != "" {
+		tx = tx.Where("token_name = ?", tokenName)
+	}
+	if modelName != "" {
+		tx = tx.Where("model_name LIKE ?", "%"+modelName+"%")
+	}
+	if channel != 0 {
+		tx = tx.Where("channel_id = ?", channel)
+	}
+	if group != "" {
+		tx = tx.Where("`group` = ?", group)
+	}
+	if startTimestamp != 0 {
+		tx = tx.Where("created_at >= ?", startTimestamp)
+	}
+	if endTimestamp != 0 {
+		tx = tx.Where("created_at <= ?", endTimestamp)
+	}
+
+	// 按时间粒度分组 - 使用数据库无关的函数
+	var groupBy string
+	switch defaultTime {
+	case "hour":
+		if common.UsingMySQL {
+			groupBy = "token_name, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d %H:00:00')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "token_name, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-MM-DD HH24:00:00')"
+		} else {
+			// SQLite
+			groupBy = "token_name, strftime('%Y-%m-%d %H:00:00', datetime(created_at, 'unixepoch'))"
+		}
+	case "day":
+		if common.UsingMySQL {
+			groupBy = "token_name, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "token_name, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-MM-DD')"
+		} else {
+			// SQLite
+			groupBy = "token_name, strftime('%Y-%m-%d', datetime(created_at, 'unixepoch'))"
+		}
+	case "week":
+		if common.UsingMySQL {
+			groupBy = "token_name, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%U')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "token_name, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-IW')"
+		} else {
+			// SQLite
+			groupBy = "token_name, strftime('%Y-%W', datetime(created_at, 'unixepoch'))"
+		}
+	default:
+		if common.UsingMySQL {
+			groupBy = "token_name, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "token_name, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-MM-DD')"
+		} else {
+			// SQLite
+			groupBy = "token_name, strftime('%Y-%m-%d', datetime(created_at, 'unixepoch'))"
+		}
+	}
+
+	// 按令牌和时间分组
+	tx = tx.Group(groupBy).Order("MIN(created_at) ASC")
+
+	// 执行查询
+	var results []struct {
+		CreatedAt int64  `json:"created_at"`
+		TokenName string `json:"token_name"`
+		Count     int    `json:"count"`
+		Quota     int    `json:"quota"`
+	}
+
+	err := tx.Scan(&results).Error
+	if err != nil {
+		return nil, err
+	}
+
+	// 格式化时间并构建返回结果
+	for _, result := range results {
+		timeStr := formatTime(result.CreatedAt, defaultTime)
+		statistics = append(statistics, TokenStatistics{
+			Time:      timeStr,
+			TokenName: result.TokenName,
+			Quota:     result.Quota,
+			Count:     result.Count,
+		})
+	}
+
+	return statistics, nil
+}
+
+// GetUserStatistics 获取按用户统计的数据
+func GetUserStatistics(startTimestamp, endTimestamp int, username, tokenName, modelName string, channel int, group, defaultTime string) ([]UserStatistics, error) {
+	var statistics []UserStatistics
+
+	// 构建查询条件
+	tx := LOG_DB.Table("logs").Select(`
+		created_at,
+		username,
+		COUNT(*) as count,
+		SUM(quota) as quota
+	`).Where("type = ?", 2) // LogTypeConsume = 2
+
+	if username != "" {
+		tx = tx.Where("username = ?", username)
+	}
+	if tokenName != "" {
+		tx = tx.Where("token_name = ?", tokenName)
+	}
+	if modelName != "" {
+		tx = tx.Where("model_name LIKE ?", "%"+modelName+"%")
+	}
+	if channel != 0 {
+		tx = tx.Where("channel_id = ?", channel)
+	}
+	if group != "" {
+		tx = tx.Where("`group` = ?", group)
+	}
+	if startTimestamp != 0 {
+		tx = tx.Where("created_at >= ?", startTimestamp)
+	}
+	if endTimestamp != 0 {
+		tx = tx.Where("created_at <= ?", endTimestamp)
+	}
+
+	// 按时间粒度分组 - 使用数据库无关的函数
+	var groupBy string
+	switch defaultTime {
+	case "hour":
+		if common.UsingMySQL {
+			groupBy = "username, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d %H:00:00')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "username, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-MM-DD HH24:00:00')"
+		} else {
+			// SQLite
+			groupBy = "username, strftime('%Y-%m-%d %H:00:00', datetime(created_at, 'unixepoch'))"
+		}
+	case "day":
+		if common.UsingMySQL {
+			groupBy = "username, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "username, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-MM-DD')"
+		} else {
+			// SQLite
+			groupBy = "username, strftime('%Y-%m-%d', datetime(created_at, 'unixepoch'))"
+		}
+	case "week":
+		if common.UsingMySQL {
+			groupBy = "username, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%U')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "username, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-IW')"
+		} else {
+			// SQLite
+			groupBy = "username, strftime('%Y-%W', datetime(created_at, 'unixepoch'))"
+		}
+	default:
+		if common.UsingMySQL {
+			groupBy = "username, DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d')"
+		} else if common.UsingPostgreSQL {
+			groupBy = "username, TO_CHAR(TO_TIMESTAMP(created_at), 'YYYY-MM-DD')"
+		} else {
+			// SQLite
+			groupBy = "username, strftime('%Y-%m-%d', datetime(created_at, 'unixepoch'))"
+		}
+	}
+
+	// 按用户和时间分组
+	tx = tx.Group(groupBy).Order("MIN(created_at) ASC")
+
+	// 执行查询
+	var results []struct {
+		CreatedAt int64  `json:"created_at"`
+		Username  string `json:"username"`
+		Count     int    `json:"count"`
+		Quota     int    `json:"quota"`
+	}
+
+	err := tx.Scan(&results).Error
+	if err != nil {
+		return nil, err
+	}
+
+	// 格式化时间并构建返回结果
+	for _, result := range results {
+		timeStr := formatTime(result.CreatedAt, defaultTime)
+		statistics = append(statistics, UserStatistics{
+			Time:     timeStr,
+			Username: result.Username,
+			Quota:    result.Quota,
+			Count:    result.Count,
+		})
+	}
+
+	return statistics, nil
+}
+
+// formatTime 根据时间粒度格式化时间
+func formatTime(timestamp int64, defaultTime string) string {
+	t := time.Unix(timestamp, 0)
+	switch defaultTime {
+	case "hour":
+		return t.Format("2006-01-02 15:00")
+	case "day":
+		return t.Format("2006-01-02")
+	case "week":
+		_, week := t.ISOWeek()
+		return t.Format("2006-01") + "-W" + strconv.Itoa(week)
+	default:
+		return t.Format("2006-01-02")
+	}
+}

+ 10 - 1
model/token.go

@@ -34,6 +34,8 @@ type Token struct {
 	RateLimitPerMinute int            `json:"rate_limit_per_minute" gorm:"default:0"` // 每分钟访问次数限制,0表示不限制
 	RateLimitPerDay    int            `json:"rate_limit_per_day" gorm:"default:0"`    // 每日访问次数限制,0表示不限制
 	LastRateLimitReset int64          `json:"last_rate_limit_reset" gorm:"default:0"` // 最后重置时间戳
+	ChannelTag         *string        `json:"channel_tag" gorm:"default:''"`          // 渠道标签限制
+	TotalUsageLimit    *int           `json:"total_usage_limit" gorm:"default:null"`  // 总使用次数限制,nil表示不限制
 	DeletedAt          gorm.DeletedAt `gorm:"index"`
 }
 
@@ -119,6 +121,13 @@ func ValidateUserToken(key string) (token *Token, err error) {
 			return token, errors.New(fmt.Sprintf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota))
 		}
 
+		// 检查总使用次数限制
+		if token.TotalUsageLimit != nil && *token.TotalUsageLimit > 0 && token.TotalUsageCount >= *token.TotalUsageLimit {
+			keyPrefix := key[:3]
+			keySuffix := key[len(key)-3:]
+			return token, errors.New(fmt.Sprintf("[sk-%s***%s] 该令牌总使用次数已用完,限制次数: %d,已使用次数: %d", keyPrefix, keySuffix, *token.TotalUsageLimit, token.TotalUsageCount))
+		}
+
 		// 检查访问频率限制
 		if err := CheckRateLimit(token); err != nil {
 			return token, err
@@ -200,7 +209,7 @@ func (token *Token) Update() (err error) {
 	}()
 	err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota",
 		"model_limits_enabled", "model_limits", "allow_ips", "group", "daily_usage_count", "total_usage_count", "last_usage_date",
-		"rate_limit_per_minute", "rate_limit_per_day", "last_rate_limit_reset").Updates(token).Error
+		"rate_limit_per_minute", "rate_limit_per_day", "last_rate_limit_reset", "channel_tag", "total_usage_limit").Updates(token).Error
 	return err
 }
 

BIN
release/new-api-linux-amd64


BIN
release/new-api-linux-arm64


+ 10 - 1
router/api-router.go

@@ -126,6 +126,7 @@ func SetApiRouter(router *gin.Engine) {
 		{
 			tokenRoute.GET("/", controller.GetAllTokens)
 			tokenRoute.GET("/search", controller.SearchTokens)
+			tokenRoute.GET("/tags", controller.GetTokenTags)
 			tokenRoute.GET("/:id", controller.GetToken)
 			tokenRoute.POST("/", controller.AddToken)
 			tokenRoute.PUT("/", controller.UpdateToken)
@@ -185,7 +186,7 @@ func SetApiRouter(router *gin.Engine) {
 			usageStatsRoute.GET("/summary", middleware.AdminAuth(), controller.GetUsageStatisticsSummary)
 			usageStatsRoute.GET("/self", middleware.UserAuth(), controller.GetUserUsageStatistics)
 		}
-		
+
 		// 月度用量统计路由
 		monthlyUsageStatsRoute := apiRouter.Group("/usage_statistics_monthly")
 		{
@@ -193,5 +194,13 @@ func SetApiRouter(router *gin.Engine) {
 			monthlyUsageStatsRoute.GET("/summary", middleware.AdminAuth(), controller.GetMonthlyUsageStatisticsSummary)
 			monthlyUsageStatsRoute.GET("/self", middleware.UserAuth(), controller.GetUserMonthlyUsageStatistics)
 		}
+
+		// 统计图表路由
+		statisticsRoute := apiRouter.Group("/statistics")
+		{
+			statisticsRoute.GET("/channel", middleware.AdminAuth(), controller.GetChannelStatistics)
+			statisticsRoute.GET("/token", middleware.AdminAuth(), controller.GetTokenStatistics)
+			statisticsRoute.GET("/user", middleware.AdminAuth(), controller.GetUserStatistics)
+		}
 	}
 }

+ 3 - 1
web/index.html

@@ -9,7 +9,9 @@
       name="description"
       content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
     />
-    <title>New API</title>
+ 
+    <title>Mix API</title>
+
   </head>
   <body>
     <noscript>You need to enable JavaScript to run this app.</noscript>

BIN
web/public/favicon.ico


BIN
web/public/logo.png


+ 4 - 6
web/src/components/layout/Footer.js

@@ -66,7 +66,7 @@ const FooterBar = () => {
             </div>
 
             <div className="text-left">
-              <p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
+              <p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于MIX API的项目')}</p>
               <div className="flex flex-col gap-4">
                 <a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">new-api-horizon</a>
                 {/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
@@ -82,11 +82,9 @@ const FooterBar = () => {
         </div>
 
         <div className="text-sm">
-          <span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
-          <a href="https://github.com/QuantumNous/new-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">New API</a>
-          <span className="!text-semi-color-text-1"> & </span>
-          <a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">One API</a>
-        </div>
+          <span className="!text-semi-color-text-1"> </span>
+          <a href="https://github.com/aiprodcoder/MIXAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">MIX API</a>
+            </div>
       </div>
     </footer>
   ), [logo, systemName, t, currentYear, isDemoSiteMode]);

+ 2 - 0
web/src/helpers/utils.js

@@ -12,8 +12,10 @@ const HTMLToastContent = ({ htmlContent }) => {
 export default HTMLToastContent;
 export function isAdmin() {
   let user = localStorage.getItem('user');
+  console.log("===user info==",user)
   if (!user) return false;
   user = JSON.parse(user);
+  console.log("===user.role==",user.role,user.role >= 10)
   return user.role >= 10;
 }
 

+ 2 - 2
web/src/i18n/locales/en.json

@@ -937,7 +937,7 @@
   "模型限制": "Model restrictions",
   "秒": "Second",
   "更新令牌后需等待几分钟生效": "It will take a few minutes to take effect after updating the token.",
-  "一小时": "One hour",
+  "一周": "One Week",
   "新建数量": "New quantity",
   "未设置": "Not set",
   "API文档": "API documentation",
@@ -1454,7 +1454,7 @@
   "设计与开发由": "Designed & Developed with love by",
   "演示站点": "Demo Site",
   "页面未找到,请检查您的浏览器地址是否正确": "Page not found, please check if your browser address is correct",
-  "New API项目仓库地址:": "New API project repository address: ",
+  "MIX API项目仓库地址:": "MIX API project repository address: ",
   "© {{currentYear}}": "© {{currentYear}}",
   "| 基于": " | Based on ",
   "MIT许可证": "MIT License",

+ 1 - 1
web/src/index.js

@@ -13,7 +13,7 @@ import './index.css';
 // 欢迎信息(二次开发者不准将此移除)
 // Welcome message (Secondary developers are not allowed to remove this)
 if (typeof window !== 'undefined') {
-  console.log('%cWe ❤ NewAPI%c Github: https://github.com/QuantumNous/new-api',
+  console.log('%cWe ❤ MIXAPI%c Github: https://github.com/aiprodcoder/MIXAPI',
     'color: #10b981; font-weight: bold; font-size: 24px;',
     'color: inherit; font-size: 14px;');
 }

+ 12 - 12
web/src/pages/About/index.js

@@ -40,44 +40,44 @@ const About = () => {
   const customDescription = (
     <div style={{ textAlign: 'center' }}>
       <p>{t('可在设置页面设置关于内容,支持 HTML & Markdown')}</p>
-      {t('New API项目仓库地址:')}
+      {t('MIX API项目仓库地址:')}
       <a
-        href='https://github.com/QuantumNous/new-api'
+        href='https://github.com/aiprodcoder/MIXAPI'
         target="_blank"
         rel="noopener noreferrer"
         className="!text-semi-color-primary"
       >
-        https://github.com/QuantumNous/new-api
+        https://github.com/aiprodcoder/MIXAPI
       </a>
       <p>
         <a
-          href="https://github.com/QuantumNous/new-api"
+          href="https://github.com/aiprodcoder/MIXAPI"
           target="_blank"
           rel="noopener noreferrer"
           className="!text-semi-color-primary"
         >
-          NewAPI
+          MIXAPI
         </a> {t('© {{currentYear}}', { currentYear })} <a
-          href="https://github.com/QuantumNous"
+          href="https://github.com/aiprodcoder"
           target="_blank"
           rel="noopener noreferrer"
           className="!text-semi-color-primary"
         >
-          QuantumNous
+          AiProdCoder
         </a> {t('| 基于')} <a
           href="https://github.com/songquanpeng/one-api/releases/tag/v0.5.4"
           target="_blank"
           rel="noopener noreferrer"
           className="!text-semi-color-primary"
         >
-          One API v0.5.4
-        </a> © 2023 <a
-          href="https://github.com/songquanpeng"
+          New API  
+        </a>  <a
+          href="https://github.com/QuantumNous/new-api"
           target="_blank"
           rel="noopener noreferrer"
           className="!text-semi-color-primary"
         >
-          JustSong
+          New API
         </a>
       </p>
       <p>
@@ -92,7 +92,7 @@ const About = () => {
         </a>
         {t('授权,需在遵守')}
         <a
-          href="https://github.com/QuantumNous/new-api/blob/main/LICENSE"
+          href="https://github.com/aiprodcoder/MIXAPI/blob/main/LICENSE"
           target="_blank"
           rel="noopener noreferrer"
           className="!text-semi-color-primary"

+ 1 - 1
web/src/pages/Channel/EditChannel.js

@@ -1443,7 +1443,7 @@ const EditChannel = (props) => {
                         </Text>
                         <Text
                           className="!text-semi-color-primary cursor-pointer"
-                          onClick={() => window.open('https://github.com/QuantumNous/new-api/blob/main/docs/channel/other_setting.md')}
+                          onClick={() => window.open('https://github.com/aiprodcoder/MIXAPI/blob/main/docs/channel/other_setting.md')}
                         >
                           {t('设置说明')}
                         </Text>

+ 326 - 2
web/src/pages/Detail/index.js

@@ -219,6 +219,117 @@ const Detail = (props) => {
     tpm: []
   });
 
+  // ========== Additional State for new charts ==========
+  const [channelData, setChannelData] = useState([]);
+  const [tokenData, setTokenData] = useState([]);
+  const [userData, setUserData] = useState([]);
+
+  // ========== Additional Chart Specs State ==========
+  const [spec_channel_line, setSpecChannelLine] = useState({
+    type: 'line',
+    data: [
+      {
+        id: 'channelData',
+        values: [],
+      },
+    ],
+    xField: 'Time',
+    yField: 'Count',
+    seriesField: 'Channel',
+    legends: {
+      visible: true,
+      selectMode: 'single',
+    },
+    title: {
+      visible: true,
+      text: t('按渠道调用次数统计'),
+      subtext: '',
+    },
+    tooltip: {
+      mark: {
+        content: [
+          {
+            key: (datum) => datum['Channel'],
+            value: (datum) => renderNumber(datum['Count']),
+          },
+        ],
+      },
+    },
+    color: {
+      specified: modelColorMap,
+    },
+  });
+
+  const [spec_token_line, setSpecTokenLine] = useState({
+    type: 'line',
+    data: [
+      {
+        id: 'tokenData',
+        values: [],
+      },
+    ],
+    xField: 'Time',
+    yField: 'Count',
+    seriesField: 'Token',
+    legends: {
+      visible: true,
+      selectMode: 'single',
+    },
+    title: {
+      visible: true,
+      text: t('按令牌调用次数统计'),
+      subtext: '',
+    },
+    tooltip: {
+      mark: {
+        content: [
+          {
+            key: (datum) => datum['Token'],
+            value: (datum) => renderNumber(datum['Count']),
+          },
+        ],
+      },
+    },
+    color: {
+      specified: modelColorMap,
+    },
+  });
+
+  const [spec_user_line, setSpecUserLine] = useState({
+    type: 'line',
+    data: [
+      {
+        id: 'userData',
+        values: [],
+      },
+    ],
+    xField: 'Time',
+    yField: 'Count',
+    seriesField: 'User',
+    legends: {
+      visible: true,
+      selectMode: 'single',
+    },
+    title: {
+      visible: true,
+      text: t('按用户调用次数统计'),
+      subtext: '',
+    },
+    tooltip: {
+      mark: {
+        content: [
+          {
+            key: (datum) => datum['User'],
+            value: (datum) => renderNumber(datum['Count']),
+          },
+        ],
+      },
+    },
+    color: {
+      specified: modelColorMap,
+    },
+  });
+
   // ========== Additional Refs for new cards ==========
   const announcementScrollRef = useRef(null);
   const faqScrollRef = useRef(null);
@@ -667,6 +778,80 @@ const Detail = (props) => {
     }
   }, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]);
 
+  // ========== New Functions for Loading Statistics Data ==========
+  const loadChannelData = useCallback(async () => {
+    if (!isAdminUser) return;
+
+    try {
+      let localStartTimestamp = Date.parse(start_timestamp) / 1000;
+      let localEndTimestamp = Date.parse(end_timestamp) / 1000;
+      const url = `/api/statistics/channel?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
+
+      const res = await API.get(url);
+      const { success, message, data } = res.data;
+      if (success) {
+        setChannelData(data || []);
+        updateChannelChartData(data || []);
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      console.error('Error loading channel data:', error);
+    }
+  }, [start_timestamp, end_timestamp, dataExportDefaultTime, isAdminUser]);
+
+  const loadTokenData = useCallback(async () => {
+    if (!isAdminUser) return;
+
+    try {
+      let localStartTimestamp = Date.parse(start_timestamp) / 1000;
+      let localEndTimestamp = Date.parse(end_timestamp) / 1000;
+      const url = `/api/statistics/token?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
+
+      const res = await API.get(url);
+      const { success, message, data } = res.data;
+      if (success) {
+        setTokenData(data || []);
+        updateTokenChartData(data || []);
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      console.error('Error loading token data:', error);
+    }
+  }, [start_timestamp, end_timestamp, dataExportDefaultTime, isAdminUser]);
+
+  const loadUserData = useCallback(async () => {
+    if (!isAdminUser) return;
+
+    try {
+      let localStartTimestamp = Date.parse(start_timestamp) / 1000;
+      let localEndTimestamp = Date.parse(end_timestamp) / 1000;
+      const url = `/api/statistics/user?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
+
+      const res = await API.get(url);
+      const { success, message, data } = res.data;
+      if (success) {
+        setUserData(data || []);
+        updateUserChartData(data || []);
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      console.error('Error loading user data:', error);
+    }
+  }, [start_timestamp, end_timestamp, dataExportDefaultTime, isAdminUser]);
+
+  const loadAllStatisticsData = useCallback(async () => {
+    if (!isAdminUser) return;
+
+    await Promise.all([
+      loadChannelData(),
+      loadTokenData(),
+      loadUserData()
+    ]);
+  }, [loadChannelData, loadTokenData, loadUserData, isAdminUser]);
+
   const loadUptimeData = useCallback(async () => {
     setUptimeLoading(true);
     try {
@@ -689,7 +874,9 @@ const Detail = (props) => {
 
   const refresh = useCallback(async () => {
     await Promise.all([loadQuotaData(), loadUptimeData()]);
-  }, [loadQuotaData, loadUptimeData]);
+    // 加载新的统计数据
+    await loadAllStatisticsData();
+  }, [loadQuotaData, loadUptimeData, loadAllStatisticsData]);
 
   const handleSearchConfirm = useCallback(() => {
     refresh();
@@ -699,7 +886,9 @@ const Detail = (props) => {
   const initChart = useCallback(async () => {
     await loadQuotaData();
     await loadUptimeData();
-  }, [loadQuotaData, loadUptimeData]);
+    // 初始化新的统计数据
+    await loadAllStatisticsData();
+  }, [loadQuotaData, loadUptimeData, loadAllStatisticsData]);
 
   const showSearchModal = useCallback(() => {
     setSearchModalVisible(true);
@@ -999,6 +1188,100 @@ const Detail = (props) => {
     generateChartTimePoints, updateChartSpec, updateMapValue, t
   ]);
 
+  // ========== New Functions for Updating Chart Data ==========
+  const updateChannelChartData = useCallback((data) => {
+    if (!data || data.length === 0) return;
+
+    // 按渠道和时间分组数据
+    const groupedData = {};
+    data.forEach(item => {
+      const key = `${item.time}-${item.channel_name || 'Channel ' + item.channel_id}`;
+      if (!groupedData[key]) {
+        groupedData[key] = {
+          Time: item.time,
+          Channel: item.channel_name || 'Channel ' + item.channel_id,
+          Count: 0
+        };
+      }
+      groupedData[key].Count += item.count;
+    });
+
+    // 转换为数组并排序
+    const chartData = Object.values(groupedData);
+    chartData.sort((a, b) => a.Time.localeCompare(b.Time));
+
+    // 更新图表规格
+    updateChartSpec(
+      setSpecChannelLine,
+      chartData,
+      `${t('总计')}:${chartData.reduce((sum, item) => sum + item.Count, 0)}`,
+      modelColorMap,
+      'channelData'
+    );
+  }, [updateChartSpec, t]);
+
+  const updateTokenChartData = useCallback((data) => {
+    if (!data || data.length === 0) return;
+
+    // 按令牌和时间分组数据
+    const groupedData = {};
+    data.forEach(item => {
+      const key = `${item.time}-${item.token_name}`;
+      if (!groupedData[key]) {
+        groupedData[key] = {
+          Time: item.time,
+          Token: item.token_name,
+          Count: 0
+        };
+      }
+      groupedData[key].Count += item.count;
+    });
+
+    // 转换为数组并排序
+    const chartData = Object.values(groupedData);
+    chartData.sort((a, b) => a.Time.localeCompare(b.Time));
+
+    // 更新图表规格
+    updateChartSpec(
+      setSpecTokenLine,
+      chartData,
+      `${t('总计')}:${chartData.reduce((sum, item) => sum + item.Count, 0)}`,
+      modelColorMap,
+      'tokenData'
+    );
+  }, [updateChartSpec, t]);
+
+  const updateUserChartData = useCallback((data) => {
+    if (!data || data.length === 0) return;
+
+    // 按用户和时间分组数据
+    const groupedData = {};
+    data.forEach(item => {
+      const key = `${item.time}-${item.username}`;
+      if (!groupedData[key]) {
+        groupedData[key] = {
+          Time: item.time,
+          User: item.username,
+          Count: 0
+        };
+      }
+      groupedData[key].Count += item.count;
+    });
+
+    // 转换为数组并排序
+    const chartData = Object.values(groupedData);
+    chartData.sort((a, b) => a.Time.localeCompare(b.Time));
+
+    // 更新图表规格
+    updateChartSpec(
+      setSpecUserLine,
+      chartData,
+      `${t('总计')}:${chartData.reduce((sum, item) => sum + item.Count, 0)}`,
+      modelColorMap,
+      'userData'
+    );
+  }, [updateChartSpec, t]);
+
   // ========== Status Data Management ==========
   const announcementLegendData = useMemo(() => [
     { color: 'grey', label: t('默认'), type: 'default' },
@@ -1296,6 +1579,29 @@ const Detail = (props) => {
                       {t('调用次数排行')}
                     </span>
                   } itemKey="4" />
+                  {isAdminUser && (
+
+
+                    <TabPane tab={
+                      <span>
+                        <IconHistogram />
+                        {t('按渠道统计')}
+                      </span>
+                    } itemKey="5" />)}
+                  {isAdminUser && (<TabPane tab={
+                    <span>
+                      <IconHistogram />
+                      {t('按令牌统计')}
+                    </span>
+                  } itemKey="6" />)}
+                  {isAdminUser && (<TabPane tab={
+                    <span>
+                      <IconHistogram />
+                      {t('按用户统计')}
+                    </span>
+                  } itemKey="7" />
+
+                  )}
                 </Tabs>
               </div>
             }
@@ -1326,6 +1632,24 @@ const Detail = (props) => {
                   option={CHART_CONFIG}
                 />
               )}
+              {activeChartTab === '5' && isAdminUser && (
+                <VChart
+                  spec={spec_channel_line}
+                  option={CHART_CONFIG}
+                />
+              )}
+              {activeChartTab === '6' && isAdminUser && (
+                <VChart
+                  spec={spec_token_line}
+                  option={CHART_CONFIG}
+                />
+              )}
+              {activeChartTab === '7' && isAdminUser && (
+                <VChart
+                  spec={spec_user_line}
+                  option={CHART_CONFIG}
+                />
+              )}
             </div>
           </Card>
 

+ 1 - 1
web/src/pages/Home/index.js

@@ -171,7 +171,7 @@ const Home = () => {
                       size={isMobile ? "default" : "large"}
                       className="flex items-center !rounded-3xl px-6 py-2"
                       icon={<IconGithubLogo />}
-                      onClick={() => window.open('https://github.com/QuantumNous/new-api', '_blank')}
+                      onClick={() => window.open('https://github.com/aiprodcoder/MIXAPI', '_blank')}
                     >
                       {statusState.status.version}
                     </Button>

+ 58 - 8
web/src/pages/Token/EditToken.js

@@ -43,6 +43,7 @@ const EditToken = (props) => {
   const formApiRef = useRef(null);
   const [models, setModels] = useState([]);
   const [groups, setGroups] = useState([]);
+  const [channelTags, setChannelTags] = useState([]); // 添加渠道标签状态
   const isEdit = props.editingToken.id !== undefined;
 
   const getInitValues = () => ({
@@ -57,6 +58,8 @@ const EditToken = (props) => {
     tokenCount: 1,
     rate_limit_per_minute: 0,
     rate_limit_per_day: 0,
+    channel_tag: null,
+    total_usage_limit: null, // 添加总使用次数限制初始值
   });
 
   const handleCancel = () => {
@@ -79,6 +82,26 @@ const EditToken = (props) => {
     }
   };
 
+  // 加载渠道标签列表
+  const loadChannelTags = async () => {
+    try {
+      let res = await API.get(`/api/token/tags`);
+      const { success, message, data } = res.data;
+      if (success) {
+        // 转换为选项格式
+        const tagOptions = data.map(tag => ({
+          label: tag,
+          value: tag
+        }));
+        setChannelTags(tagOptions);
+      } else {
+        showError(t(message));
+      }
+    } catch (error) {
+      showError(t('获取渠道标签失败'));
+    }
+  };
+
   const loadModels = async () => {
     let res = await API.get(`/api/user/models`);
     const { success, message, data } = res.data;
@@ -163,6 +186,7 @@ const EditToken = (props) => {
     }
     loadModels();
     loadGroups();
+    loadChannelTags(); // 加载渠道标签
   }, [props.editingToken.id]);
 
   useEffect(() => {
@@ -403,20 +427,20 @@ const EditToken = (props) => {
                         >
                           {t('一个月')}
                         </Button>
-                        <Button
+                       <Button
                           theme='light'
                           type='tertiary'
-                          onClick={() => setExpiredTime(0, 1, 0, 0)}
+                          onClick={() => setExpiredTime(0, 7,0, 0)}
                         >
-                          {t('一天')}
-                        </Button>
-                        <Button
+                          {t('一周')}
+                        </Button>   <Button
                           theme='light'
                           type='tertiary'
-                          onClick={() => setExpiredTime(0, 0, 1, 0)}
+                          onClick={() => setExpiredTime(0, 1, 0, 0)}
                         >
-                          {t('一小时')}
+                          {t('一')}
                         </Button>
+                      
                       </Space>
                     </Form.Slot>
                   </Col>
@@ -535,6 +559,20 @@ const EditToken = (props) => {
                       style={{ width: '100%' }}
                     />
                   </Col>
+                  {/* 添加渠道标签选择 */}
+                  <Col span={24}>
+                    <Form.Select
+                      field='channel_tag'
+                      label={t('渠道组标签')}
+                      placeholder={t('请选择渠道组标签,留空则不限制')}
+                      optionList={channelTags}
+                      extraText={t('设置后,此令牌只能使用指定标签下的渠道')}
+                      filter
+                      searchPosition='dropdown'
+                      showClear
+                      style={{ width: '100%' }}
+                    />
+                  </Col>
                   <Col span={12}>
                     <Form.InputNumber
                       field='rate_limit_per_minute'
@@ -557,6 +595,18 @@ const EditToken = (props) => {
                       style={{ width: '100%' }}
                     />
                   </Col>
+                  {/* 添加总使用次数限制输入框 */}
+                  <Col span={24}>
+                    <Form.InputNumber
+                      field='total_usage_limit'
+                      label={t('总使用次数限制')}
+                      placeholder={t('留空表示不限制')}
+                      min={0}
+                      step={1}
+                      extraText={t('限制令牌总使用次数,0或留空表示不限制')}
+                      style={{ width: '100%' }}
+                    />
+                  </Col>
                 </Row>
               </Card>
             </div>
@@ -567,4 +617,4 @@ const EditToken = (props) => {
   );
 };
 
-export default EditToken;
+export default EditToken;