Merge branch 'token' into ai-hub

This commit is contained in:
chenchun
2025-12-04 16:35:17 +08:00
28 changed files with 2921 additions and 688 deletions

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
/// <summary>
/// 创建Token输入
/// </summary>
public class TokenCreateInput
{
/// <summary>
/// 名称(同一用户不能重复)
/// </summary>
[Required(ErrorMessage = "名称不能为空")]
[StringLength(100, ErrorMessage = "名称长度不能超过100个字符")]
public string Name { get; set; }
/// <summary>
/// 过期时间(空为永不过期)
/// </summary>
public DateTime? ExpireTime { get; set; }
/// <summary>
/// 尊享包额度限制(空为不限制)
/// </summary>
public long? PremiumQuotaLimit { get; set; }
}

View File

@@ -0,0 +1,47 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
/// <summary>
/// Token列表输出
/// </summary>
public class TokenGetListOutputDto
{
/// <summary>
/// Token Id
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// Token密钥
/// </summary>
public string ApiKey { get; set; }
/// <summary>
/// 过期时间(空为永不过期)
/// </summary>
public DateTime? ExpireTime { get; set; }
/// <summary>
/// 尊享包额度限制(空为不限制)
/// </summary>
public long? PremiumQuotaLimit { get; set; }
/// <summary>
/// 尊享包已使用额度
/// </summary>
public long PremiumUsedQuota { get; set; }
/// <summary>
/// 是否禁用
/// </summary>
public bool IsDisabled { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreationTime { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
public class TokenSelectListOutputDto
{
public Guid TokenId { get; set; }
public string Name { get; set; }
public bool IsDisabled { get; set; }
}

View File

@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
/// <summary>
/// 编辑Token输入
/// </summary>
public class TokenUpdateInput
{
/// <summary>
/// Token Id
/// </summary>
[Required(ErrorMessage = "Id不能为空")]
public Guid Id { get; set; }
/// <summary>
/// 名称(同一用户不能重复)
/// </summary>
[Required(ErrorMessage = "名称不能为空")]
[StringLength(100, ErrorMessage = "名称长度不能超过100个字符")]
public string Name { get; set; }
/// <summary>
/// 过期时间(空为永不过期)
/// </summary>
public DateTime? ExpireTime { get; set; }
/// <summary>
/// 尊享包额度限制(空为不限制)
/// </summary>
public long? PremiumQuotaLimit { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
/// <summary>
/// 尊享包不同Token用量占比DTO饼图
/// </summary>
public class TokenPremiumUsageDto
{
/// <summary>
/// Token Id
/// </summary>
public Guid TokenId { get; set; }
/// <summary>
/// Token名称
/// </summary>
public string TokenName { get; set; }
/// <summary>
/// Token消耗量
/// </summary>
public long Tokens { get; set; }
/// <summary>
/// 占比(百分比)
/// </summary>
public decimal Percentage { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
public class UsageStatisticsGetInput
{
/// <summary>
/// tokenId
/// </summary>
public Guid? TokenId { get; set; }
}

View File

@@ -11,13 +11,13 @@ public interface IUsageStatisticsService
/// 获取当前用户近7天的Token消耗统计
/// </summary>
/// <returns>每日Token使用量列表</returns>
Task<List<DailyTokenUsageDto>> GetLast7DaysTokenUsageAsync();
Task<List<DailyTokenUsageDto>> GetLast7DaysTokenUsageAsync(UsageStatisticsGetInput input);
/// <summary>
/// 获取当前用户各个模型的Token消耗量及占比
/// </summary>
/// <returns>模型Token使用量列表</returns>
Task<List<ModelTokenUsageDto>> GetModelTokenUsageAsync();
Task<List<ModelTokenUsageDto>> GetModelTokenUsageAsync(UsageStatisticsGetInput input);
/// <summary>
/// 获取当前用户尊享服务Token用量统计

View File

@@ -135,7 +135,7 @@ public class AiChatService : ApplicationService
//ai网关代理httpcontext
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
CurrentUser.Id, sessionId, cancellationToken);
CurrentUser.Id, sessionId, null, cancellationToken);
}
@@ -172,6 +172,6 @@ public class AiChatService : ApplicationService
//ai网关代理httpcontext
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
CurrentUser.Id, null, cancellationToken);
CurrentUser.Id, null, null, cancellationToken);
}
}

View File

@@ -1,55 +1,245 @@
using Microsoft.AspNetCore.Authorization;
using Dm.util;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SqlSugar;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos.Token;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.Ddd.Application.Contracts;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Application.Services;
/// <summary>
/// Token服务
/// </summary>
[Authorize]
public class TokenService : ApplicationService
{
private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository;
private readonly TokenManager _tokenManager;
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="tokenRepository"></param>
/// <param name="tokenManager"></param>
public TokenService(ISqlSugarRepository<TokenAggregateRoot> tokenRepository, TokenManager tokenManager)
public TokenService(
ISqlSugarRepository<TokenAggregateRoot> tokenRepository,
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository)
{
_tokenRepository = tokenRepository;
_tokenManager = tokenManager;
_usageStatisticsRepository = usageStatisticsRepository;
}
/// <summary>
/// 获取token
/// 获取当前用户的Token列表
/// </summary>
/// <returns></returns>
[Authorize]
public async Task<TokenOutput> GetAsync()
[HttpGet("token/list")]
public async Task<PagedResultDto<TokenGetListOutputDto>> GetListAsync([FromQuery] PagedAllResultRequestDto input)
{
return new TokenOutput
RefAsync<int> total = 0;
var userId = CurrentUser.GetId();
var tokens = await _tokenRepository._DbQueryable
.Where(x => x.UserId == userId)
.OrderByDescending(x => x.CreationTime)
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
if (!tokens.Any())
{
ApiKey = await _tokenManager.GetAsync(CurrentUser.GetId())
};
return new PagedResultDto<TokenGetListOutputDto>();
}
// 获取尊享包模型ID列表
var premiumModelIds = PremiumPackageConst.ModeIds;
// 批量查询所有Token的尊享包已使用额度
var tokenIds = tokens.Select(t => t.Id).ToList();
var usageStats = await _usageStatisticsRepository._DbQueryable
.Where(x => x.UserId == userId && tokenIds.Contains(x.TokenId) && premiumModelIds.Contains(x.ModelId))
.GroupBy(x => x.TokenId)
.Select(g => new
{
TokenId = g.TokenId,
UsedQuota = SqlFunc.AggregateSum(g.TotalTokenCount)
})
.ToListAsync();
var result = tokens.Select(t =>
{
var usedQuota = usageStats.FirstOrDefault(u => u.TokenId == t.Id)?.UsedQuota ?? 0;
return new TokenGetListOutputDto
{
Id = t.Id,
Name = t.Name,
ApiKey = t.Token,
ExpireTime = t.ExpireTime,
PremiumQuotaLimit = t.PremiumQuotaLimit,
PremiumUsedQuota = usedQuota,
IsDisabled = t.IsDisabled,
CreationTime = t.CreationTime
};
}).ToList();
return new PagedResultDto<TokenGetListOutputDto>(total, result);
}
/// <summary>
/// 创建token
/// </summary>
/// <exception cref="UserFriendlyException"></exception>
[Authorize]
public async Task CreateAsync()
[HttpGet("token/select-list")]
public async Task<List<TokenSelectListOutputDto>> GetSelectListAsync()
{
var userId = CurrentUser.GetId();
var tokens = await _tokenRepository._DbQueryable
.Where(x => x.UserId == userId)
.OrderBy(x => x.IsDisabled)
.OrderByDescending(x => x.CreationTime)
.Select(x => new TokenSelectListOutputDto
{
TokenId = x.Id,
Name = x.Name,
IsDisabled = x.IsDisabled
}).ToListAsync();
tokens.Insert(0,new TokenSelectListOutputDto
{
TokenId = Guid.Empty,
Name = "默认",
IsDisabled = false
});
return tokens;
}
/// <summary>
/// 创建Token
/// </summary>
[HttpPost("token")]
public async Task<TokenGetListOutputDto> CreateAsync([FromBody] TokenCreateInput input)
{
var userId = CurrentUser.GetId();
// 检查用户是否为VIP
if (!CurrentUser.IsAiVip())
{
throw new UserFriendlyException("充值成为Vip畅享第三方token服务");
}
await _tokenManager.CreateAsync(CurrentUser.GetId());
// 检查名称是否重复
var exists = await _tokenRepository._DbQueryable
.AnyAsync(x => x.UserId == userId && x.Name == input.Name);
if (exists)
{
throw new UserFriendlyException($"名称【{input.Name}】已存在,请使用其他名称");
}
var token = new TokenAggregateRoot(userId, input.Name)
{
ExpireTime = input.ExpireTime,
PremiumQuotaLimit = input.PremiumQuotaLimit
};
await _tokenRepository.InsertAsync(token);
return new TokenGetListOutputDto
{
Id = token.Id,
Name = token.Name,
ApiKey = token.Token,
ExpireTime = token.ExpireTime,
PremiumQuotaLimit = token.PremiumQuotaLimit,
PremiumUsedQuota = 0,
IsDisabled = token.IsDisabled,
CreationTime = token.CreationTime
};
}
/// <summary>
/// 编辑Token
/// </summary>
[HttpPut("token")]
public async Task UpdateAsync([FromBody] TokenUpdateInput input)
{
var userId = CurrentUser.GetId();
var token = await _tokenRepository._DbQueryable
.FirstAsync(x => x.Id == input.Id && x.UserId == userId);
if (token is null)
{
throw new UserFriendlyException("Token不存在或无权限操作");
}
// 检查名称是否重复(排除自己)
var exists = await _tokenRepository._DbQueryable
.AnyAsync(x => x.UserId == userId && x.Name == input.Name && x.Id != input.Id);
if (exists)
{
throw new UserFriendlyException($"名称【{input.Name}】已存在,请使用其他名称");
}
token.Name = input.Name;
token.ExpireTime = input.ExpireTime;
token.PremiumQuotaLimit = input.PremiumQuotaLimit;
await _tokenRepository.UpdateAsync(token);
}
/// <summary>
/// 删除Token
/// </summary>
[HttpDelete("token/{id}")]
public async Task DeleteAsync(Guid id)
{
var userId = CurrentUser.GetId();
var token = await _tokenRepository._DbQueryable
.FirstAsync(x => x.Id == id && x.UserId == userId);
if (token is null)
{
throw new UserFriendlyException("Token不存在或无权限操作");
}
await _tokenRepository.DeleteAsync(token);
}
/// <summary>
/// 启用Token
/// </summary>
[HttpPost("token/{id}/enable")]
public async Task EnableAsync(Guid id)
{
var userId = CurrentUser.GetId();
var token = await _tokenRepository._DbQueryable
.FirstAsync(x => x.Id == id && x.UserId == userId);
if (token is null)
{
throw new UserFriendlyException("Token不存在或无权限操作");
}
token.Enable();
await _tokenRepository.UpdateAsync(token);
}
/// <summary>
/// 禁用Token
/// </summary>
[HttpPost("token/{id}/disable")]
public async Task DisableAsync(Guid id)
{
var userId = CurrentUser.GetId();
var token = await _tokenRepository._DbQueryable
.FirstAsync(x => x.Id == id && x.UserId == userId);
if (token is null)
{
throw new UserFriendlyException("Token不存在或无权限操作");
}
token.Disable();
await _tokenRepository.UpdateAsync(token);
}
}

