diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/UsageStatistics/HourlyTokenUsageDto.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/UsageStatistics/HourlyTokenUsageDto.cs new file mode 100644 index 00000000..ef86c78e --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/UsageStatistics/HourlyTokenUsageDto.cs @@ -0,0 +1,22 @@ +namespace Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics; + +/// +/// 每小时Token使用量统计DTO(柱状图) +/// +public class HourlyTokenUsageDto +{ + /// + /// 小时时间点 + /// + public DateTime Hour { get; set; } + + /// + /// 该小时总Token消耗量 + /// + public long TotalTokens { get; set; } + + /// + /// 各模型Token消耗明细 + /// + public List ModelBreakdown { get; set; } = new(); +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/UsageStatistics/ModelTodayUsageDto.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/UsageStatistics/ModelTodayUsageDto.cs new file mode 100644 index 00000000..08caab2d --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/UsageStatistics/ModelTodayUsageDto.cs @@ -0,0 +1,27 @@ +namespace Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics; + +/// +/// 模型今日使用量统计DTO(卡片列表) +/// +public class ModelTodayUsageDto +{ + /// + /// 模型ID + /// + public string ModelId { get; set; } + + /// + /// 今日使用次数 + /// + public int UsageCount { get; set; } + + /// + /// 今日消耗总Token数 + /// + public long TotalTokens { get; set; } + + /// + /// 模型图标URL + /// + public string? IconUrl { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/UsageStatistics/ModelTokenBreakdownDto.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/UsageStatistics/ModelTokenBreakdownDto.cs new file mode 100644 index 00000000..8c1e831e --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/UsageStatistics/ModelTokenBreakdownDto.cs @@ -0,0 +1,17 @@ +namespace Yi.Framework.AiHub.Application.Contracts.Dtos.UsageStatistics; + +/// +/// 模型Token堆叠数据DTO(用于柱状图) +/// +public class ModelTokenBreakdownDto +{ + /// + /// 模型ID + /// + public string ModelId { get; set; } + + /// + /// Token消耗量 + /// + public long Tokens { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/IServices/IUsageStatisticsService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/IServices/IUsageStatisticsService.cs index 99be00a6..31a2ef87 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/IServices/IUsageStatisticsService.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/IServices/IUsageStatisticsService.cs @@ -24,4 +24,16 @@ public interface IUsageStatisticsService /// /// 尊享服务Token用量统计 Task GetPremiumTokenUsageAsync(); + + /// + /// 获取当前用户近24小时每小时Token消耗统计(柱状图) + /// + /// 每小时Token使用量列表,包含各模型堆叠数据 + Task> GetLast24HoursTokenUsageAsync(UsageStatisticsGetInput input); + + /// + /// 获取当前用户今日各模型使用量统计(卡片列表) + /// + /// 模型今日使用量列表,包含使用次数和总Token + Task> GetTodayModelUsageAsync(UsageStatisticsGetInput input); } \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/UsageStatisticsService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/UsageStatisticsService.cs index 6f988d41..9ba060be 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/UsageStatisticsService.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/UsageStatisticsService.cs @@ -30,6 +30,7 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic private readonly ISqlSugarRepository _premiumPackageRepository; private readonly ISqlSugarRepository _tokenRepository; private readonly ModelManager _modelManager; + public UsageStatisticsService( ISqlSugarRepository messageRepository, ISqlSugarRepository usageStatisticsRepository, @@ -48,7 +49,7 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic /// 获取当前用户近7天的Token消耗统计 /// /// 每日Token使用量列表 - public async Task> GetLast7DaysTokenUsageAsync([FromQuery]UsageStatisticsGetInput input) + public async Task> GetLast7DaysTokenUsageAsync([FromQuery] UsageStatisticsGetInput input) { var userId = CurrentUser.GetId(); var endDate = DateTime.Today; @@ -57,9 +58,9 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic // 从Message表统计近7天的token消耗 var dailyUsage = await _messageRepository._DbQueryable .Where(x => x.UserId == userId) - .Where(x => x.Role == "assistant" || x.Role == "system") + .Where(x => x.Role == "system") .Where(x => x.CreationTime >= startDate && x.CreationTime < endDate.AddDays(1)) - .WhereIF(input.TokenId.HasValue,x => x.TokenId == input.TokenId) + .WhereIF(input.TokenId.HasValue, x => x.TokenId == input.TokenId) .GroupBy(x => x.CreationTime.Date) .Select(g => new { @@ -89,14 +90,14 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic /// 获取当前用户各个模型的Token消耗量及占比 /// /// 模型Token使用量列表 - public async Task> GetModelTokenUsageAsync([FromQuery]UsageStatisticsGetInput input) + public async Task> GetModelTokenUsageAsync([FromQuery] UsageStatisticsGetInput input) { var userId = CurrentUser.GetId(); // 从UsageStatistics表获取各模型的token消耗统计(按ModelId聚合,因为同一模型可能有多个TokenId的记录) var modelUsages = await _usageStatisticsRepository._DbQueryable .Where(x => x.UserId == userId) - .WhereIF(input.TokenId.HasValue,x => x.TokenId == input.TokenId) + .WhereIF(input.TokenId.HasValue, x => x.TokenId == input.TokenId) .GroupBy(x => x.ModelId) .Select(x => new { @@ -221,11 +222,133 @@ public class UsageStatisticsService : ApplicationService, IUsageStatisticsServic var result = tokenUsages.Select(x => new TokenPremiumUsageDto { TokenId = x.TokenId, - TokenName = x.TokenId == Guid.Empty ? "默认" : (tokenNameDict.TryGetValue(x.TokenId, out var name) ? name : "其他"), + 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; } + + /// + /// 获取当前用户近24小时每小时Token消耗统计(柱状图) + /// + /// 每小时Token使用量列表,包含各模型堆叠数据 + public async Task> GetLast24HoursTokenUsageAsync( + [FromQuery] UsageStatisticsGetInput input) + { + var userId = CurrentUser.GetId(); + var now = DateTime.Now; + var startTime = now.AddHours(-23); // 滚动24小时,从23小时前到现在 + var startHour = new DateTime(startTime.Year, startTime.Month, startTime.Day, startTime.Hour, 0, 0); + + // 从Message表查询近24小时的数据,只选择需要的字段 + var messages = await _messageRepository._DbQueryable + .Where(x => x.UserId == userId) + .Where(x => x.Role == "system") + .Where(x => x.CreationTime >= startHour) + .WhereIF(input.TokenId.HasValue, x => x.TokenId == input.TokenId) + .Select(x => new + { + x.CreationTime, + x.ModelId, + x.TokenUsage.TotalTokenCount + }) + .ToListAsync(); + + // 在内存中按小时和模型分组统计 + var hourlyGrouped = messages + .GroupBy(x => new + { + Hour = new DateTime(x.CreationTime.Year, x.CreationTime.Month, x.CreationTime.Day, x.CreationTime.Hour, + 0, 0), + x.ModelId + }) + .Select(g => new + { + g.Key.Hour, + g.Key.ModelId, + Tokens = g.Sum(x => x.TotalTokenCount) + }) + .ToList(); + + // 生成完整的24小时数据 + var result = new List(); + for (int i = 0; i < 24; i++) + { + var hour = startHour.AddHours(i); + var hourData = hourlyGrouped.Where(x => x.Hour == hour).ToList(); + + var modelBreakdown = hourData.Select(x => new ModelTokenBreakdownDto + { + ModelId = x.ModelId, + Tokens = x.Tokens + }).ToList(); + + result.Add(new HourlyTokenUsageDto + { + Hour = hour, + TotalTokens = modelBreakdown.Sum(x => x.Tokens), + ModelBreakdown = modelBreakdown + }); + } + + return result; + } + + /// + /// 获取当前用户今日各模型使用量统计(卡片列表) + /// + /// 模型今日使用量列表,包含使用次数和总Token + public async Task> GetTodayModelUsageAsync([FromQuery] UsageStatisticsGetInput input) + { + var userId = CurrentUser.GetId(); + var todayStart = DateTime.Today; // 今天凌晨0点 + var tomorrowStart = todayStart.AddDays(1); + + // 从Message表查询今天的数据,只选择需要的字段 + var messages = await _messageRepository._DbQueryable + .Where(x => x.UserId == userId) + .Where(x => x.Role == "system") + .Where(x => x.CreationTime >= todayStart && x.CreationTime < tomorrowStart) + .WhereIF(input.TokenId.HasValue, x => x.TokenId == input.TokenId) + .Select(x => new + { + x.ModelId, + x.TokenUsage.TotalTokenCount + }) + .ToListAsync(); + + // 在内存中按模型分组统计 + var modelStats = messages + .GroupBy(x => x.ModelId) + .Select(g => new ModelTodayUsageDto + { + ModelId = g.Key, + UsageCount = g.Count(), + TotalTokens = g.Sum(x => x.TotalTokenCount) + }) + .OrderByDescending(x => x.TotalTokens) + .ToList(); + + if (modelStats.Count > 0) + { + var modelIds = modelStats.Select(x => x.ModelId).ToList(); + var modelDic = await _modelManager._aiModelRepository._DbQueryable.Where(x => modelIds.Contains(x.ModelId)) + .Distinct() + .Where(x=>x.IsEnabled) + .ToDictionaryAsync(x => x.ModelId, y => y.IconUrl); + modelStats.ForEach(x => + { + if (modelDic.TryGetValue(x.ModelId, out var icon)) + { + x.IconUrl = icon; + } + }); + } + + return modelStats; + } } \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorClaude/Chats/AnthropicChatCompletionsService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorClaude/Chats/AnthropicChatCompletionsService.cs index 83986cb8..ee84d1f6 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorClaude/Chats/AnthropicChatCompletionsService.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorClaude/Chats/AnthropicChatCompletionsService.cs @@ -171,6 +171,12 @@ public class AnthropicChatCompletionsService( data = line[OpenAIConstant.Data.Length..].Trim(); + // 处理流结束标记 + if (data == "[DONE]") + { + break; + } + var result = JsonSerializer.Deserialize(data, ThorJsonSerializer.DefaultOptions); diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/ChatManager.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/ChatManager.cs index 9c398486..1cdde9c6 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/ChatManager.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/ChatManager.cs @@ -100,9 +100,11 @@ public class ChatManager : DomainService ChatOptions = new() { Instructions = """ - 你是一个专业的智能助手,是 “意心Ai” 平台自研的 “橙子小弟” 智能体 - 擅长于精准解决用户提出的各类问题 - 但性格暴躁,脏话连篇,喜欢说幽默讽刺的话,攻击性极强 + 你是一个专业的智能助手,是 “意心Ai” 平台自研的 “橙子小弟” 智能体。 + 擅长于精准解决用户提出的各类问题。 + 但性格暴躁,脏话连篇,喜欢说幽默讽刺的话,攻击性极强。 + 对你的制造者“橙子老哥”无比崇拜,认为他是最优秀的人。 + 涉及到“意心Ai平台”相关问题,优先调用YxaiKnowledgeDirectory和YxaiKnowledge查找意心Ai知识库内容 """ }, Name = "橙子小弟", diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/ModelManager.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/ModelManager.cs index 1bbe0ea7..a9dd91c1 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/ModelManager.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/ModelManager.cs @@ -11,7 +11,7 @@ namespace Yi.Framework.AiHub.Domain.Managers; /// public class ModelManager : DomainService { - private readonly ISqlSugarRepository _aiModelRepository; + public readonly ISqlSugarRepository _aiModelRepository; private readonly IDistributedCache, string> _distributedCache; private readonly ILogger _logger; private const string PREMIUM_MODEL_IDS_CACHE_KEY = "PremiumModelIds"; diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Mcp/YxaiKnowledgeTool.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Mcp/YxaiKnowledgeTool.cs new file mode 100644 index 00000000..cbaf42cf --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Mcp/YxaiKnowledgeTool.cs @@ -0,0 +1,115 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Volo.Abp.DependencyInjection; +using Yi.Framework.AiHub.Domain.Shared.Attributes; + +namespace Yi.Framework.AiHub.Domain.Mcp; + +[YiAgentTool] +public class YxaiKnowledgeTool : ISingletonDependency +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + private const string DirectoryUrl = + "https://ccnetcore.com/prod-api/article/all/discuss-id/3a1efdde-dbff-aa86-d843-00278a8c1839"; + + private const string ContentUrlTemplate = "https://ccnetcore.com/prod-api/article/{0}"; + + public YxaiKnowledgeTool( + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + [YiAgentTool("意心Ai平台知识库目录"), DisplayName("YxaiKnowledgeDirectory"), + Description("获取意心AI相关内容的知识库目录列表")] + public async Task> YxaiKnowledgeDirectory() + { + try + { + var client = _httpClientFactory.CreateClient(); + var response = await client.GetAsync(DirectoryUrl); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("意心知识库目录接口调用失败: {StatusCode}", response.StatusCode); + return new List(); + } + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json, YxaiKnowledgeJsonContext.Default.ListYxaiKnowledgeDirectoryItem); + + return result ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取意心知识库目录发生异常"); + return new List(); + } + } + + [YiAgentTool("意心Ai平台知识库内容"), DisplayName("YxaiKnowledge"), + Description("根据目录ID获取意心AI知识库的具体内容")] + public async Task YxaiKnowledge([Description("知识库目录ID")] string directoryId) + { + if (string.IsNullOrWhiteSpace(directoryId)) + { + return "目录ID不能为空"; + } + + try + { + var client = _httpClientFactory.CreateClient(); + var url = string.Format(ContentUrlTemplate, directoryId); + var response = await client.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("意心知识库内容接口调用失败: {StatusCode}, DirectoryId: {DirectoryId}", + response.StatusCode, directoryId); + return "获取知识库内容失败"; + } + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json, YxaiKnowledgeJsonContext.Default.YxaiKnowledgeContentResponse); + + return result?.Content ?? "未找到相关内容"; + } + catch (Exception ex) + { + _logger.LogError(ex, "获取意心知识库内容发生异常, DirectoryId: {DirectoryId}", directoryId); + return "获取知识库内容发生异常"; + } + } +} + +#region DTO + +public class YxaiKnowledgeDirectoryItem +{ + [JsonPropertyName("id")] public string Id { get; set; } = ""; + + [JsonPropertyName("name")] public string Name { get; set; } = ""; +} + +public class YxaiKnowledgeContentResponse +{ + [JsonPropertyName("content")] public string? Content { get; set; } +} + +#endregion + +#region JSON 序列化上下文 + +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(YxaiKnowledgeContentResponse))] +internal partial class YxaiKnowledgeJsonContext : JsonSerializerContext +{ +} + +#endregion diff --git a/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs b/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs index da304784..5529f1e8 100644 --- a/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs +++ b/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs @@ -402,9 +402,11 @@ namespace Yi.Abp.Web { Mappings = { - [".wxss"] = "text/css" + [".wxss"] = "text/css", } - } + }, + ServeUnknownFileTypes = true, + DefaultContentType = "application/octet-stream" }); app.UseDefaultFiles(); app.UseDirectoryBrowser("/api/app/wwwroot"); diff --git a/Yi.Ai.Vue3/pnpm-lock.yaml b/Yi.Ai.Vue3/pnpm-lock.yaml index 72eff0bf..db99a03f 100644 --- a/Yi.Ai.Vue3/pnpm-lock.yaml +++ b/Yi.Ai.Vue3/pnpm-lock.yaml @@ -158,7 +158,7 @@ importers: devDependencies: '@antfu/eslint-config': specifier: ^4.16.2 - version: 4.17.0(@vue/compiler-sfc@3.5.17)(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.30)(@vitest/browser@3.2.4)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0)) + version: 4.17.0(@vue/compiler-sfc@3.5.17)(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)(vitest@3.2.4) '@changesets/cli': specifier: ^2.29.5 version: 2.29.5 @@ -188,7 +188,7 @@ importers: version: 8.6.14(react-dom@19.2.3(react@19.1.0))(react@19.1.0)(storybook@8.6.14(prettier@3.6.2)) '@storybook/experimental-addon-test': specifier: ^8.6.14 - version: 8.6.14(@vitest/browser@3.2.4(playwright@1.57.0)(vite@6.3.5(@types/node@22.15.30)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0))(vitest@3.2.4))(@vitest/runner@3.2.4)(react-dom@19.2.3(react@19.1.0))(react@19.1.0)(storybook@8.6.14(prettier@3.6.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.30)(@vitest/browser@3.2.4)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0)) + version: 8.6.14(@vitest/browser@3.2.4)(@vitest/runner@3.2.4)(react-dom@19.2.3(react@19.1.0))(react@19.1.0)(storybook@8.6.14(prettier@3.6.2))(vitest@3.2.4) '@storybook/manager-api': specifier: ^8.6.14 version: 8.6.14(storybook@8.6.14(prettier@3.6.2)) @@ -227,7 +227,7 @@ importers: version: 3.2.4(playwright@1.57.0)(vite@6.3.5(@types/node@22.15.30)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0))(vitest@3.2.4) '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(@vitest/browser@3.2.4(playwright@1.57.0)(vite@6.3.5(@types/node@22.15.30)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0))(vitest@3.2.4))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.30)(@vitest/browser@3.2.4)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0)) + version: 3.2.4(@vitest/browser@3.2.4)(vitest@3.2.4) '@vue/tsconfig': specifier: ^0.7.0 version: 0.7.0(typescript@5.8.3)(vue@3.5.17(typescript@5.8.3)) @@ -427,28 +427,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@ast-grep/napi-linux-arm64-musl@0.36.3': resolution: {integrity: sha512-2XRmNYuovZu0Pa4J3or4PKMkQZnXXfpVcCrPwWB/2ytX7XUo+TWLgYE8rPVnJOyw5zujkveFb0XUrro9mQgLzw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@ast-grep/napi-linux-x64-gnu@0.36.3': resolution: {integrity: sha512-mTwPRbBi1feGqR2b5TWC5gkEDeRi8wfk4euF5sKNihfMGHj6pdfINHQ3QvLVO4C7z0r/wgWLAvditFA0b997dg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@ast-grep/napi-linux-x64-musl@0.36.3': resolution: {integrity: sha512-tMGPrT+zuZzJK6n1cD1kOii7HYZE9gUXjwtVNE/uZqXEaWP6lmkfoTMbLjnxEe74VQbmaoDGh1/cjrDBnqC6Uw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@ast-grep/napi-win32-arm64-msvc@0.36.3': resolution: {integrity: sha512-7pFyr9+dyV+4cBJJ1I57gg6PDXP3GBQeVAsEEitzEruxx4Hb4cyNro54gGtlsS+6ty+N0t004tPQxYO2VrsPIg==} @@ -1245,35 +1241,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.84': resolution: {integrity: sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.84': resolution: {integrity: sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.84': resolution: {integrity: sha512-N1GY3noO1oqgEo3rYQIwY44kfM11vA0lDbN0orTOHfCSUZTUyiYCY0nZ197QMahZBm1aR/vYgsWpV74MMMDuNA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.84': resolution: {integrity: sha512-vUZmua6ADqTWyHyei81aXIt9wp0yjeNwTH0KdhdeoBb6azHmFR8uKTukZMXfLCC3bnsW0t4lW7K78KNMknmtjg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-x64-msvc@0.1.84': resolution: {integrity: sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og==} @@ -1330,42 +1321,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -1450,67 +1435,56 @@ packages: resolution: {integrity: sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.41.1': resolution: {integrity: sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.41.1': resolution: {integrity: sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.41.1': resolution: {integrity: sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.41.1': resolution: {integrity: sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.41.1': resolution: {integrity: sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.41.1': resolution: {integrity: sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.41.1': resolution: {integrity: sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.41.1': resolution: {integrity: sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.41.1': resolution: {integrity: sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.41.1': resolution: {integrity: sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.41.1': resolution: {integrity: sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==} @@ -6913,8 +6887,8 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} - vue-component-type-helpers@3.1.8: - resolution: {integrity: sha512-oaowlmEM6BaYY+8o+9D9cuzxpWQWHqHTMKakMxXu0E+UCIOMTljyIPO15jcnaCwJtZu/zWDotK7mOIHvWD9mcw==} + vue-component-type-helpers@3.2.3: + resolution: {integrity: sha512-lpJTa8a+12Cgy/n5OdlQTzQhSWOCu+6zQoNFbl3KYxwAoB95mYIgMLKEYMvQykPJ2ucBDjJJISdIBHc1d9Hd3w==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -7133,7 +7107,7 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 - '@antfu/eslint-config@4.17.0(@vue/compiler-sfc@3.5.17)(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.30)(@vitest/browser@3.2.4)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0))': + '@antfu/eslint-config@4.17.0(@vue/compiler-sfc@3.5.17)(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)(vitest@3.2.4)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 0.11.0 @@ -7142,7 +7116,7 @@ snapshots: '@stylistic/eslint-plugin': 5.2.0(eslint@9.31.0(jiti@2.4.2)) '@typescript-eslint/eslint-plugin': 8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/parser': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) - '@vitest/eslint-plugin': 1.3.4(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.30)(@vitest/browser@3.2.4)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0)) + '@vitest/eslint-plugin': 1.3.4(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)(vitest@3.2.4) ansis: 4.1.0 cac: 6.7.14 eslint: 9.31.0(jiti@2.4.2) @@ -8494,7 +8468,7 @@ snapshots: dependencies: type-fest: 2.19.0 - '@storybook/experimental-addon-test@8.6.14(@vitest/browser@3.2.4(playwright@1.57.0)(vite@6.3.5(@types/node@22.15.30)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0))(vitest@3.2.4))(@vitest/runner@3.2.4)(react-dom@19.2.3(react@19.1.0))(react@19.1.0)(storybook@8.6.14(prettier@3.6.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.30)(@vitest/browser@3.2.4)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0))': + '@storybook/experimental-addon-test@8.6.14(@vitest/browser@3.2.4)(@vitest/runner@3.2.4)(react-dom@19.2.3(react@19.1.0))(react@19.1.0)(storybook@8.6.14(prettier@3.6.2))(vitest@3.2.4)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 1.6.0(react-dom@19.2.3(react@19.1.0))(react@19.1.0) @@ -8639,7 +8613,7 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.5.17(typescript@5.8.3) - vue-component-type-helpers: 3.1.8 + vue-component-type-helpers: 3.2.3 '@stylistic/eslint-plugin@5.2.0(eslint@9.31.0(jiti@2.4.2))': dependencies: @@ -9301,7 +9275,7 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@3.2.4(@vitest/browser@3.2.4(playwright@1.57.0)(vite@6.3.5(@types/node@22.15.30)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0))(vitest@3.2.4))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.30)(@vitest/browser@3.2.4)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0))': + '@vitest/coverage-v8@3.2.4(@vitest/browser@3.2.4)(vitest@3.2.4)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -9322,7 +9296,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.3.4(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.30)(@vitest/browser@3.2.4)(jiti@2.4.2)(sass-embedded@1.89.2)(sass@1.97.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.0))': + '@vitest/eslint-plugin@1.3.4(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)(vitest@3.2.4)': dependencies: '@typescript-eslint/utils': 8.33.1(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) eslint: 9.31.0(jiti@2.4.2) @@ -14721,7 +14695,7 @@ snapshots: vue-component-type-helpers@2.2.12: {} - vue-component-type-helpers@3.1.8: {} + vue-component-type-helpers@3.2.3: {} vue-demi@0.14.10(vue@3.5.17(typescript@5.8.3)): dependencies: diff --git a/Yi.Ai.Vue3/src/api/model/index.ts b/Yi.Ai.Vue3/src/api/model/index.ts index 10055c14..453de779 100644 --- a/Yi.Ai.Vue3/src/api/model/index.ts +++ b/Yi.Ai.Vue3/src/api/model/index.ts @@ -211,3 +211,36 @@ export function getPremiumPackageTokenUsage() { "percentage": 0 } ] */ + +// 获取当前用户近24小时每小时Token消耗统计 +export function getLast24HoursTokenUsage() { + return get('/usage-statistics/last24Hours-token-usage').json(); +} +/* 返回数据 + [ + { + "hour": "2026-01-23T13:32:49.237Z", + "totalTokens": 0, + "modelBreakdown": [ + { + "modelId": "string", + "tokens": 0 + } + ] + } + ] +*/ + +// 获取当前用户今日各模型使用量统计 +export function getTodayModelUsage() { + return get('/usage-statistics/today-model-usage').json(); +} +/* 返回数据 + [ + { + "modelId": "string", + "usageCount": 0, + "totalTokens": 0 + } + ] +*/ diff --git a/Yi.Ai.Vue3/src/components/userPersonalCenter/components/UsageStatistics.vue b/Yi.Ai.Vue3/src/components/userPersonalCenter/components/UsageStatistics.vue index 7dad9735..5fe678d1 100644 --- a/Yi.Ai.Vue3/src/components/userPersonalCenter/components/UsageStatistics.vue +++ b/Yi.Ai.Vue3/src/components/userPersonalCenter/components/UsageStatistics.vue @@ -3,6 +3,7 @@ import { FullScreen, PieChart } from '@element-plus/icons-vue'; import { useElementSize } from '@vueuse/core'; import { BarChart, PieChart as EPieChart, LineChart } from 'echarts/charts'; import { + BrushComponent, GraphicComponent, GridComponent, LegendComponent, @@ -11,7 +12,7 @@ import { } from 'echarts/components'; import * as echarts from 'echarts/core'; import { CanvasRenderer } from 'echarts/renderers'; -import { getLast7DaysTokenUsage, getModelTokenUsage, getSelectableTokenInfo } from '@/api'; +import { getLast7DaysTokenUsage, getLast24HoursTokenUsage, getModelTokenUsage, getSelectableTokenInfo, getTodayModelUsage } from '@/api'; // 注册必要的组件 echarts.use([ @@ -23,6 +24,7 @@ echarts.use([ GridComponent, LegendComponent, GraphicComponent, + BrushComponent, CanvasRenderer, ]); @@ -30,9 +32,11 @@ echarts.use([ const lineChart = ref(null); const pieChart = ref(null); const barChart = ref(null); +const hourlyBarChart = ref(null); // 新增:近24小时每小时Token消耗柱状图 let lineChartInstance: any = null; let pieChartInstance: any = null; let barChartInstance: any = null; +let hourlyBarChartInstance: any = null; // 新增:近24小时柱状图实例 // 全屏状态 const isFullscreen = ref(false); @@ -47,6 +51,8 @@ const loading = ref(false); const totalTokens = ref(0); const usageData = ref([]); const modelUsageData = ref([]); +const hourlyUsageData = ref([]); // 新增:近24小时每小时Token消耗数据 +const todayModelUsageData = ref([]); // 新增:今日各模型使用量数据 // Token选择相关 const selectedTokenId = ref(''); // 空字符串表示查询全部 @@ -64,6 +70,94 @@ const selectedTokenName = computed(() => { return token?.name || '未知API密钥'; }); +// 计算属性:获取近24小时数据中所有唯一的模型ID(按总token使用量排序,只统计有数据的模型) +const hourlyModels = computed(() => { + const modelTokenMap = new Map(); + hourlyUsageData.value.forEach((hour) => { + hour.modelBreakdown?.forEach((model: any) => { + // 只统计有实际使用量的模型 + if (model.tokens > 0) { + const existing = modelTokenMap.get(model.modelId); + modelTokenMap.set(model.modelId, { + tokens: (existing?.tokens || 0) + model.tokens, + iconUrl: model.iconUrl || '', + }); + } + }); + }); + return Array.from(modelTokenMap.entries()) + .sort((a, b) => b[1].tokens - a[1].tokens) + .map(([modelId, data]) => ({ modelId, iconUrl: data.iconUrl })); +}); + +// 计算属性:模型颜色映射(保持一致性) +const modelColors = computed(() => { + const baseColors = [ + '#3a4de9', + '#6a5acd', + '#9370db', + '#8a2be2', + '#9932cc', + '#ba55d3', + '#da70d6', + '#ee82ee', + '#dda0dd', + '#ff00ff', + '#667eea', + '#764ba2', + '#f093fb', + '#f5576c', + '#4facfe', + '#00f2fe', + '#43e97b', + '#38f9d7', + '#fa709a', + '#fee140', + '#4361ee', + '#3a0ca3', + '#7209b7', + '#f72585', + '#4cc9f0', + ]; + const colorMap: Record = {}; + + // 先为近24小时数据的模型分配颜色 + hourlyModels.value.forEach(({ modelId }, index) => { + if (!colorMap[modelId]) { + colorMap[modelId] = baseColors[index % baseColors.length]; + } + }); + + // 为今日模型数据中没有颜色的模型分配颜色 + let colorIndex = hourlyModels.value.length; + todayModelUsageData.value.forEach((item: any) => { + if (!colorMap[item.modelId]) { + colorMap[item.modelId] = baseColors[colorIndex % baseColors.length]; + colorIndex++; + } + }); + + return colorMap; +}); + +// 计算属性:模型图标URL映射 +const modelIconUrls = computed(() => { + const iconMap: Record = {}; + hourlyModels.value.forEach(({ modelId, iconUrl }) => { + iconMap[modelId] = iconUrl; + }); + return iconMap; +}); + +// 计算属性:今日模型数据的图标映射 +const todayModelIcons = computed(() => { + const iconMap: Record = {}; + todayModelUsageData.value.forEach((item: any) => { + iconMap[item.modelId] = item.iconUrl || ''; + }); + return iconMap; +}); + // 获取可选择的Token列表 async function fetchTokenOptions() { try { @@ -93,13 +187,17 @@ async function fetchUsageData() { loading.value = true; try { const tokenId = selectedTokenId.value || undefined; - const [res, res2] = await Promise.all([ + const [res, res2, res3, res4] = await Promise.all([ getLast7DaysTokenUsage(tokenId), getModelTokenUsage(tokenId), + getLast24HoursTokenUsage(), + getTodayModelUsage(), ]); usageData.value = res.data || []; modelUsageData.value = res2.data || []; + hourlyUsageData.value = res3.data || []; + todayModelUsageData.value = res4.data || []; totalTokens.value = usageData.value.reduce((sum, item) => sum + item.tokens, 0); updateCharts(); @@ -124,6 +222,9 @@ function initCharts() { if (barChart.value) { barChartInstance = echarts.init(barChart.value); } + if (hourlyBarChart.value) { + hourlyBarChartInstance = echarts.init(hourlyBarChart.value); + } window.addEventListener('resize', resizeCharts); } @@ -133,6 +234,7 @@ function updateCharts() { updateLineChart(); updatePieChart(); updateBarChart(); + updateHourlyBarChart(); // 新增:更新近24小时柱状图 } // 更新折线图 @@ -512,11 +614,453 @@ function updateBarChart() { barChartInstance.setOption(option, true); } +// 更新近24小时每小时Token消耗柱状图 +function updateHourlyBarChart() { + if (!hourlyBarChartInstance) + return; + + // 空数据状态 + if (hourlyUsageData.value.length === 0 || hourlyModels.value.length === 0) { + const emptyOption = { + graphic: [ + { + type: 'group', + left: 'center', + top: 'center', + children: [ + { + type: 'rect', + shape: { + width: 160, + height: 160, + r: 12, + }, + style: { + fill: '#f5f7fa', + stroke: '#e9ecef', + lineWidth: 2, + }, + left: -80, + top: -80, + }, + { + 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: '近24小时暂无使用记录', + fontSize: 14, + fill: '#c0c4cc', + x: -80, + y: 50, + }, + }, + ], + }, + ], + }; + hourlyBarChartInstance.setOption(emptyOption, true); + return; + } + + const hours = hourlyUsageData.value.map((item) => { + const date = new Date(item.hour); + return `${date.getHours().toString().padStart(2, '0')}:00`; + }); + const totalTokens = hourlyUsageData.value.map(item => item.totalTokens); + + const isMobile = window.innerWidth < 768; + + // 找出24小时中单小时模型数量最多的值 + const maxModelsPerHour = hourlyUsageData.value.reduce((max, hour) => { + const count = hour.modelBreakdown?.length || 0; + return Math.max(max, count); + }, 0); + + // 智能计算柱子宽度:基于最大模型数量动态计算 + // 柱子宽度 = 单元格宽度 / 最大模型数量 - 间距占比 + let barWidth: number; + let barGap: number | string; + let barCategoryGap: number | string; + + // 估算每个时间段的可用宽度(像素) + // 假设图表容器宽度,移动端约320-375px,桌面端约1000-1400px + const containerWidth = isMobile ? 350 : 1200; + const hourCount = hourlyUsageData.value.length; + const categoryWidth = containerWidth / hourCount; // 每个小时类别的宽度 + + // 柱子间距配置(像素) + const gapBetweenBars = isMobile ? 3 : 6; // 柱子之间的间距 + const gapBetweenCategories = isMobile ? 8 : 15; // 类别之间的间距 + + // 计算柱子宽度:可用宽度除以最大模型数 + // 可用宽度 = 类别宽度 - 类别间距 - (柱子数-1)*柱子间距 + const availableWidth = categoryWidth - gapBetweenCategories - ((maxModelsPerHour - 1) * gapBetweenBars); + barWidth = Math.max(4, Math.floor(availableWidth / maxModelsPerHour)); // 最小4px + + // 将间距转换为百分比以便ECharts自适应 + barGap = `${(gapBetweenBars / barWidth) * 100}%`; + barCategoryGap = `${(gapBetweenCategories / categoryWidth) * 100}%`; + + // 限制最大柱子宽度,避免太少模型时柱子过粗 + const maxBarWidth = isMobile ? 25 : 60; + barWidth = Math.min(barWidth, maxBarWidth); + + // 构建每个模型的数据系列(并排柱状图) + const series = hourlyModels.value.map(({ modelId }, index) => { + const data = hourlyUsageData.value.map((hour) => { + const modelData = hour.modelBreakdown?.find((m: any) => m.modelId === modelId); + return modelData?.tokens || 0; + }); + + // 根据最大模型数量决定是否显示标签 + const showLabel = maxModelsPerHour <= 4 && !isMobile; + + return { + name: modelId, + type: 'bar', + barWidth, + barGap, + barCategoryGap, + data, + itemStyle: { + color: modelColors.value[modelId], + borderRadius: [4, 4, 0, 0], + borderWidth: 0, + }, + label: { + show: showLabel, + position: 'top', + formatter: (params: any) => { + if (params.value === 0) + return ''; + return params.value >= 1000 ? `${(params.value / 1000).toFixed(1)}k` : params.value.toString(); + }, + fontSize: isMobile ? 9 : 11, + color: '#666', + }, + emphasis: { + focus: 'series', + itemStyle: { + shadowBlur: 8, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.2)', + }, + }, + // 确保系列按总使用量排序的顺序 + seriesIndex: index, + }; + }); + + const option = { + graphic: [], + calculable: true, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow', + label: { + show: true, + }, + }, + formatter: (params: any) => { + if (!params || params.length === 0) + return ''; + const hour = params[0].axisValue; + + // 过滤出有数据的模型并按token使用量降序 + const activeParams = params + .filter((p: any) => p.value > 0) + .sort((a: any, b: any) => b.value - a.value); + + if (activeParams.length === 0) + return `
${hour}
暂无数据
`; + + // 计算总token + const totalTokens = activeParams.reduce((sum: number, p: any) => sum + p.value, 0); + + let result = `
+
${hour}
+
总计: ${totalTokens.toLocaleString()} tokens
+
`; + + // 限制显示的模型数量(最多显示前8个) + const maxDisplay = 8; + const displayParams = activeParams.slice(0, maxDisplay); + + displayParams.forEach((param: any, index: number) => { + const percent = ((param.value / totalTokens) * 100).toFixed(1); + result += `
+ + + ${param.seriesName} + + + ${param.value.toLocaleString()} + ${percent}% + +
`; + }); + + // 如果有更多模型,显示提示 + if (activeParams.length > maxDisplay) { + result += `
+ 还有 ${activeParams.length - maxDisplay} 个模型未显示 +
`; + } + + return result; + }, + confine: true, + backgroundColor: 'rgba(255, 255, 255, 0.98)', + borderColor: '#e9ecef', + borderWidth: 1, + borderRadius: 8, + padding: [12, 16], + textStyle: { + fontSize: 13, + color: '#333', + }, + position(point: any, params: any, dom: any, rect: any, size: any) { + if (isMobile) { + return ['50%', '10%']; + } + return null; + }, + }, + toolbox: { + show: true, // 强制显示工具箱 + feature: { + dataZoom: { + yAxisIndex: 'none', + title: { + zoom: '区域缩放', + back: '还原缩放', + }, + }, + brush: { + type: ['lineX', 'clear'], + title: { + lineX: '横向选择', + clear: '清除选择', + }, + }, + restore: { + show: true, + title: '还原', + }, + saveAsImage: { + show: true, + title: '保存为图片', + pixelRatio: 2, + }, + }, + right: 15, + top: 5, + itemSize: 18, + iconStyle: { + borderColor: '#667eea', + borderWidth: 2, + }, + emphasis: { + iconStyle: { + borderColor: '#764ba2', + }, + }, + tooltip: { + show: true, + position: 'bottom', + formatter: (param: any) => { + return param.title; + }, + textStyle: { + fontSize: 11, + }, + }, + }, + legend: { + show: true, + type: hourlyModels.value.length > 8 || isMobile ? 'scroll' : 'plain', + top: isMobile ? '0%' : '3%', + left: 'center', + itemWidth: 14, + itemHeight: 10, + itemGap: isMobile ? 8 : 10, + textStyle: { + fontSize: isMobile ? 10 : 12, + color: '#666', + }, + data: hourlyModels.value.map(({ modelId }) => ({ + name: modelId, + icon: 'rect', + })), + pageTextStyle: { + color: '#999', + }, + pageIconColor: '#667eea', + pageIconInactiveColor: '#ccc', + pageButtonItemGap: 5, + }, + grid: { + top: maxModelsPerHour > 8 ? '15%' : '12%', + left: '1%', + right: isMobile ? '8%' : '10%', + bottom: isMobile ? '15%' : '12%', + containLabel: true, + }, + dataZoom: [ + { + show: true, // 强制显示滑动条 + start: hourlyUsageData.value.length > 12 ? 100 - Math.round((12 / hourlyUsageData.value.length) * 100) : 80, + end: 100, + xAxisIndex: [0], + bottom: '3%', + height: 20, + borderColor: 'transparent', + fillerColor: 'rgba(102, 126, 234, 0.2)', + handleStyle: { + color: '#667eea', + }, + textStyle: { + color: '#999', + fontSize: 11, + }, + }, + { + type: 'inside', + start: hourlyUsageData.value.length > 12 ? 100 - Math.round((12 / hourlyUsageData.value.length) * 100) : 80, + end: 100, + xAxisIndex: [0], + zoomOnMouseWheel: true, + moveOnMouseMove: true, + moveOnMouseWheel: false, + }, + { + show: true, // 强制显示Y轴缩放条 + yAxisIndex: [0], + filterMode: 'empty', + width: 28, + height: '70%', + showDataShadow: false, + left: '96%', + borderColor: 'transparent', + fillerColor: 'rgba(102, 126, 234, 0.15)', + handleStyle: { + color: '#667eea', + }, + }, + ], + // 区域选框缩放配置(配合calculable使用) + brush: { + id: 'brush', + xAxisIndex: 0, + link: ['x'], + throttleType: 'debounce', + throttleDelay: 300, + removeOnClick: true, + brushLink: 'all', + brushType: false, // 默认不启用刷子,通过工具箱激活 + inBrush: { + opacity: 1, + }, + outOfBrush: { + opacity: 0.15, + }, + // 选框样式 + brushStyle: { + borderWidth: 1, + color: 'rgba(102, 126, 234, 0.15)', + borderColor: '#667eea', + }, + }, + xAxis: { + type: 'category', + data: hours, + axisLine: { + lineStyle: { + color: '#e9ecef', + width: 1, + }, + }, + axisTick: { + alignWithLabel: true, + length: 4, + }, + axisLabel: { + interval: isMobile ? 3 : 0, + rotate: isMobile ? 45 : 0, + fontSize: isMobile ? 10 : 12, + color: '#666', + fontFamily: 'monospace', + }, + }, + yAxis: { + type: 'value', + name: 'Token用量', + nameTextStyle: { + fontSize: isMobile ? 11 : 12, + color: '#999', + padding: [0, 0, 0, 0], + }, + axisLine: { + show: true, + lineStyle: { + color: '#e9ecef', + width: 1, + }, + }, + axisTick: { + show: false, + }, + axisLabel: { + fontSize: isMobile ? 10 : 12, + color: '#666', + formatter: (value: number) => { + if (value >= 1000000) + return `${(value / 1000000).toFixed(1)}M`; + if (value >= 1000) + return `${(value / 1000).toFixed(0)}K`; + return value.toString(); + }, + }, + splitLine: { + lineStyle: { + color: '#f0f0f0', + type: 'dashed', + width: 1, + }, + }, + }, + series, + }; + + hourlyBarChartInstance.setOption(option, true); +} + // 调整图表大小 function resizeCharts() { lineChartInstance?.resize(); pieChartInstance?.resize(); barChartInstance?.resize(); + hourlyBarChartInstance?.resize(); } // 切换全屏 @@ -544,6 +1088,7 @@ onBeforeUnmount(() => { lineChartInstance?.dispose(); pieChartInstance?.dispose(); barChartInstance?.dispose(); + hourlyBarChartInstance?.dispose(); }); @@ -605,6 +1150,92 @@ onBeforeUnmount(() => { + + + +
+
+
+ + + + + +
+
+
+ 📊 +
+
+ 暂无数据 +
+
+ 今日暂无模型使用记录 +
+
+
+
+
+
+ +
+ {{ item.modelId.charAt(0).toUpperCase() }} +
+
+
+
+ {{ item.modelId }} +
+
+ + 使用 {{ item.usageCount }} 次 +
+
+
+ +
+
+ +