mirror of
https://gitee.com/ccnetcore/Yi
synced 2026-04-23 18:06:36 +08:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dcbfcad33 | ||
|
|
f64fd43951 | ||
|
|
551597765c | ||
|
|
bfda33280a | ||
|
|
8d0411f1f4 | ||
|
|
3995d4acab | ||
|
|
6ff5727156 | ||
|
|
f654386dfe | ||
|
|
c03ef82643 | ||
|
|
525545329b | ||
|
|
755cb6f509 | ||
|
|
55469708f0 | ||
|
|
94c52c62fe | ||
|
|
37b4709d76 | ||
|
|
86555af6ce | ||
|
|
ddb00879f4 | ||
|
|
2d0ca08314 | ||
|
|
b78ecf27d5 | ||
|
|
02a5f69958 | ||
|
|
cf5bf746ef | ||
|
|
0a5e40ee25 | ||
|
|
51a266ef58 | ||
|
|
1f0901c90c | ||
|
|
a725c06396 | ||
|
|
54547f0d7c | ||
|
|
afe9c8bcae | ||
|
|
688d93e5c1 | ||
|
|
4c65b2398d | ||
|
|
41435f1aa3 | ||
|
|
20206bbc44 | ||
|
|
f2dc0d1825 | ||
|
|
51b4d1b072 | ||
|
|
9180799e4e | ||
|
|
9788b9182b | ||
|
|
260b9a4795 | ||
|
|
9380e3daa8 | ||
|
|
8e8338743d |
@@ -14,8 +14,10 @@ namespace Yi.Framework.SqlSugarCore.Repositories
|
||||
{
|
||||
public class SqlSugarRepository<TEntity> : ISqlSugarRepository<TEntity>, IRepository<TEntity> where TEntity : class, IEntity, new()
|
||||
{
|
||||
[Obsolete("使用GetDbContextAsync()")]
|
||||
public ISqlSugarClient _Db => AsyncContext.Run(async () => await GetDbContextAsync());
|
||||
|
||||
[Obsolete("使用AsQueryable()")]
|
||||
public ISugarQueryable<TEntity> _DbQueryable => _Db.Queryable<TEntity>();
|
||||
|
||||
private readonly ISugarDbContextProvider<ISqlSugarDbContext> _dbContextProvider;
|
||||
@@ -320,12 +322,12 @@ namespace Yi.Framework.SqlSugarCore.Repositories
|
||||
|
||||
public virtual async Task<List<TEntity>> GetPageListAsync(Expression<Func<TEntity, bool>> whereExpression, int pageNum, int pageSize)
|
||||
{
|
||||
return await (await GetDbSimpleClientAsync()).GetPageListAsync(whereExpression, new PageModel() { PageIndex = pageNum, PageSize = pageSize });
|
||||
return await (await AsQueryable()).Where(whereExpression).ToPageListAsync(pageNum, pageSize);
|
||||
}
|
||||
|
||||
public virtual async Task<List<TEntity>> GetPageListAsync(Expression<Func<TEntity, bool>> whereExpression, int pageNum, int pageSize, Expression<Func<TEntity, object>>? orderByExpression = null, OrderByType orderByType = OrderByType.Asc)
|
||||
{
|
||||
return await (await GetDbSimpleClientAsync()).GetPageListAsync(whereExpression, new PageModel { PageIndex = pageNum, PageSize = pageSize }, orderByExpression, orderByType);
|
||||
return await (await AsQueryable()).Where(whereExpression) .OrderBy( orderByExpression,orderByType).ToPageListAsync(pageNum, pageSize);
|
||||
}
|
||||
|
||||
public virtual async Task<TEntity> GetSingleAsync(Expression<Func<TEntity, bool>> whereExpression)
|
||||
|
||||
@@ -62,4 +62,9 @@ public class ModelGetListOutput
|
||||
/// 备注信息
|
||||
/// </summary>
|
||||
public string? Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否为尊享包
|
||||
/// </summary>
|
||||
public bool IsPremiumPackage { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics;
|
||||
|
||||
public class UsageStatisticsGetInput
|
||||
{
|
||||
/// <summary>
|
||||
/// tokenId
|
||||
/// </summary>
|
||||
public Guid? TokenId { get; set; }
|
||||
}
|
||||
@@ -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用量统计
|
||||
|
||||
@@ -87,7 +87,8 @@ public class AiChatService : ApplicationService
|
||||
SystemPrompt = null,
|
||||
ApiHost = null,
|
||||
ApiKey = null,
|
||||
Remark = x.Description
|
||||
Remark = x.Description,
|
||||
IsPremiumPackage = PremiumPackageConst.ModeIds.Contains(x.ModelId)
|
||||
}).ToListAsync();
|
||||
return output;
|
||||
}
|
||||
@@ -134,7 +135,7 @@ public class AiChatService : ApplicationService
|
||||
|
||||
//ai网关代理httpcontext
|
||||
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
||||
CurrentUser.Id, sessionId, cancellationToken);
|
||||
CurrentUser.Id, sessionId, null, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +172,6 @@ public class AiChatService : ApplicationService
|
||||
|
||||
//ai网关代理httpcontext
|
||||
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
|
||||
CurrentUser.Id, null, cancellationToken);
|
||||
CurrentUser.Id, null, null, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,12 @@ namespace Yi.Framework.AiHub.Domain.Shared.Consts;
|
||||
|
||||
public class PremiumPackageConst
|
||||
{
|
||||
public static List<string> ModeIds = ["claude-sonnet-4-5-20250929"];
|
||||
public static List<string> ModeIds =
|
||||
[
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-opus-4-5-20251101",
|
||||
"gemini-3-pro-preview",
|
||||
"gpt-5.1-codex-max"
|
||||
];
|
||||
}
|
||||
@@ -56,4 +56,9 @@ public class AiModelDescribe
|
||||
/// 模型额外信息
|
||||
/// </summary>
|
||||
public string? ModelExtraInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型倍率
|
||||
/// </summary>
|
||||
public decimal Multiplier { get; set; }
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public class AnthropicStreamDto
|
||||
};
|
||||
|
||||
|
||||
public void SupplementalMultiplier(double multiplier)
|
||||
public void SupplementalMultiplier(decimal multiplier)
|
||||
{
|
||||
if (this.Usage is not null)
|
||||
{
|
||||
@@ -130,7 +130,7 @@ public class AnthropicChatCompletionDto
|
||||
CompletionTokensDetails = null
|
||||
};
|
||||
|
||||
public void SupplementalMultiplier(double multiplier)
|
||||
public void SupplementalMultiplier(decimal multiplier)
|
||||
{
|
||||
if (this.Usage is not null)
|
||||
{
|
||||
|
||||
@@ -61,7 +61,7 @@ public record ThorChatCompletionsResponse
|
||||
[JsonPropertyName("error")]
|
||||
public ThorError? Error { get; set; }
|
||||
|
||||
public void SupplementalMultiplier(double multiplier)
|
||||
public void SupplementalMultiplier(decimal multiplier)
|
||||
{
|
||||
if (this.Usage is not null)
|
||||
{
|
||||
@@ -73,6 +73,9 @@ public record ThorChatCompletionsResponse
|
||||
(int)Math.Round((this.Usage.CompletionTokens ?? 0) * multiplier);
|
||||
this.Usage.PromptTokens =
|
||||
(int)Math.Round((this.Usage.PromptTokens ?? 0) * multiplier);
|
||||
|
||||
this.Usage.TotalTokens =
|
||||
(int)Math.Round((this.Usage.TotalTokens ?? 0) * multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,11 +102,6 @@ public enum GoodsTypeEnum
|
||||
[Price(155.4, 6, 25.9)] [DisplayName("YiXinVip 6 month", "6个月", "年度热销")] [GoodsCategory(GoodsCategoryType.Vip)]
|
||||
YiXinVip6 = 6,
|
||||
|
||||
[Price(183.2, 8, 22.9)]
|
||||
[DisplayName("YiXinVip 8 month", "8个月(推荐)", "限时活动,超高性价比")]
|
||||
[GoodsCategory(GoodsCategoryType.Vip)]
|
||||
YiXinVip8 = 8,
|
||||
|
||||
// 尊享包服务 - 需要VIP资格才能购买
|
||||
[Price(188.9, 0, 1750)]
|
||||
[DisplayName("YiXinPremiumPackage 5000W Tokens", "5000万Tokens", "简单尝试")]
|
||||
|
||||
@@ -14,8 +14,6 @@ public class AnthropicChatCompletionsService(
|
||||
ILogger<AnthropicChatCompletionsService> logger)
|
||||
: IAnthropicChatCompletionService
|
||||
{
|
||||
public const double ClaudeMultiplier = 1.3d;
|
||||
|
||||
public async Task<AnthropicChatCompletionDto> ChatCompletionsAsync(AiModelDescribe options, AnthropicInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -86,8 +84,7 @@ public class AnthropicChatCompletionsService(
|
||||
var value =
|
||||
await response.Content.ReadFromJsonAsync<AnthropicChatCompletionDto>(ThorJsonSerializer.DefaultOptions,
|
||||
cancellationToken: cancellationToken);
|
||||
value.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
|
||||
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -169,8 +166,7 @@ public class AnthropicChatCompletionsService(
|
||||
|
||||
var result = JsonSerializer.Deserialize<AnthropicStreamDto>(data,
|
||||
ThorJsonSerializer.DefaultOptions);
|
||||
|
||||
result.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
|
||||
|
||||
yield return (eventType, result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ public sealed class ClaudiaChatCompletionsService(
|
||||
var response = await client.HttpRequestRaw(options.Endpoint.TrimEnd('/') + "/v1/messages", new
|
||||
{
|
||||
model = input.Model,
|
||||
max_tokens = input.MaxTokens ?? 2048,
|
||||
max_tokens = input.MaxTokens ?? 64000,
|
||||
stream = true,
|
||||
tool_choice,
|
||||
system = CreateMessage(input.Messages.Where(x => x.Role == "system").ToList(), options),
|
||||
@@ -716,7 +716,7 @@ public sealed class ClaudiaChatCompletionsService(
|
||||
output.Usage.PromptTokens = output.Usage.InputTokens;
|
||||
output.Usage.CompletionTokens = output.Usage.OutputTokens;
|
||||
output.Usage.TotalTokens = output.Usage.InputTokens + output.Usage.OutputTokens;
|
||||
output.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
|
||||
|
||||
yield return output;
|
||||
}
|
||||
}
|
||||
@@ -873,7 +873,6 @@ public sealed class ClaudiaChatCompletionsService(
|
||||
}
|
||||
|
||||
thor.Usage.TotalTokens = thor.Usage.InputTokens + thor.Usage.OutputTokens;
|
||||
thor.SupplementalMultiplier(AnthropicChatCompletionsService.ClaudeMultiplier);
|
||||
return thor;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -60,4 +60,9 @@ public class AiModelEntity : Entity<Guid>, IOrderNum, ISoftDelete
|
||||
/// 模型Api类型,现支持同一个模型id,多种接口格式
|
||||
/// </summary>
|
||||
public ModelApiTypeEnum ModelApiType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型倍率
|
||||
/// </summary>
|
||||
public decimal Multiplier { get; set; } = 1;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -77,7 +77,8 @@ public class AiGateWayManager : DomainService
|
||||
ModelName = model.Name,
|
||||
Description = model.Description,
|
||||
AppExtraUrl = app.ExtraUrl,
|
||||
ModelExtraInfo = model.ExtraInfo
|
||||
ModelExtraInfo = model.ExtraInfo,
|
||||
Multiplier = model.Multiplier
|
||||
})
|
||||
.FirstAsync();
|
||||
if (aiModelDescribe is null)
|
||||
@@ -106,6 +107,7 @@ public class AiGateWayManager : DomainService
|
||||
|
||||
await foreach (var result in chatService.CompleteChatStreamAsync(modelDescribe, request, cancellationToken))
|
||||
{
|
||||
result.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||
yield return result;
|
||||
}
|
||||
}
|
||||
@@ -118,12 +120,14 @@ public class AiGateWayManager : DomainService
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传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);
|
||||
@@ -134,6 +138,7 @@ public class AiGateWayManager : DomainService
|
||||
var chatService =
|
||||
LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
|
||||
var data = await chatService.CompleteChatAsync(modelDescribe, request, cancellationToken);
|
||||
data.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||
if (userId is not null)
|
||||
{
|
||||
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
|
||||
@@ -142,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
|
||||
@@ -151,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))
|
||||
@@ -176,6 +181,7 @@ public class AiGateWayManager : DomainService
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传null或Guid.Empty)</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task CompleteChatStreamForStatisticsAsync(
|
||||
@@ -183,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;
|
||||
@@ -285,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
|
||||
@@ -293,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))
|
||||
@@ -316,10 +323,11 @@ public class AiGateWayManager : DomainService
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传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
|
||||
{
|
||||
@@ -347,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
|
||||
@@ -355,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))
|
||||
@@ -381,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 Id(Web端传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
|
||||
{
|
||||
@@ -471,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)
|
||||
{
|
||||
@@ -506,6 +515,7 @@ public class AiGateWayManager : DomainService
|
||||
|
||||
await foreach (var result in chatService.StreamChatCompletionsAsync(modelDescribe, request, cancellationToken))
|
||||
{
|
||||
result.Item2.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||
yield return result;
|
||||
}
|
||||
}
|
||||
@@ -518,12 +528,14 @@ public class AiGateWayManager : DomainService
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传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);
|
||||
@@ -534,6 +546,9 @@ public class AiGateWayManager : DomainService
|
||||
var chatService =
|
||||
LazyServiceProvider.GetRequiredKeyedService<IAnthropicChatCompletionService>(modelDescribe.HandlerName);
|
||||
var data = await chatService.ChatCompletionsAsync(modelDescribe, request, cancellationToken);
|
||||
|
||||
data.SupplementalMultiplier(modelDescribe.Multiplier);
|
||||
|
||||
if (userId is not null)
|
||||
{
|
||||
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
|
||||
@@ -542,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
|
||||
@@ -550,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;
|
||||
@@ -572,6 +587,7 @@ public class AiGateWayManager : DomainService
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="tokenId">Token Id(Web端传null或Guid.Empty)</param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task AnthropicCompleteChatStreamForStatisticsAsync(
|
||||
@@ -579,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;
|
||||
@@ -620,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
|
||||
@@ -628,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)
|
||||
|
||||
@@ -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 Id(Web端传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 Id(Web端传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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace Yi.Framework.AiHub.Domain
|
||||
nameof(OpenAiChatCompletionsService));
|
||||
services.AddKeyedTransient<IChatCompletionService, ClaudiaChatCompletionsService>(
|
||||
nameof(ClaudiaChatCompletionsService));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Anthropic ChatCompletion
|
||||
@@ -73,6 +74,34 @@ namespace Yi.Framework.AiHub.Domain
|
||||
//ai模型特殊性兼容处理
|
||||
Configure<SpecialCompatibleOptions>(options =>
|
||||
{
|
||||
options.Handles.Add(request =>
|
||||
{
|
||||
if (request.Model == "gpt-5.1-chat")
|
||||
{
|
||||
request.Temperature = null;
|
||||
request.TopP = null;
|
||||
request.MaxCompletionTokens = request.MaxTokens;
|
||||
request.MaxTokens = null;
|
||||
request.PresencePenalty = null;
|
||||
}
|
||||
});
|
||||
|
||||
options.Handles.Add(request =>
|
||||
{
|
||||
if (request.Model =="gpt-5-mini")
|
||||
{
|
||||
request.Temperature = null;
|
||||
request.TopP = null;
|
||||
}
|
||||
});
|
||||
options.Handles.Add(request =>
|
||||
{
|
||||
if (request.Model == "databricks-claude-sonnet-4")
|
||||
{
|
||||
request.PresencePenalty = null;
|
||||
}
|
||||
});
|
||||
|
||||
options.Handles.Add(request =>
|
||||
{
|
||||
if (request.Model == "o1")
|
||||
@@ -101,9 +130,9 @@ namespace Yi.Framework.AiHub.Domain
|
||||
});
|
||||
options.Handles.Add(request =>
|
||||
{
|
||||
if (request.MaxTokens >= 16384)
|
||||
if (request.MaxTokens > 128000)
|
||||
{
|
||||
request.MaxTokens = 16384;
|
||||
request.MaxTokens = 128000;
|
||||
}
|
||||
});
|
||||
options.AnthropicHandles.add(request =>
|
||||
@@ -128,7 +157,7 @@ namespace Yi.Framework.AiHub.Domain
|
||||
{
|
||||
builder.ConfigureHttpClient(client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("User-Agent","Apifox/1.0.0 (https://apifox.com)");
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "Apifox/1.0.0 (https://apifox.com)");
|
||||
client.Timeout = TimeSpan.FromMinutes(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
"ComputedRef": true,
|
||||
"DirectiveBinding": true,
|
||||
"EffectScope": true,
|
||||
"ElMessage": true,
|
||||
"ElMessageBox": true,
|
||||
"ExtractDefaultPropTypes": true,
|
||||
"ExtractPropTypes": true,
|
||||
"ExtractPublicPropTypes": true,
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
<body>
|
||||
<!-- 加载动画容器 -->
|
||||
<div id="yixinai-loader" class="loader-container">
|
||||
<div class="loader-title">意心Ai 2.3</div>
|
||||
<div class="loader-title">意心Ai 2.5</div>
|
||||
<div class="loader-subtitle">海外地址,仅首次访问预计加载约10秒</div>
|
||||
<div class="loader-logo">
|
||||
<div class="pulse-box"></div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
] */
|
||||
|
||||
@@ -99,14 +99,16 @@ function handleModelClick(item: GetSessionListVO) {
|
||||
规则2:金色光泽(VIP/付费)
|
||||
规则3:彩色流光(尊享/高级)
|
||||
-------------------------------- */
|
||||
function getModelStyleClass(modelName: any) {
|
||||
if (!modelName) {
|
||||
function getModelStyleClass(mode: any) {
|
||||
if (!mode) {
|
||||
return;
|
||||
}
|
||||
const name = modelName.toLowerCase();
|
||||
// isPremiumPackage
|
||||
const name = mode.modelName.toLowerCase();
|
||||
const isPremiumPackage = mode.isPremiumPackage;
|
||||
|
||||
// 规则3:彩色流光
|
||||
if (name.includes('claude-sonnet-4-5-20250929')) {
|
||||
if (isPremiumPackage) {
|
||||
return `
|
||||
text-transparent bg-clip-text
|
||||
bg-[linear-gradient(45deg,#ff0000,#ff8000,#ffff00,#00ff00,#00ffff,#0000ff,#8000ff,#ff0080)]
|
||||
@@ -167,7 +169,7 @@ function getWrapperClass(item: GetSessionListVO) {
|
||||
<div class="model-select-box-icon">
|
||||
<SvgIcon name="models" size="12" />
|
||||
</div>
|
||||
<div :class="getModelStyleClass(currentModelName)" class="model-select-box-text font-size-12px">
|
||||
<div :class="getModelStyleClass(modelStore.currentModelInfo)" class="model-select-box-text font-size-12px">
|
||||
{{ currentModelName }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +190,7 @@ function getWrapperClass(item: GetSessionListVO) {
|
||||
:offset="[12, 0]"
|
||||
>
|
||||
<template #trigger>
|
||||
<span :class="getModelStyleClass(item.modelName)">
|
||||
<span :class="getModelStyleClass(item)">
|
||||
{{ item.modelName }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
// 图标文档 https://remixicon.com/
|
||||
const props = defineProps<{
|
||||
className?: string;
|
||||
name: string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
// 打开AI使用教程(跳转到外部链接)
|
||||
function openTutorial() {
|
||||
window.open('https://ccnetcore.com/article/3a1bc4d1-6a7d-751d-91cc-2817eb2ddcde', '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ai-tutorial-btn-container" data-tour="ai-tutorial-link">
|
||||
<div
|
||||
class="ai-tutorial-btn"
|
||||
title="点击跳转YiXinAI玩法指南专栏"
|
||||
@click="openTutorial"
|
||||
>
|
||||
<!-- PC端显示文字 -->
|
||||
<span class="pc-text">AI使用教程</span>
|
||||
<!-- 移动端显示图标 -->
|
||||
<svg
|
||||
class="mobile-icon w-6 h-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 14l9-5-9-5-9 5 9 5z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 14l6.16-3.422A12.083 12.083 0 0118 13.5c0 2.579-3.582 4.5-6 4.5s-6-1.921-6-4.5c0-.432.075-.85.198-1.244L12 14z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ai-tutorial-btn-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ai-tutorial-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #E6A23C;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #F1B44C;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
// PC端显示文字,隐藏图标
|
||||
.pc-text {
|
||||
display: inline;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.mobile-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端显示图标,隐藏文字
|
||||
@media (max-width: 768px) {
|
||||
.ai-tutorial-btn-container {
|
||||
.ai-tutorial-btn {
|
||||
.pc-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-icon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,7 +33,7 @@ function openAnnouncement() {
|
||||
@click="openAnnouncement"
|
||||
>
|
||||
<!-- PC端显示文字 -->
|
||||
<span class="pc-text">公告/活动</span>
|
||||
<span class="pc-text">公告</span>
|
||||
<!-- 移动端显示图标 -->
|
||||
<el-icon class="mobile-icon" :size="20">
|
||||
<Bell />
|
||||
@@ -62,11 +62,11 @@ function openAnnouncement() {
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #e6a23c;
|
||||
color: #409eff;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #ebb563;
|
||||
color: #66b1ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ const popoverList = ref([
|
||||
title: '用户中心',
|
||||
icon: 'settings-4-fill',
|
||||
},
|
||||
{
|
||||
key: '6',
|
||||
title: '新手引导',
|
||||
icon: 'dashboard-fill',
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
divider: true,
|
||||
@@ -100,7 +105,12 @@ function handleNavChange(nav: string) {
|
||||
function handleContactSupport() {
|
||||
rechargeLogRef.value?.contactCustomerService();
|
||||
}
|
||||
const { startHeaderTour } = useGuideTour();
|
||||
|
||||
// 开始引导教程
|
||||
function handleStartTutorial() {
|
||||
startHeaderTour();
|
||||
}
|
||||
// 点击
|
||||
function handleClick(item: any) {
|
||||
switch (item.key) {
|
||||
@@ -113,6 +123,9 @@ function handleClick(item: any) {
|
||||
case '5':
|
||||
openDialog();
|
||||
break;
|
||||
case '6':
|
||||
handleStartTutorial();
|
||||
break;
|
||||
case '4':
|
||||
popoverRef.value?.hide?.();
|
||||
ElMessageBox.confirm('退出登录不会丢失任何数据,你仍可以登录此账号。', '确认退出登录?', {
|
||||
@@ -278,49 +291,6 @@ watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- <div class="text-1.2xl font-bold text-gray-800 hover:text-blue-600 transition-colors"> -->
|
||||
<!-- <a -->
|
||||
<!-- href="https://ccnetcore.com/article/3a1bc4d1-6a7d-751d-91cc-2817eb2ddcde" -->
|
||||
<!-- target="_blank" -->
|
||||
<!-- class="flex items-center gap-2 group" -->
|
||||
<!-- style="color: #E6A23C;" -->
|
||||
<!-- title="点击跳转YiXinAI玩法指南专栏" -->
|
||||
<!-- > -->
|
||||
<!-- AI使用教程 -->
|
||||
<!-- </a> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<div class="text-1.2xl font-bold text-gray-800 hover:text-blue-600 transition-colors" data-tour="ai-tutorial-link">
|
||||
<a
|
||||
href="https://ccnetcore.com/article/3a1bc4d1-6a7d-751d-91cc-2817eb2ddcde"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 group"
|
||||
style="color: #E6A23C;"
|
||||
title="点击跳转YiXinAI玩法指南专栏"
|
||||
>
|
||||
<!-- PC端显示文字 -->
|
||||
<span class="pc-text">AI使用教程</span>
|
||||
<!-- 移动端显示图标,这里用一个示例SVG,实际可以换成你想要的 -->
|
||||
<svg
|
||||
class="inline md:hidden w-6 h-6 text-yellow-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 14l9-5-9-5-9 5 9 5z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 14l6.16-3.422A12.083 12.083 0 0118 13.5c0 2.579-3.582 4.5-6 4.5s-6-1.921-6-4.5c0-.432.075-.85.198-1.244L12 14z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
class="buy-btn flex items-center gap-2 px-5 py-2 font-semibold shadow-lg"
|
||||
data-tour="buy-btn"
|
||||
@@ -523,22 +493,4 @@ watch(() => guideTourStore.shouldStartUserCenterTour, (shouldStart) => {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* 默认 PC 端文字显示,图标隐藏 */
|
||||
.pc-text {
|
||||
display: inline;
|
||||
}
|
||||
.mobile-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端显示图标,隐藏文字 */
|
||||
@media (max-width: 768px) {
|
||||
.pc-text {
|
||||
display: none;
|
||||
}
|
||||
.mobile-icon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,13 +4,13 @@ import { onKeyStroke } from '@vueuse/core';
|
||||
import { SIDE_BAR_WIDTH } from '@/config/index';
|
||||
import { useDesignStore, useUserStore } from '@/stores';
|
||||
import { useSessionStore } from '@/stores/modules/session';
|
||||
import AiTutorialBtn from './components/AiTutorialBtn.vue';
|
||||
import AnnouncementBtn from './components/AnnouncementBtn.vue';
|
||||
import Avatar from './components/Avatar.vue';
|
||||
import Collapse from './components/Collapse.vue';
|
||||
import CreateChat from './components/CreateChat.vue';
|
||||
import LoginBtn from './components/LoginBtn.vue';
|
||||
import TitleEditing from './components/TitleEditing.vue';
|
||||
import TutorialBtn from './components/TutorialBtn.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const designStore = useDesignStore();
|
||||
@@ -72,7 +72,7 @@ onKeyStroke(event => event.ctrlKey && event.key.toLowerCase() === 'k', handleCtr
|
||||
<!-- 右边 -->
|
||||
<div class="right-box flex h-full items-center pr-20px flex-shrink-0 mr-auto flex-row">
|
||||
<AnnouncementBtn />
|
||||
<TutorialBtn />
|
||||
<AiTutorialBtn />
|
||||
<Avatar v-show="userStore.userInfo" />
|
||||
<LoginBtn v-show="!userStore.userInfo" />
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 用户头像
|
||||
|
||||
40
Yi.Ai.Vue3/types/components.d.ts
vendored
40
Yi.Ai.Vue3/types/components.d.ts
vendored
@@ -13,42 +13,6 @@ declare module 'vue' {
|
||||
CardFlipActivity: typeof import('./../src/components/userPersonalCenter/components/CardFlipActivity.vue')['default']
|
||||
DailyTask: typeof import('./../src/components/userPersonalCenter/components/DailyTask.vue')['default']
|
||||
DeepThinking: typeof import('./../src/components/DeepThinking/index.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
FilesSelect: typeof import('./../src/components/FilesSelect/index.vue')['default']
|
||||
IconSelect: typeof import('./../src/components/IconSelect/index.vue')['default']
|
||||
Indexl: typeof import('./../src/components/SupportModelProducts/indexl.vue')['default']
|
||||
@@ -69,12 +33,10 @@ 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']
|
||||
WelecomeText: typeof import('./../src/components/WelecomeText/index.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user