View File

@@ -76,12 +76,12 @@ public class FileMasterService : ApplicationService
if (input.Stream == true)
{
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
userId, null, cancellationToken);
userId, null, null, cancellationToken);
}
else
{
await _aiGateWayManager.CompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
null,
null, null,
cancellationToken);
}
}

View File

@@ -55,7 +55,9 @@ public class OpenApiService : ApplicationService
{
//前面都是校验,后面才是真正的调用
var httpContext = this._httpContextAccessor.HttpContext;
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
var tokenValidation = await _tokenManager.ValidateTokenAsync(GetTokenByHttpContext(httpContext), input.Model);
var userId = tokenValidation.UserId;
var tokenId = tokenValidation.TokenId;
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
//如果是尊享包服务,需要校验是是否尊享包足够
@@ -68,17 +70,17 @@ public class OpenApiService : ApplicationService
throw new UserFriendlyException("尊享token包用量不足请先购买尊享token包");
}
}
//ai网关代理httpcontext
if (input.Stream == true)
{
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
userId, null, cancellationToken);
userId, null, tokenId, cancellationToken);
}
else
{
await _aiGateWayManager.CompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
null,
null, tokenId,
cancellationToken);
}
}
@@ -93,9 +95,11 @@ public class OpenApiService : ApplicationService
{
var httpContext = this._httpContextAccessor.HttpContext;
Intercept(httpContext);
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
var tokenValidation = await _tokenManager.ValidateTokenAsync(GetTokenByHttpContext(httpContext), input.Model);
var userId = tokenValidation.UserId;
var tokenId = tokenValidation.TokenId;
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
await _aiGateWayManager.CreateImageForStatisticsAsync(httpContext, userId, null, input);
await _aiGateWayManager.CreateImageForStatisticsAsync(httpContext, userId, null, input, tokenId);
}
/// <summary>
@@ -108,9 +112,11 @@ public class OpenApiService : ApplicationService
{
var httpContext = this._httpContextAccessor.HttpContext;
Intercept(httpContext);
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
var tokenValidation = await _tokenManager.ValidateTokenAsync(GetTokenByHttpContext(httpContext), input.Model);
var userId = tokenValidation.UserId;
var tokenId = tokenValidation.TokenId;
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
await _aiGateWayManager.EmbeddingForStatisticsAsync(httpContext, userId, null, input);
await _aiGateWayManager.EmbeddingForStatisticsAsync(httpContext, userId, null, input, tokenId);
}
@@ -151,7 +157,9 @@ public class OpenApiService : ApplicationService
{
//前面都是校验,后面才是真正的调用
var httpContext = this._httpContextAccessor.HttpContext;
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
var tokenValidation = await _tokenManager.ValidateTokenAsync(GetTokenByHttpContext(httpContext), input.Model);
var userId = tokenValidation.UserId;
var tokenId = tokenValidation.TokenId;
await _aiBlacklistManager.VerifiyAiBlacklist(userId);
// 验证用户是否为VIP
@@ -178,12 +186,12 @@ public class OpenApiService : ApplicationService
if (input.Stream)
{
await _aiGateWayManager.AnthropicCompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
userId, null, cancellationToken);
userId, null, tokenId, cancellationToken);
}
else
{
await _aiGateWayManager.AnthropicCompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
null,
null, tokenId,
cancellationToken);
}
}

View File

@@ -9,7 +9,9 @@ using Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
using Yi.Framework.AiHub.Application.Contracts.IServices;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.Ddd.Application.Contracts;
using Yi.Framework.SqlSugarCore.Abstractions;
@@ -24,22 +26,25 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
private readonly ISqlSugarRepository<MessageAggregateRoot> _messageRepository;
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
private readonly ISqlSugarRepository<PremiumPackageAggregateRoot> _premiumPackageRepository;
private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository;
public UsageStatisticsService(
ISqlSugarRepository<MessageAggregateRoot> messageRepository,
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository,
ISqlSugarRepository<PremiumPackageAggregateRoot> premiumPackageRepository)
ISqlSugarRepository<PremiumPackageAggregateRoot> premiumPackageRepository,
ISqlSugarRepository<TokenAggregateRoot> tokenRepository)
{
_messageRepository = messageRepository;
_usageStatisticsRepository = usageStatisticsRepository;
_premiumPackageRepository = premiumPackageRepository;
_tokenRepository = tokenRepository;
}
/// <summary>
/// 获取当前用户近7天的Token消耗统计
/// </summary>
/// <returns>每日Token使用量列表</returns>
public async Task<List<DailyTokenUsageDto>> GetLast7DaysTokenUsageAsync()
public async Task<List<DailyTokenUsageDto>> GetLast7DaysTokenUsageAsync([FromQuery]UsageStatisticsGetInput input)
{
var userId = CurrentUser.GetId();
var endDate = DateTime.Today;
@@ -50,6 +55,7 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
.Where(x => x.UserId == userId)
.Where(x => x.Role == "assistant" || x.Role == "system")
.Where(x => x.CreationTime >= startDate && x.CreationTime < endDate.AddDays(1))
.WhereIF(input.TokenId.HasValue,x => x.TokenId == input.TokenId)
.GroupBy(x => x.CreationTime.Date)
.Select(g => new
{
@@ -79,17 +85,19 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
/// 获取当前用户各个模型的Token消耗量及占比
/// </summary>
/// <returns>模型Token使用量列表</returns>
public async Task<List<ModelTokenUsageDto>> GetModelTokenUsageAsync()
public async Task<List<ModelTokenUsageDto>> GetModelTokenUsageAsync([FromQuery]UsageStatisticsGetInput input)
{
var userId = CurrentUser.GetId();
// 从UsageStatistics表获取各模型的token消耗统计
// 从UsageStatistics表获取各模型的token消耗统计按ModelId聚合因为同一模型可能有多个TokenId的记录
var modelUsages = await _usageStatisticsRepository._DbQueryable
.Where(x => x.UserId == userId)
.WhereIF(input.TokenId.HasValue,x => x.TokenId == input.TokenId)
.GroupBy(x => x.ModelId)
.Select(x => new
{
x.ModelId,
x.TotalTokenCount
ModelId = x.ModelId,
TotalTokenCount = SqlFunc.AggregateSum(x.TotalTokenCount)
})
.ToListAsync();
@@ -164,4 +172,54 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic
return new PagedResultDto<PremiumTokenUsageGetListOutput>(total,
entities.Adapt<List<PremiumTokenUsageGetListOutput>>());
}
/// <summary>
/// 获取当前用户尊享包不同Token用量占比饼图
/// </summary>
/// <returns>各Token的尊享模型用量及占比</returns>
[HttpGet("usage-statistics/premium-token-usage/by-token")]
public async Task<List<TokenPremiumUsageDto>> GetPremiumTokenUsageByTokenAsync()
{
var userId = CurrentUser.GetId();
var premiumModelIds = PremiumPackageConst.ModeIds;
// 从UsageStatistics表获取尊享模型的token消耗统计按TokenId聚合
var tokenUsages = await _usageStatisticsRepository._DbQueryable
.Where(x => x.UserId == userId && premiumModelIds.Contains(x.ModelId))
.GroupBy(x => x.TokenId)
.Select(x => new
{
TokenId = x.TokenId,
TotalTokenCount = SqlFunc.AggregateSum(x.TotalTokenCount)
})
.ToListAsync();
if (!tokenUsages.Any())
{
return new List<TokenPremiumUsageDto>();
}
// 获取用户的所有Token信息用于名称映射
var tokenIds = tokenUsages.Select(x => x.TokenId).ToList();
var tokens = await _tokenRepository._DbQueryable
.Where(x => x.UserId == userId && tokenIds.Contains(x.Id))
.Select(x => new { x.Id, x.Name })
.ToListAsync();
var tokenNameDict = tokens.ToDictionary(x => x.Id, x => x.Name);
// 计算总token数
var totalTokens = tokenUsages.Sum(x => x.TotalTokenCount);
// 计算各Token占比
var result = tokenUsages.Select(x => new TokenPremiumUsageDto
{
TokenId = x.TokenId,
TokenName = x.TokenId == Guid.Empty ? "默认" : (tokenNameDict.TryGetValue(x.TokenId, out var name) ? name : "其他"),
Tokens = x.TotalTokenCount,
Percentage = totalTokens > 0 ? Math.Round((decimal)x.TotalTokenCount / totalTokens * 100, 2) : 0
}).OrderByDescending(x => x.Tokens).ToList();
return result;
}
}

View File

@@ -20,10 +20,11 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot<Guid>
}
public MessageAggregateRoot(Guid? userId, Guid? sessionId, string content, string role, string modelId,
ThorUsageResponse? tokenUsage)
ThorUsageResponse? tokenUsage, Guid? tokenId = null)
{
UserId = userId;
SessionId = sessionId;
TokenId = tokenId ?? Guid.Empty;
//如果没有会话,不存储对话内容
Content = sessionId is null ? null : content;
Role = role;
@@ -59,6 +60,11 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot<Guid>
public Guid? UserId { get; set; }
public Guid? SessionId { get; set; }
/// <summary>
/// Token密钥Id通过API调用时记录Web调用为Guid.Empty
/// </summary>
public Guid TokenId { get; set; }
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString)]
public string? Content { get; set; }

View File

@@ -5,27 +5,84 @@ using Volo.Abp.Domain.Entities.Auditing;
namespace Yi.Framework.AiHub.Domain.Entities.OpenApi;
[SugarTable("Ai_Token")]
[SugarIndex($"index_{{table}}_{nameof(UserId)}", nameof(UserId), OrderByType.Asc)]
public class TokenAggregateRoot : FullAuditedAggregateRoot<Guid>
{
public TokenAggregateRoot()
{
}
public TokenAggregateRoot(Guid userId)
public TokenAggregateRoot(Guid userId, string name)
{
this.UserId = userId;
this.Token = GenerateToken();
UserId = userId;
Name = name;
Token = GenerateToken();
IsDisabled = false;
}
/// <summary>
/// Token密钥
/// </summary>
public string Token { get; set; }
/// <summary>
/// 用户Id
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 重置token
/// 名称
/// </summary>
public void ResetToken()
[SugarColumn(Length = 100)]
public string Name { get; set; }
/// <summary>
/// 过期时间(空为永不过期)
/// </summary>
public DateTime? ExpireTime { get; set; }
/// <summary>
/// 尊享包额度限制(空为不限制)
/// </summary>
public long? PremiumQuotaLimit { get; set; }
/// <summary>
/// 是否禁用
/// </summary>
public bool IsDisabled { get; set; }
/// <summary>
/// 检查Token是否可用
/// </summary>
public bool IsAvailable()
{
this.Token = GenerateToken();
if (IsDisabled)
{
return false;
}
if (ExpireTime.HasValue && ExpireTime.Value < DateTime.Now)
{
return false;
}
return true;
}
/// <summary>
/// 禁用Token
/// </summary>
public void Disable()
{
IsDisabled = true;
}
/// <summary>
/// 启用Token
/// </summary>
public void Enable()
{
IsDisabled = false;
}
private string GenerateToken(int length = 36)

View File

@@ -7,16 +7,22 @@ namespace Yi.Framework.AiHub.Domain.Entities;
/// 用量统计
/// </summary>
[SugarTable("Ai_UsageStatistics")]
[SugarIndex($"index_{{table}}_{nameof(UserId)}_{nameof(ModelId)}_{nameof(TokenId)}",
nameof(UserId), OrderByType.Asc,
nameof(ModelId), OrderByType.Asc,
nameof(TokenId), OrderByType.Asc
)]
public class UsageStatisticsAggregateRoot : FullAuditedAggregateRoot<Guid>
{
public UsageStatisticsAggregateRoot()
{
}
public UsageStatisticsAggregateRoot(Guid? userId, string modelId)
public UsageStatisticsAggregateRoot(Guid? userId, string modelId, Guid tokenId)
{
UserId = userId;
ModelId = modelId;
TokenId = tokenId;
}
/// <summary>
@@ -29,6 +35,11 @@ public class UsageStatisticsAggregateRoot : FullAuditedAggregateRoot<Guid>
/// </summary>
public string ModelId { get; set; }
/// <summary>
/// Token密钥Id通过API调用时记录Web调用为Guid.Empty
/// </summary>
public Guid TokenId { get; set; }
/// <summary>
/// 对话次数
/// </summary>

View File

@@ -120,12 +120,14 @@ public class AiGateWayManager : DomainService
/// <param name="request"></param>
/// <param name="userId"></param>
/// <param name="sessionId"></param>
/// <param name="tokenId">Token IdWeb端传null或Guid.Empty</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task CompleteChatForStatisticsAsync(HttpContext httpContext,
ThorChatCompletionsRequest request,
Guid? userId = null,
Guid? sessionId = null,
Guid? tokenId = null,
CancellationToken cancellationToken = default)
{
_specialCompatible.Compatible(request);
@@ -145,7 +147,7 @@ public class AiGateWayManager : DomainService
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault().Content ?? string.Empty,
ModelId = request.Model,
TokenUsage = data.Usage,
});
}, tokenId);
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
new MessageInputDto
@@ -154,9 +156,9 @@ public class AiGateWayManager : DomainService
sessionId is null ? "不予存储" : data.Choices?.FirstOrDefault()?.Delta.Content ?? string.Empty,
ModelId = request.Model,
TokenUsage = data.Usage
});
}, tokenId);
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.Usage);
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.Usage, tokenId);
// 扣减尊享token包用量
if (PremiumPackageConst.ModeIds.Contains(request.Model))
@@ -179,6 +181,7 @@ public class AiGateWayManager : DomainService
/// <param name="request"></param>
/// <param name="userId"></param>
/// <param name="sessionId"></param>
/// <param name="tokenId">Token IdWeb端传null或Guid.Empty</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task CompleteChatStreamForStatisticsAsync(
@@ -186,6 +189,7 @@ public class AiGateWayManager : DomainService
ThorChatCompletionsRequest request,
Guid? userId = null,
Guid? sessionId = null,
Guid? tokenId = null,
CancellationToken cancellationToken = default)
{
var response = httpContext.Response;
@@ -288,7 +292,7 @@ public class AiGateWayManager : DomainService
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault()?.Content ?? string.Empty,
ModelId = request.Model,
TokenUsage = tokenUsage,
});
}, tokenId);
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
new MessageInputDto
@@ -296,9 +300,9 @@ public class AiGateWayManager : DomainService
Content = sessionId is null ? "不予存储" : backupSystemContent.ToString(),
ModelId = request.Model,
TokenUsage = tokenUsage
});
}, tokenId);
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage);
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage, tokenId);
// 扣减尊享token包用量
if (userId is not null && PremiumPackageConst.ModeIds.Contains(request.Model))
@@ -319,10 +323,11 @@ public class AiGateWayManager : DomainService
/// <param name="userId"></param>
/// <param name="sessionId"></param>
/// <param name="request"></param>
/// <param name="tokenId">Token IdWeb端传null或Guid.Empty</param>
/// <exception cref="BusinessException"></exception>
/// <exception cref="Exception"></exception>
public async Task CreateImageForStatisticsAsync(HttpContext context, Guid? userId, Guid? sessionId,
ImageCreateRequest request)
ImageCreateRequest request, Guid? tokenId = null)
{
try
{
@@ -350,7 +355,7 @@ public class AiGateWayManager : DomainService
Content = sessionId is null ? "不予存储" : request.Prompt,
ModelId = model,
TokenUsage = response.Usage,
});
}, tokenId);
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
new MessageInputDto
@@ -358,9 +363,9 @@ public class AiGateWayManager : DomainService
Content = sessionId is null ? "不予存储" : response.Results?.FirstOrDefault()?.Url,
ModelId = model,
TokenUsage = response.Usage
});
}, tokenId);
await _usageStatisticsManager.SetUsageAsync(userId, model, response.Usage);
await _usageStatisticsManager.SetUsageAsync(userId, model, response.Usage, tokenId);
// 扣减尊享token包用量
if (userId is not null && PremiumPackageConst.ModeIds.Contains(request.Model))
@@ -384,13 +389,14 @@ public class AiGateWayManager : DomainService
/// 向量生成
/// </summary>
/// <param name="context"></param>
/// <param name="userId"></param>
/// <param name="sessionId"></param>
/// <param name="input"></param>
/// <param name="userId"></param>
/// <param name="tokenId">Token IdWeb端传null或Guid.Empty</param>
/// <exception cref="Exception"></exception>
/// <exception cref="BusinessException"></exception>
public async Task EmbeddingForStatisticsAsync(HttpContext context, Guid? userId, Guid? sessionId,
ThorEmbeddingInput input)
ThorEmbeddingInput input, Guid? tokenId = null)
{
try
{
@@ -474,7 +480,7 @@ public class AiGateWayManager : DomainService
// TokenUsage = usage
// });
await _usageStatisticsManager.SetUsageAsync(userId, input.Model, usage);
await _usageStatisticsManager.SetUsageAsync(userId, input.Model, usage, tokenId);
}
catch (ThorRateLimitException)
{
@@ -522,12 +528,14 @@ public class AiGateWayManager : DomainService
/// <param name="request"></param>
/// <param name="userId"></param>
/// <param name="sessionId"></param>
/// <param name="tokenId">Token IdWeb端传null或Guid.Empty</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task AnthropicCompleteChatForStatisticsAsync(HttpContext httpContext,
AnthropicInput request,
Guid? userId = null,
Guid? sessionId = null,
Guid? tokenId = null,
CancellationToken cancellationToken = default)
{
_specialCompatible.AnthropicCompatible(request);
@@ -549,7 +557,7 @@ public class AiGateWayManager : DomainService
Content = sessionId is null ? "不予存储" : request.Messages?.FirstOrDefault()?.Content ?? string.Empty,
ModelId = request.Model,
TokenUsage = data.TokenUsage,
});
}, tokenId);
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
new MessageInputDto
@@ -557,9 +565,9 @@ public class AiGateWayManager : DomainService
Content = sessionId is null ? "不予存储" : data.content?.FirstOrDefault()?.text,
ModelId = request.Model,
TokenUsage = data.TokenUsage
});
}, tokenId);
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.TokenUsage);
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.TokenUsage, tokenId);
// 扣减尊享token包用量
var totalTokens = data.TokenUsage.TotalTokens ?? 0;
@@ -579,6 +587,7 @@ public class AiGateWayManager : DomainService
/// <param name="request"></param>
/// <param name="userId"></param>
/// <param name="sessionId"></param>
/// <param name="tokenId">Token IdWeb端传null或Guid.Empty</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task AnthropicCompleteChatStreamForStatisticsAsync(
@@ -586,6 +595,7 @@ public class AiGateWayManager : DomainService
AnthropicInput request,
Guid? userId = null,
Guid? sessionId = null,
Guid? tokenId = null,
CancellationToken cancellationToken = default)
{
var response = httpContext.Response;
@@ -627,7 +637,7 @@ public class AiGateWayManager : DomainService
Content = sessionId is null ? "不予存储" : request.Messages?.LastOrDefault()?.Content ?? string.Empty,
ModelId = request.Model,
TokenUsage = tokenUsage,
});
}, tokenId);
await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId,
new MessageInputDto
@@ -635,9 +645,9 @@ public class AiGateWayManager : DomainService
Content = sessionId is null ? "不予存储" : backupSystemContent.ToString(),
ModelId = request.Model,
TokenUsage = tokenUsage
});
}, tokenId);
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage);
await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage, tokenId);
// 扣减尊享token包用量
if (userId.HasValue && tokenUsage is not null)

View File

@@ -19,28 +19,30 @@ public class AiMessageManager : DomainService
/// <summary>
/// 创建系统消息
/// </summary>
/// <param name="sessionId"></param>
/// <param name="userId"></param>
/// <param name="input"></param>
/// <param name="userId">用户Id</param>
/// <param name="sessionId">会话Id</param>
/// <param name="input">消息输入</param>
/// <param name="tokenId">Token IdWeb端传Guid.Empty</param>
/// <returns></returns>
public async Task CreateSystemMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input)
public async Task CreateSystemMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null)
{
input.Role = "system";
var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId,input.TokenUsage);
var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId, input.TokenUsage, tokenId);
await _repository.InsertAsync(message);
}
/// <summary>
/// 创建系统消息
/// 创建用户消息
/// </summary>
/// <param name="sessionId"></param>
/// <param name="userId"></param>
/// <param name="input"></param>
/// <param name="userId">用户Id</param>
/// <param name="sessionId">会话Id</param>
/// <param name="input">消息输入</param>
/// <param name="tokenId">Token IdWeb端传Guid.Empty</param>
/// <returns></returns>
public async Task CreateUserMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input)
public async Task CreateUserMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null)
{
input.Role = "user";
var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId,input.TokenUsage);
var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId, input.TokenUsage, tokenId);
await _repository.InsertAsync(message);
}
}

View File

@@ -1,64 +1,134 @@
using Volo.Abp.Domain.Services;
using Volo.Abp.Users;
using SqlSugar;
using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AiHub.Domain.Shared.Consts;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Domain.Managers;
/// <summary>
/// Token验证结果
/// </summary>
public class TokenValidationResult
{
/// <summary>
/// 用户Id
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// Token Id
/// </summary>
public Guid TokenId { get; set; }
}
public class TokenManager : DomainService
{
private readonly ISqlSugarRepository<TokenAggregateRoot> _tokenRepository;
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _usageStatisticsRepository;
public TokenManager(ISqlSugarRepository<TokenAggregateRoot> tokenRepository)
public TokenManager(
ISqlSugarRepository<TokenAggregateRoot> tokenRepository,
ISqlSugarRepository<UsageStatisticsAggregateRoot> usageStatisticsRepository)
{
_tokenRepository = tokenRepository;
_usageStatisticsRepository = usageStatisticsRepository;
}
public async Task<string?> GetAsync(Guid userId)
{
var entity = await _tokenRepository._DbQueryable.FirstAsync(x => x.UserId == userId);
if (entity is not null)
{
return entity.Token;
}
else
{
return null;
}
}
public async Task CreateAsync(Guid userId)
{
var entity = await _tokenRepository._DbQueryable.FirstAsync(x => x.UserId == userId);
if (entity is not null)
{
entity.ResetToken();
await _tokenRepository.UpdateAsync(entity);
}
else
{
var token = new TokenAggregateRoot(userId);
await _tokenRepository.InsertAsync(token);
}
}
public async Task<Guid> GetUserIdAsync(string? token)
/// <summary>
/// 验证Token并返回用户Id和TokenId
/// </summary>
/// <param name="token">Token密钥</param>
/// <param name="modelId">模型Id用于判断是否是尊享模型需要检查额度</param>
/// <returns>Token验证结果</returns>
public async Task<TokenValidationResult> ValidateTokenAsync(string? token, string? modelId = null)
{
if (token is null)
{
throw new UserFriendlyException("当前请求未包含token", "401");
}
if (token.StartsWith("yi-"))
if (!token.StartsWith("yi-"))
{
var entity = await _tokenRepository._DbQueryable.Where(x => x.Token == token).FirstAsync();
if (entity is null)
{
throw new UserFriendlyException("当前请求token无效", "401");
}
return entity.UserId;
throw new UserFriendlyException("当前请求token非法", "401");
}
throw new UserFriendlyException("当前请求token非法", "401");
var entity = await _tokenRepository._DbQueryable
.Where(x => x.Token == token)
.FirstAsync();
if (entity is null)
{
throw new UserFriendlyException("当前请求token无效", "401");
}
// 检查Token是否被禁用
if (entity.IsDisabled)
{
throw new UserFriendlyException("当前Token已被禁用请启用后再使用", "403");
}
// 检查Token是否过期
if (entity.ExpireTime.HasValue && entity.ExpireTime.Value < DateTime.Now)
{
throw new UserFriendlyException("当前Token已过期请更新过期时间或创建新的Token", "403");
}
// 如果是尊享模型且Token设置了额度限制检查是否超限
if (!string.IsNullOrEmpty(modelId) &&
PremiumPackageConst.ModeIds.Contains(modelId) &&
entity.PremiumQuotaLimit.HasValue)
{
var usedQuota = await GetTokenPremiumUsedQuotaAsync(entity.UserId, entity.Id);
if (usedQuota >= entity.PremiumQuotaLimit.Value)
{
throw new UserFriendlyException($"当前Token的尊享包额度已用完已使用{usedQuota},限制:{entity.PremiumQuotaLimit.Value}请调整额度限制或使用其他Token", "403");
}
}
return new TokenValidationResult
{
UserId = entity.UserId,
TokenId = entity.Id
};
}
}
/// <summary>
/// 获取Token的尊享包已使用额度
/// </summary>
private async Task<long> GetTokenPremiumUsedQuotaAsync(Guid userId, Guid tokenId)
{
var premiumModelIds = PremiumPackageConst.ModeIds;
var usedQuota = await _usageStatisticsRepository._DbQueryable
.Where(x => x.UserId == userId && x.TokenId == tokenId && premiumModelIds.Contains(x.ModelId))
.SumAsync(x => x.TotalTokenCount);
return usedQuota;
}
/// <summary>
/// 获取用户的Token兼容旧接口返回第一个可用的Token
/// </summary>
[Obsolete("请使用 ValidateTokenAsync 方法")]
public async Task<string?> GetAsync(Guid userId)
{
var entity = await _tokenRepository._DbQueryable
.Where(x => x.UserId == userId && !x.IsDisabled)
.OrderBy(x => x.CreationTime)
.FirstAsync();
return entity?.Token;
}
/// <summary>
/// 获取用户Id兼容旧接口
/// </summary>
[Obsolete("请使用 ValidateTokenAsync 方法")]
public async Task<Guid> GetUserIdAsync(string? token)
{
var result = await ValidateTokenAsync(token);
return result.UserId;
}
}

View File

@@ -18,8 +18,10 @@ public class UsageStatisticsManager : DomainService
private IDistributedLockProvider DistributedLock =>
LazyServiceProvider.LazyGetRequiredService<IDistributedLockProvider>();
public async Task SetUsageAsync(Guid? userId, string modelId, ThorUsageResponse? tokenUsage)
public async Task SetUsageAsync(Guid? userId, string modelId, ThorUsageResponse? tokenUsage, Guid? tokenId = null)
{
var actualTokenId = tokenId ?? Guid.Empty;
long inputTokenCount = tokenUsage?.PromptTokens
?? tokenUsage?.InputTokens
?? 0;
@@ -28,10 +30,10 @@ public class UsageStatisticsManager : DomainService
?? tokenUsage?.OutputTokens
?? 0;
await using (await DistributedLock.AcquireLockAsync($"UsageStatistics:{userId?.ToString()}"))
await using (await DistributedLock.AcquireLockAsync($"UsageStatistics:{userId?.ToString()}:{actualTokenId}:{modelId}"))
{
var entity = await _repository._DbQueryable.FirstAsync(x => x.UserId == userId && x.ModelId == modelId);
//存在数据,更
var entity = await _repository._DbQueryable.FirstAsync(x => x.UserId == userId && x.ModelId == modelId && x.TokenId == actualTokenId);
//存在数据,更
if (entity is not null)
{
entity.AddOnceChat(inputTokenCount, outputTokenCount);
@@ -40,7 +42,7 @@ public class UsageStatisticsManager : DomainService
//不存在插入
else
{
var usage = new UsageStatisticsAggregateRoot(userId, modelId);
var usage = new UsageStatisticsAggregateRoot(userId, modelId, actualTokenId);
usage.AddOnceChat(inputTokenCount, outputTokenCount);
await _repository.InsertAsync(usage);
}

View File

@@ -31,6 +31,8 @@ using Yi.Abp.SqlsugarCore;
using Yi.Framework.AiHub.Application;
using Yi.Framework.AiHub.Application.Services;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Chat;
using Yi.Framework.AiHub.Domain.Entities.OpenApi;
using Yi.Framework.AspNetCore;
using Yi.Framework.AspNetCore.Authentication.OAuth;
using Yi.Framework.AspNetCore.Authentication.OAuth.Gitee;
@@ -355,10 +357,9 @@ namespace Yi.Abp.Web
var app = context.GetApplicationBuilder();
app.UseRouting();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<AnnouncementAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<CardFlipTaskAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<InviteCodeAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<InvitationRecordAggregateRoot>();
app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<MessageAggregateRoot>();
app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<TokenAggregateRoot>();
app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<UsageStatisticsAggregateRoot>();
//跨域
app.UseCors(DefaultCorsPolicyName);

View File

@@ -1,9 +1,8 @@
import type { GetSessionListVO } from './types';
import { get, post } from '@/utils/request';
import { del, get, post, put } from '@/utils/request';
// 获取当前用户的模型列表
export function getModelList() {
// return get<GetSessionListVO[]>('/system/model/modelList');
return get<GetSessionListVO[]>('/ai-chat/model').json();
}
// 申请ApiKey
@@ -21,10 +20,99 @@ export function getRechargeLog() {
}
// 查询用户近7天token消耗
export function getLast7DaysTokenUsage() {
return get<any>('/usage-statistics/last7Days-token-usage').json();
// tokenId: 可选传入则查询该token的用量不传则查询全部
export function getLast7DaysTokenUsage(tokenId?: string) {
const url = tokenId
? `/usage-statistics/last7Days-token-usage?tokenId=${tokenId}`
: '/usage-statistics/last7Days-token-usage';
return get<any>(url).json();
}
// 查询用户token消耗各模型占比
export function getModelTokenUsage() {
return get<any>('/usage-statistics/model-token-usage').json();
// tokenId: 可选传入则查询该token的用量不传则查询全部
export function getModelTokenUsage(tokenId?: string) {
const url = tokenId
? `/usage-statistics/model-token-usage?tokenId=${tokenId}`
: '/usage-statistics/model-token-usage';
return get<any>(url).json();
}
// 获取当前用户得token列表
export function getTokenList(params?: {
skipCount?: number;
maxResultCount?: number;
orderByColumn?: string;
isAsc?: string;
}) {
// 构建查询参数
const queryParams = new URLSearchParams();
if (params?.skipCount !== undefined) {
queryParams.append('SkipCount', params.skipCount.toString());
}
if (params?.maxResultCount !== undefined) {
queryParams.append('MaxResultCount', params.maxResultCount.toString());
}
if (params?.orderByColumn) {
queryParams.append('OrderByColumn', params.orderByColumn);
}
if (params?.isAsc) {
queryParams.append('IsAsc', params.isAsc);
}
const queryString = queryParams.toString();
const url = queryString ? `/token/list?${queryString}` : '/token/list';
return get<any>(url).json();
}
// 创建token
export function createToken(data: any) {
return post<any>('/token', data).json();
}
// 编辑token
export function editToken(data: any) {
return put('/token', data).json();
}
// 删除token
export function deleteToken(id: string) {
return del(`/token/${id}`).json();
}
// 启用token
export function enableToken(id: string) {
return post(`/token/${id}/enable`).json();
}
// 禁用token
export function disableToken(id: string) {
return post(`/token/${id}/disable`).json();
}
// 新增接口2
// 获取可选择的token信息
export function getSelectableTokenInfo() {
return get<any>('/token/select-list').json();
}
/*
返回数据
[
{
"tokenId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "string",
"isDisabled": true
}
] */
// 获取当前用户尊享包不同token用量占比饼图
export function getPremiumPackageTokenUsage() {
return get<any>('/usage-statistics/premium-token-usage/by-token').json();
}
/* 返回数据
[
{
"tokenId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tokenName": "string",
"tokens": 0,
"percentage": 0
}
] */

View File

@@ -1,7 +1,36 @@
<script lang="ts" setup>
import { Clock, Coin, TrophyBase, WarningFilled } from '@element-plus/icons-vue';
import { PieChart as EPieChart } from 'echarts/charts';
import {
GraphicComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { getPremiumPackageTokenUsage } from '@/api';
import { showProductPackage } from '@/utils/product-package.ts';
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
// Emits
const emit = defineEmits<{
refresh: [];
}>();
// 注册必要的组件
echarts.use([
EPieChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GraphicComponent,
CanvasRenderer,
]);
// Props
interface Props {
packageData: {
@@ -15,14 +44,11 @@ interface Props {
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
// Emits
const emit = defineEmits<{
refresh: [];
}>();
// 饼图相关
const tokenPieChart = ref(null);
let tokenPieChartInstance: any = null;
const tokenUsageData = ref<any[]>([]);
const tokenUsageLoading = ref(false);
// 计算属性
const usagePercent = computed(() => {
@@ -64,6 +90,193 @@ function formatRawNumber(num: number): string {
function onProductPackage() {
showProductPackage();
}
// 获取Token用量数据
async function fetchTokenUsageData() {
try {
tokenUsageLoading.value = true;
const res = await getPremiumPackageTokenUsage();
if (res.data) {
tokenUsageData.value = res.data;
updateTokenPieChart();
}
}
catch (error) {
console.error('获取Token用量数据失败:', error);
}
finally {
tokenUsageLoading.value = false;
}
}
// 初始化Token饼图
function initTokenPieChart() {
if (tokenPieChart.value) {
tokenPieChartInstance = echarts.init(tokenPieChart.value);
}
window.addEventListener('resize', resizeTokenPieChart);
}
// 更新Token饼图
function updateTokenPieChart() {
if (!tokenPieChartInstance)
return;
// 空数据状态
if (tokenUsageData.value.length === 0) {
const emptyOption = {
graphic: [
{
type: 'group',
left: 'center',
top: 'center',
children: [
{
type: 'circle',
shape: {
r: 80,
},
style: {
fill: '#f5f7fa',
stroke: '#e9ecef',
lineWidth: 2,
},
},
{
type: 'text',
style: {
text: '📊',
fontSize: 48,
x: -24,
y: -40,
},
},
{
type: 'text',
style: {
text: '暂无数据',
fontSize: 18,
fontWeight: 'bold',
fill: '#909399',
x: -36,
y: 20,
},
},
{
type: 'text',
style: {
text: '还没有Token使用记录',
fontSize: 14,
fill: '#c0c4cc',
x: -70,
y: 50,
},
},
],
},
],
};
tokenPieChartInstance.setOption(emptyOption, true);
return;
}
const data = tokenUsageData.value.map(item => ({
name: item.tokenName,
value: item.tokens,
}));
const option = {
graphic: [],
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} tokens ({d}%)',
},
legend: {
show: false, // 隐藏图例,使用标签线代替
},
series: [
{
name: 'Token用量',
type: 'pie',
radius: ['50%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: true,
position: 'outside',
formatter: (params: any) => {
const item = tokenUsageData.value.find(d => d.tokenName === params.name);
const percentage = item?.percentage || 0;
return `${params.name}: ${percentage.toFixed(1)}%`;
},
fontSize: 13,
fontWeight: 600,
color: '#333',
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.3)',
},
},
labelLine: {
show: true,
length: 15,
length2: 10,
lineStyle: {
width: 1.5,
},
},
data,
},
],
};
tokenPieChartInstance.setOption(option, true);
}
// 调整饼图大小
function resizeTokenPieChart() {
tokenPieChartInstance?.resize();
}
// 根据索引获取Token颜色
function getTokenColor(index: number) {
const colors = [
'#667eea',
'#764ba2',
'#f093fb',
'#f5576c',
'#4facfe',
'#00f2fe',
'#43e97b',
'#38f9d7',
'#fa709a',
'#fee140',
];
return colors[index % colors.length];
}
onMounted(() => {
initTokenPieChart();
fetchTokenUsageData();
});
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeTokenPieChart);
tokenPieChartInstance?.dispose();
});
</script>
<template>
@@ -231,6 +444,98 @@ function onProductPackage() {
</el-alert>
</div>
</el-card>
<!-- Token用量占比卡片 -->
<el-card v-loading="tokenUsageLoading" class="package-card token-usage-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="card-header-left">
<el-icon class="header-icon token-icon">
<i-ep-pie-chart />
</el-icon>
<div class="header-text">
<span class="header-title">各API密钥用量占比</span>
<span class="header-subtitle">Premium APIKEY Usage Distribution</span>
</div>
</div>
</div>
</template>
<div class="token-usage-content">
<div class="chart-container-wrapper">
<div ref="tokenPieChart" class="token-pie-chart" />
</div>
<!-- Token统计列表 -->
<div v-if="tokenUsageData.length > 0" class="token-stats-list">
<div
v-for="(item, index) in tokenUsageData"
:key="item.tokenId"
class="token-stat-item"
>
<div class="token-stat-header">
<div class="token-rank">
<span class="rank-badge" :class="`rank-${index + 1}`">#{{ index + 1 }}</span>
</div>
<div class="token-name">
<el-icon><i-ep-key /></el-icon>
<span>{{ item.tokenName }}</span>
</div>
</div>
<div class="token-stat-data">
<div class="stat-tokens">
<span class="label">用量:</span>
<span class="value">{{ item.tokens.toLocaleString() }}</span>
<span class="unit">tokens</span>
</div>
<div class="stat-percentage">
<el-progress
:percentage="item.percentage"
:color="getTokenColor(index)"
:stroke-width="8"
:show-text="true"
/>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-else
description="暂无Token使用数据"
class="token-empty-state"
:image-size="120"
>
<template #image>
<div class="custom-empty-image">
<el-icon class="empty-main-icon">
<i-ep-pie-chart />
</el-icon>
<div class="empty-decoration">
<div class="decoration-circle" />
<div class="decoration-circle" />
<div class="decoration-circle" />
</div>
</div>
</template>
<template #description>
<div class="empty-description">
<h3 class="empty-title">
暂无Token使用数据
</h3>
<p class="empty-text">
当您开始使用Token后这里将展示各Token的用量占比统计
</p>
<div class="empty-tips">
<el-icon><i-ep-info-filled /></el-icon>
<span>创建并使用Token后即可查看详细的用量分析</span>
</div>
</div>
</template>
</el-empty>
</div>
</el-card>
</div>
</template>
@@ -495,6 +800,270 @@ function onProductPackage() {
font-size: 13px;
}
/* Token用量占比卡片 */
.token-usage-card {
margin-top: 24px;
}
.token-icon {
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
color: #0284c7;
}
.token-usage-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.chart-container-wrapper {
width: 100%;
background: linear-gradient(135deg, #fafbfc 0%, #f5f6f8 100%);
border-radius: 12px;
padding: 20px;
}
.token-pie-chart {
width: 100%;
height: 400px;
}
/* Token统计列表 */
.token-stats-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.token-stat-item {
padding: 16px;
background: linear-gradient(135deg, #fafbfc 0%, #ffffff 100%);
border-radius: 12px;
border: 1px solid #f0f2f5;
transition: all 0.3s;
&:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
transform: translateY(-2px);
}
}
.token-stat-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.token-rank {
flex-shrink: 0;
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
font-size: 14px;
font-weight: 700;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
&.rank-1 {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
&.rank-2 {
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
}
&.rank-3 {
background: linear-gradient(135deg, #fb923c 0%, #ea580c 100%);
}
}
.token-name {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
.el-icon {
color: #667eea;
font-size: 18px;
}
}
.token-stat-data {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-tokens {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 14px;
.label {
color: #909399;
}
.value {
font-size: 20px;
font-weight: 700;
color: #667eea;
}
.unit {
color: #909399;
font-size: 12px;
}
}
.stat-percentage {
:deep(.el-progress__text) {
font-size: 14px !important;
font-weight: 700;
}
}
/* 空状态 */
.token-empty-state {
padding: 60px 20px;
:deep(.el-empty__image) {
width: auto;
}
}
.custom-empty-image {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 140px;
height: 140px;
margin: 0 auto;
}
.empty-main-icon {
font-size: 80px;
color: #667eea;
position: relative;
z-index: 2;
animation: float 3s ease-in-out infinite;
}
.empty-decoration {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.decoration-circle {
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
&:nth-child(1) {
width: 60px;
height: 60px;
top: 10%;
left: 10%;
animation: pulse 2s ease-in-out infinite;
}
&:nth-child(2) {
width: 40px;
height: 40px;
bottom: 20%;
right: 15%;
animation: pulse 2s ease-in-out infinite 0.5s;
}
&:nth-child(3) {
width: 30px;
height: 30px;
top: 60%;
right: 5%;
animation: pulse 2s ease-in-out infinite 1s;
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.2);
opacity: 0.3;
}
}
.empty-description {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-top: 20px;
}
.empty-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #303133;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.empty-text {
margin: 0;
font-size: 14px;
color: #606266;
line-height: 1.6;
max-width: 360px;
}
.empty-tips {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 100%);
border-radius: 8px;
font-size: 13px;
color: #667eea;
margin-top: 8px;
.el-icon {
font-size: 16px;
flex-shrink: 0;
}
}
/* 警告卡片 */
.warning-card {
border-radius: 12px;

View File

@@ -0,0 +1,358 @@
<script lang="ts" setup>
import { ElMessage } from 'element-plus';
import { computed, ref, watch } from 'vue';
interface TokenFormData {
id?: string;
name: string;
expireTime: string;
premiumQuotaLimit: number | null;
quotaUnit: string;
}
interface Props {
visible: boolean;
mode: 'create' | 'edit';
formData?: TokenFormData;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
mode: 'create',
formData: () => ({
name: '',
expireTime: '',
premiumQuotaLimit: 0,
quotaUnit: '万',
}),
});
const emit = defineEmits<{
'update:visible': [value: boolean];
'confirm': [data: TokenFormData];
}>();
const localFormData = ref<TokenFormData>({
name: '',
expireTime: '',
premiumQuotaLimit: 0,
quotaUnit: '万',
});
const submitting = ref(false);
const neverExpire = ref(false); // 永不过期开关
const unlimitedQuota = ref(false); // 无限制额度开关
const quotaUnitOptions = [
{ label: '个', value: '个', multiplier: 1 },
{ label: '十', value: '十', multiplier: 10 },
{ label: '百', value: '百', multiplier: 100 },
{ label: '千', value: '千', multiplier: 1000 },
{ label: '万', value: '万', multiplier: 10000 },
{ label: '亿', value: '亿', multiplier: 100000000 },
];
// 监听visible变化重置表单
watch(() => props.visible, (newVal) => {
if (newVal) {
if (props.mode === 'edit' && props.formData) {
// 编辑模式:转换后端数据为展示数据
const quota = props.formData.premiumQuotaLimit || 0;
let displayValue = quota;
let unit = '个';
// 判断是否无限制
unlimitedQuota.value = quota === 0;
if (!unlimitedQuota.value) {
// 自动选择合适的单位
if (quota >= 100000000 && quota % 100000000 === 0) {
displayValue = quota / 100000000;
unit = '亿';
}
else if (quota >= 10000 && quota % 10000 === 0) {
displayValue = quota / 10000;
unit = '万';
}
else if (quota >= 1000 && quota % 1000 === 0) {
displayValue = quota / 1000;
unit = '千';
}
else if (quota >= 100 && quota % 100 === 0) {
displayValue = quota / 100;
unit = '百';
}
else if (quota >= 10 && quota % 10 === 0) {
displayValue = quota / 10;
unit = '十';
}
}
// 判断是否永不过期
neverExpire.value = !props.formData.expireTime;
localFormData.value = {
...props.formData,
premiumQuotaLimit: displayValue,
quotaUnit: unit,
};
}
else {
// 新增模式:重置表单
localFormData.value = {
name: '',
expireTime: '',
premiumQuotaLimit: 1,
quotaUnit: '万',
};
neverExpire.value = false;
unlimitedQuota.value = false;
}
submitting.value = false;
}
});
// 监听永不过期开关
watch(neverExpire, (newVal) => {
if (newVal) {
localFormData.value.expireTime = '';
}
});
// 监听无限制开关
watch(unlimitedQuota, (newVal) => {
if (newVal) {
localFormData.value.premiumQuotaLimit = 0;
}
});
// 关闭对话框
function handleClose() {
if (submitting.value)
return;
emit('update:visible', false);
}
// 确认提交
async function handleConfirm() {
if (!localFormData.value.name.trim()) {
ElMessage.warning('请输入API密钥名称');
return;
}
if (!neverExpire.value && !localFormData.value.expireTime) {
ElMessage.warning('请选择过期时间');
return;
}
if (!unlimitedQuota.value && localFormData.value.premiumQuotaLimit <= 0) {
ElMessage.warning('请输入有效的配额限制');
return;
}
submitting.value = true;
try {
// 将展示值转换为实际值
let actualQuota = null;
if (!unlimitedQuota.value) {
const unit = quotaUnitOptions.find(u => u.value === localFormData.value.quotaUnit);
actualQuota = localFormData.value.premiumQuotaLimit * (unit?.multiplier || 1);
}
const submitData: TokenFormData = {
...localFormData.value,
expireTime: neverExpire.value ? '' : localFormData.value.expireTime,
premiumQuotaLimit: actualQuota,
};
emit('confirm', submitData);
}
finally {
// 注意:这里不设置 submitting.value = false
// 因为父组件会关闭对话框watch会重置状态
}
}
const dialogTitle = computed(() => props.mode === 'create' ? '新增 API密钥' : '编辑 API密钥');
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="540px"
:close-on-click-modal="false"
:show-close="!submitting"
@close="handleClose"
>
<el-form :model="localFormData" label-width="110px" label-position="right">
<el-form-item label="API密钥名称" required>
<el-input
v-model="localFormData.name"
placeholder="例如:生产环境、测试环境、开发环境"
maxlength="50"
show-word-limit
clearable
:disabled="submitting"
>
<template #prefix>
<el-icon><i-ep-collection-tag /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="过期时间">
<div class="form-item-with-switch">
<el-switch
v-model="neverExpire"
active-text="永不过期"
:disabled="submitting"
class="expire-switch"
/>
<el-date-picker
v-if="!neverExpire"
v-model="localFormData.expireTime"
type="datetime"
placeholder="选择过期时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss"
style="width: 100%"
clearable
:disabled="submitting"
:disabled-date="(time: Date) => time.getTime() < Date.now()"
>
<template #prefix>
<el-icon><i-ep-clock /></el-icon>
</template>
</el-date-picker>
</div>
<div v-if="!neverExpire" class="form-hint">
<el-icon><i-ep-warning /></el-icon>
API密钥将在过期时间后自动失效
</div>
</el-form-item>
<el-form-item label="配额限制">
<div class="form-item-with-switch">
<el-switch
v-model="unlimitedQuota"
active-text="无限制"
:disabled="submitting"
class="quota-switch"
/>
<div v-if="!unlimitedQuota" class="quota-input-group">
<el-input-number
v-model="localFormData.premiumQuotaLimit"
:min="1"
:precision="0"
:controls="true"
controls-position="right"
placeholder="请输入配额"
class="quota-number"
:disabled="submitting"
/>
<el-select
v-model="localFormData.quotaUnit"
class="quota-unit"
placeholder="单位"
:disabled="submitting"
>
<el-option
v-for="option in quotaUnitOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</div>
</div>
<div v-if="!unlimitedQuota" class="form-hint">
<el-icon><i-ep-info-filled /></el-icon>
超出配额后API密钥将无法继续使用
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button :disabled="submitting" @click="handleClose">
取消
</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="submitting"
@click="handleConfirm"
>
{{ mode === 'create' ? '创建' : '保存' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-item-with-switch {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.expire-switch,
.quota-switch {
--el-switch-on-color: #67c23a;
}
.quota-input-group {
display: flex;
gap: 10px;
width: 100%;
}
.quota-number {
flex: 1;
:deep(.el-input__wrapper) {
width: 100%;
}
}
.quota-unit {
width: 100px;
}
.form-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
font-size: 13px;
color: #606266;
background: #f4f4f5;
border-radius: 6px;
border-left: 3px solid #409eff;
.el-icon {
font-size: 14px;
color: #409eff;
flex-shrink: 0;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
:deep(.el-form-item__label) {
font-weight: 600;
color: #303133;
}
:deep(.el-input__prefix) {
color: #909399;
}
</style>

View File

@@ -11,7 +11,7 @@ import {
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { getLast7DaysTokenUsage, getModelTokenUsage } from '@/api';
import { getLast7DaysTokenUsage, getModelTokenUsage, getSelectableTokenInfo } from '@/api';
// 注册必要的组件
echarts.use([
@@ -48,16 +48,54 @@ const totalTokens = ref(0);
const usageData = ref<any[]>([]);
const modelUsageData = ref<any[]>([]);
// Token选择相关
const selectedTokenId = ref<string>(''); // 空字符串表示查询全部
const tokenOptions = ref<any[]>([]);
const tokenOptionsLoading = ref(false);
// 计算属性:是否有模型数据
const hasModelData = computed(() => modelUsageData.value.length > 0);
// 计算属性当前选择的token名称
const selectedTokenName = computed(() => {
if (!selectedTokenId.value)
return '全部API密钥';
const token = tokenOptions.value.find(t => t.tokenId === selectedTokenId.value);
return token?.name || '未知API密钥';
});
// 获取可选择的Token列表
async function fetchTokenOptions() {
try {
tokenOptionsLoading.value = true;
const res = await getSelectableTokenInfo();
if (res.data) {
// 不再过滤禁用的token全部显示
tokenOptions.value = res.data;
}
}
catch (error) {
console.error('获取API密钥列表失败:', error);
ElMessage.error('获取TAPI密钥列表失败');
}
finally {
tokenOptionsLoading.value = false;
}
}
// Token选择变化
function handleTokenChange() {
fetchUsageData();
}
// 获取用量数据
async function fetchUsageData() {
loading.value = true;
try {
const tokenId = selectedTokenId.value || undefined;
const [res, res2] = await Promise.all([
getLast7DaysTokenUsage(),
getModelTokenUsage(),
getLast7DaysTokenUsage(tokenId),
getModelTokenUsage(tokenId),
]);
usageData.value = res.data || [];
@@ -235,49 +273,47 @@ function updatePieChart() {
formatter: '{a} <br/>{b}: {c} tokens ({d}%)',
},
legend: {
orient: isManyItems ? 'vertical' : 'horizontal',
right: isManyItems ? 10 : 'auto',
bottom: isManyItems ? 0 : 10,
type: isManyItems ? 'scroll' : 'plain',
pageIconColor: '#3a4de9',
pageIconInactiveColor: '#ccc',
pageTextStyle: { color: '#333' },
itemGap: isSmallContainer ? 5 : 10,
itemWidth: isSmallContainer ? 15 : 25,
itemHeight: isSmallContainer ? 10 : 14,
textStyle: {
fontSize: isSmallContainer ? 10 : 12,
},
formatter(name: string) {
return name.length > 15 ? `${name.substring(0, 12)}...` : name;
},
data: data.map(item => item.name),
show: false, // 隐藏图例,使用标签线代替
},
series: [
{
name: '模型用量',
type: 'pie',
radius: ['50%', '70%'],
center: isManyItems ? ['40%', '50%'] : ['50%', '50%'],
avoidLabelOverlap: false,
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
show: true,
position: 'outside',
formatter: '{b}: {d}%',
fontSize: 13,
fontWeight: 600,
color: '#333',
},
emphasis: {
label: {
show: true,
fontSize: '18',
fontSize: 16,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.3)',
},
},
labelLine: {
show: false,
show: true,
length: 15,
length2: 10,
lineStyle: {
width: 1.5,
},
},
data,
},
@@ -453,6 +489,7 @@ watch([pieContainerSize.width, barContainerSize.width], () => {
onMounted(() => {
initCharts();
fetchTokenOptions();
fetchUsageData();
});
@@ -475,19 +512,56 @@ onBeforeUnmount(() => {
<el-icon><PieChart /></el-icon>
Token用量统计
</h2>
<el-button
:icon="FullScreen"
circle
plain
size="small"
@click="toggleFullscreen"
/>
<div class="header-actions">
<el-select
v-model="selectedTokenId"
placeholder="选择API密钥"
clearable
filterable
:loading="tokenOptionsLoading"
class="token-selector"
@change="handleTokenChange"
>
<el-option label="全部Token" value="">
<div class="token-option">
<el-icon class="option-icon all-icon">
<i-ep-folder-opened />
</el-icon>
<span class="option-label">全部Token</span>
</div>
</el-option>
<el-option
v-for="token in tokenOptions"
:key="token.tokenId"
:label="token.name"
:value="token.tokenId"
:disabled="token.isDisabled"
>
<div class="token-option" :class="{ 'disabled-token': token.isDisabled }">
<el-icon class="option-icon" :class="{ 'disabled-icon': token.isDisabled }">
<i-ep-key />
</el-icon>
<span class="option-label">{{ token.name }}</span>
<el-tag v-if="token.isDisabled" type="info" size="small" effect="plain" class="disabled-tag">
已禁用
</el-tag>
</div>
</el-option>
</el-select>
<el-button
:icon="FullScreen"
circle
plain
size="small"
@click="toggleFullscreen"
/>
</div>
</div>
<el-card v-loading="loading" class="chart-card">
<template #header>
<div class="card-header">
<span class="card-title">📊 近七天每日Token消耗量</span>
<span class="card-title">📊 近七天每日Token消耗量{{ selectedTokenId ? ` (${selectedTokenName})` : '' }}</span>
<el-tag type="primary" size="large" effect="dark">
近七日总计: {{ totalTokens }} tokens
</el-tag>
@@ -501,7 +575,7 @@ onBeforeUnmount(() => {
<el-card v-loading="loading" class="chart-card">
<template #header>
<div class="card-header">
<span class="card-title">🥧 各模型Token消耗占比</span>
<span class="card-title">🥧 各模型Token消耗占比{{ selectedTokenId ? ` (${selectedTokenName})` : '' }}</span>
</div>
</template>
<div class="chart-container">
@@ -512,7 +586,7 @@ onBeforeUnmount(() => {
<el-card v-loading="loading" class="chart-card">
<template #header>
<div class="card-header">
<span class="card-title">📈 各模型总Token消耗量</span>
<span class="card-title">📈 各模型总Token消耗量{{ selectedTokenId ? ` (${selectedTokenName})` : '' }}</span>
</div>
</template>
<div class="chart-container">
@@ -560,6 +634,62 @@ onBeforeUnmount(() => {
border-bottom: 2px solid #e9ecef;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.token-selector {
width: 240px;
}
.token-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
&.disabled-token {
opacity: 0.6;
.option-label {
text-decoration: line-through;
color: #909399;
}
}
}
.option-icon {
color: #667eea;
font-size: 16px;
flex-shrink: 0;
&.all-icon {
color: #409eff;
}
&.disabled-icon {
color: #c0c4cc;
}
}
.option-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.disabled-tag {
margin-left: auto;
flex-shrink: 0;
font-size: 11px;
padding: 0 6px;
height: 18px;
line-height: 18px;
}
.header h2 {
display: flex;
align-items: center;

View File

@@ -1,11 +1,9 @@
import { useUserStore } from '@/stores/index.js';
import {useUserStore} from '@/stores/index.js';
// 判断是否是 VIP 用户
export function isUserVip(): boolean {
const userStore = useUserStore();
const userRoles = userStore.userInfo?.roles ?? [];
const isVip = userRoles.some((role: any) => role.roleCode === 'YiXinAi-Vip');
return isVip;
return userStore.userInfo.isVip;
}
// 用户头像

View File

@@ -69,6 +69,7 @@ declare module 'vue' {
SupportModelList: typeof import('./../src/components/userPersonalCenter/components/SupportModelList.vue')['default']
SvgIcon: typeof import('./../src/components/SvgIcon/index.vue')['default']
SystemAnnouncementDialog: typeof import('./../src/components/SystemAnnouncementDialog/index.vue')['default']
TokenFormDialog: typeof import('./../src/components/userPersonalCenter/components/TokenFormDialog.vue')['default']
UsageStatistics: typeof import('./../src/components/userPersonalCenter/components/UsageStatistics.vue')['default']
UserManagement: typeof import('./../src/components/userPersonalCenter/components/UserManagement.vue')['default']
VerificationCode: typeof import('./../src/components/LoginDialog/components/FormLogin/VerificationCode.vue')['default']

View File

@@ -6,7 +6,6 @@ interface ImportMetaEnv {
readonly VITE_WEB_ENV: string;
readonly VITE_WEB_BASE_API: string;
readonly VITE_API_URL: string;
readonly VITE_BUILD_COMPRESS: string;
readonly VITE_SSO_SEVER_URL: string;
readonly VITE_APP_VERSION: string;
}