diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/MessageCreatedOutput.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/MessageCreatedOutput.cs new file mode 100644 index 00000000..e0ec14f7 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/Chat/MessageCreatedOutput.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using System.Text.Json.Serialization; + +namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat; + +/// +/// 消息创建结果输出 +/// +public class MessageCreatedOutput +{ + /// + /// 消息类型 + /// + [JsonIgnore] + public ChatMessageTypeEnum TypeEnum { get; set; } + + /// + /// 消息类型 + /// + public string Type => TypeEnum.ToString(); + + /// + /// 消息ID + /// + public Guid MessageId { get; set; } + + /// + /// 消息创建时间 + /// + public DateTime CreationTime { get; set; } +} + +/// +/// 消息类型枚举 +/// +public enum ChatMessageTypeEnum +{ + /// + /// 用户消息 + /// + UserMessage, + + /// + /// 系统消息 + /// + SystemMessage +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingGetListInput.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingGetListInput.cs new file mode 100644 index 00000000..71f39c56 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingGetListInput.cs @@ -0,0 +1,14 @@ +using Yi.Framework.AiHub.Domain.Shared.Enums; + +namespace Yi.Framework.AiHub.Application.Contracts.Dtos; + +/// +/// 排行榜查询输入 +/// +public class RankingGetListInput +{ + /// + /// 排行榜类型:0-模型,1-工具,不传返回全部 + /// + public RankingTypeEnum? Type { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingItemDto.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingItemDto.cs new file mode 100644 index 00000000..12b478d8 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/Dtos/RankingItemDto.cs @@ -0,0 +1,41 @@ +using Yi.Framework.AiHub.Domain.Shared.Enums; + +namespace Yi.Framework.AiHub.Application.Contracts.Dtos; + +/// +/// 排行榜项DTO +/// +public class RankingItemDto +{ + public Guid Id { get; set; } + + /// + /// 名称 + /// + public string Name { get; set; } = null!; + + /// + /// 描述 + /// + public string Description { get; set; } = null!; + + /// + /// Logo地址 + /// + public string? LogoUrl { get; set; } + + /// + /// 得分 + /// + public decimal Score { get; set; } + + /// + /// 提供者 + /// + public string Provider { get; set; } = null!; + + /// + /// 排行榜类型 + /// + public RankingTypeEnum Type { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/IServices/IRankingService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/IServices/IRankingService.cs new file mode 100644 index 00000000..d689d9eb --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application.Contracts/IServices/IRankingService.cs @@ -0,0 +1,16 @@ +using Yi.Framework.AiHub.Application.Contracts.Dtos; + +namespace Yi.Framework.AiHub.Application.Contracts.IServices; + +/// +/// 排行榜服务接口 +/// +public interface IRankingService +{ + /// + /// 获取排行榜列表(全量返回) + /// + /// 查询条件 + /// 排行榜列表 + Task> GetListAsync(RankingGetListInput input); +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiChatService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiChatService.cs index 5f3dbe1c..3ce93e5d 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiChatService.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/AiChatService.cs @@ -127,54 +127,55 @@ public class AiChatService : ApplicationService } - /// - /// 发送消息 - /// - /// - /// - /// - [HttpPost("ai-chat/send")] - public async Task PostSendAsync([FromBody] ThorChatCompletionsRequest input, [FromQuery] Guid? sessionId, - CancellationToken cancellationToken) - { - //除了免费模型,其他的模型都要校验 - if (input.Model!=FreeModelId) - { - //有token,需要黑名单校验 - if (CurrentUser.IsAuthenticated) - { - await _aiBlacklistManager.VerifiyAiBlacklist(CurrentUser.GetId()); - if (!CurrentUser.IsAiVip()) - { - throw new UserFriendlyException("该模型需要VIP用户才能使用,请购买VIP后重新登录重试"); - } - } - else - { - throw new UserFriendlyException("未登录用户,只能使用未加速的DeepSeek-R1,请登录后重试"); - } - } - - //如果是尊享包服务,需要校验是是否尊享包足够 - if (CurrentUser.IsAuthenticated) - { - var isPremium = await _modelManager.IsPremiumModelAsync(input.Model); - - if (isPremium) - { - // 检查尊享token包用量 - var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(CurrentUser.GetId()); - if (availableTokens <= 0) - { - throw new UserFriendlyException("尊享token包用量不足,请先购买尊享token包"); - } - } - } - - //ai网关代理httpcontext - await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input, - CurrentUser.Id, sessionId, null, CancellationToken.None); - } + // /// + // /// 发送消息 + // /// + // /// + // /// + // /// + // [HttpPost("ai-chat/send")] + // [Obsolete] + // public async Task PostSendAsync([FromBody] ThorChatCompletionsRequest input, [FromQuery] Guid? sessionId, + // CancellationToken cancellationToken) + // { + // //除了免费模型,其他的模型都要校验 + // if (input.Model!=FreeModelId) + // { + // //有token,需要黑名单校验 + // if (CurrentUser.IsAuthenticated) + // { + // await _aiBlacklistManager.VerifiyAiBlacklist(CurrentUser.GetId()); + // if (!CurrentUser.IsAiVip()) + // { + // throw new UserFriendlyException("该模型需要VIP用户才能使用,请购买VIP后重新登录重试"); + // } + // } + // else + // { + // throw new UserFriendlyException("未登录用户,只能使用未加速的DeepSeek-R1,请登录后重试"); + // } + // } + // + // //如果是尊享包服务,需要校验是是否尊享包足够 + // if (CurrentUser.IsAuthenticated) + // { + // var isPremium = await _modelManager.IsPremiumModelAsync(input.Model); + // + // if (isPremium) + // { + // // 检查尊享token包用量 + // var availableTokens = await _premiumPackageManager.GetAvailableTokensAsync(CurrentUser.GetId()); + // if (availableTokens <= 0) + // { + // throw new UserFriendlyException("尊享token包用量不足,请先购买尊享token包"); + // } + // } + // } + // + // //ai网关代理httpcontext + // await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input, + // CurrentUser.Id, sessionId, null, CancellationToken.None); + // } /// /// 发送消息 diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/MessageService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/MessageService.cs index 56101112..378fbb7b 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/MessageService.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/Chat/MessageService.cs @@ -46,7 +46,7 @@ public class MessageService : ApplicationService /// /// 删除参数,包含消息Id列表和是否删除后续消息的开关 [Authorize] - public async Task DeleteAsync([FromBody] MessageDeleteInput input) + public async Task DeleteAsync([FromQuery] MessageDeleteInput input) { var userId = CurrentUser.GetId(); diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/RankingService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/RankingService.cs new file mode 100644 index 00000000..92b13c84 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/RankingService.cs @@ -0,0 +1,38 @@ +using Mapster; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Volo.Abp.Application.Services; +using Yi.Framework.AiHub.Application.Contracts.Dtos; +using Yi.Framework.AiHub.Application.Contracts.IServices; +using Yi.Framework.AiHub.Domain.Entities; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace Yi.Framework.AiHub.Application.Services; + +/// +/// 排行榜服务 +/// +public class RankingService : ApplicationService, IRankingService +{ + private readonly ISqlSugarRepository _repository; + + public RankingService(ISqlSugarRepository repository) + { + _repository = repository; + } + + /// + /// 获取排行榜列表(全量返回,按得分降序) + /// + [HttpGet("ranking/list")] + [AllowAnonymous] + public async Task> GetListAsync([FromQuery] RankingGetListInput input) + { + var query = _repository._DbQueryable + .WhereIF(input.Type.HasValue, x => x.Type == input.Type!.Value) + .OrderByDescending(x => x.Score); + + var entities = await query.ToListAsync(); + return entities.Adapt>(); + } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Enums/RankingTypeEnum.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Enums/RankingTypeEnum.cs new file mode 100644 index 00000000..d233b944 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Enums/RankingTypeEnum.cs @@ -0,0 +1,17 @@ +namespace Yi.Framework.AiHub.Domain.Shared.Enums; + +/// +/// 排行榜类型枚举 +/// +public enum RankingTypeEnum +{ + /// + /// 模型 + /// + Model = 0, + + /// + /// 工具 + /// + Tool = 1 +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/Chat/MessageAggregateRoot.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/Chat/MessageAggregateRoot.cs index a704e924..e390efd4 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/Chat/MessageAggregateRoot.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/Chat/MessageAggregateRoot.cs @@ -53,6 +53,15 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot TotalTokenCount = tokenUsage.TotalTokens ?? 0 }; } + else + { + this.TokenUsage = new TokenUsageValueObject + { + OutputTokenCount = 0, + InputTokenCount = 0, + TotalTokenCount = 0 + }; + } this.MessageType = sessionId is null ? MessageTypeEnum.Api : MessageTypeEnum.Web; } diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/RankingItemAggregateRoot.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/RankingItemAggregateRoot.cs new file mode 100644 index 00000000..ef36886b --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Entities/RankingItemAggregateRoot.cs @@ -0,0 +1,42 @@ +using SqlSugar; +using Volo.Abp.Domain.Entities.Auditing; +using Yi.Framework.AiHub.Domain.Shared.Enums; + +namespace Yi.Framework.AiHub.Domain.Entities; + +/// +/// 排行榜项聚合根 +/// +[SugarTable("Ai_RankingItem")] +public class RankingItemAggregateRoot : FullAuditedAggregateRoot +{ + /// + /// 名称 + /// + public string Name { get; set; } + + /// + /// 描述 + /// + public string? Description { get; set; } + + /// + /// Logo地址 + /// + public string? LogoUrl { get; set; } + + /// + /// 得分 + /// + public decimal Score { get; set; } + + /// + /// 提供者 + /// + public string? Provider { get; set; } + + /// + /// 排行榜类型:0-模型,1-工具 + /// + public RankingTypeEnum Type { get; set; } +} diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs index 90708bbe..f789241a 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs @@ -23,6 +23,7 @@ using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings; using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Images; using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Responses; using Yi.Framework.AiHub.Domain.Shared.Enums; +using Yi.Framework.AiHub.Application.Contracts.Dtos.Chat; using Yi.Framework.AiHub.Domain.Shared.Extensions; using Yi.Framework.Core.Extensions; using Yi.Framework.SqlSugarCore.Abstractions; @@ -1143,6 +1144,7 @@ public class AiGateWayManager : DomainService Guid? tokenId = null, CancellationToken cancellationToken = default) { + var startTime = DateTime.Now; var response = httpContext.Response; // 设置响应头,声明是 SSE 流 response.ContentType = "text/event-stream;charset=utf-8;"; @@ -1206,20 +1208,17 @@ public class AiGateWayManager : DomainService throw new UserFriendlyException($"不支持的API类型: {apiType}"); } - // 标记完成并等待消费任务结束 - isComplete = true; - await outputTask; - + // 统一的统计处理 - await _aiMessageManager.CreateUserMessageAsync(userId, sessionId, + var userMessageId = await _aiMessageManager.CreateUserMessageAsync(userId, sessionId, new MessageInputDto { Content = sessionId is null ? "不予存储" : processResult?.UserContent ?? string.Empty, ModelId = sourceModelId, TokenUsage = processResult?.TokenUsage, - }, tokenId); + }, tokenId,createTime:startTime); - await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId, + var systemMessageId = await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId, new MessageInputDto { Content = sessionId is null ? "不予存储" : processResult?.SystemContent ?? string.Empty, @@ -1227,6 +1226,29 @@ public class AiGateWayManager : DomainService TokenUsage = processResult?.TokenUsage }, tokenId); + // 流式返回消息ID + var now = DateTime.Now; + var userMessageOutput = new MessageCreatedOutput + { + TypeEnum = ChatMessageTypeEnum.UserMessage, + MessageId = userMessageId, + CreationTime = startTime + }; + messageQueue.Enqueue($"data: {JsonSerializer.Serialize(userMessageOutput, ThorJsonSerializer.DefaultOptions)}\n\n"); + + var systemMessageOutput = new MessageCreatedOutput + { + TypeEnum = ChatMessageTypeEnum.SystemMessage, + MessageId = systemMessageId, + CreationTime = now + }; + messageQueue.Enqueue($"data: {JsonSerializer.Serialize(systemMessageOutput, ThorJsonSerializer.DefaultOptions)}\n\n"); + + // 标记完成并等待消费任务结束 + messageQueue.Enqueue("data: [DONE]\n\n"); + isComplete = true; + await outputTask; + await _usageStatisticsManager.SetUsageAsync(userId, sourceModelId, processResult?.TokenUsage, tokenId); // 扣减尊享token包用量 @@ -1324,8 +1346,7 @@ public class AiGateWayManager : DomainService }); messageQueue.Enqueue($"data: {errorMessage}\n\n"); } - - messageQueue.Enqueue("data: [DONE]\n\n"); + return new StreamProcessResult { UserContent = userContent, diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiMessageManager.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiMessageManager.cs index 95730424..90509246 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiMessageManager.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiMessageManager.cs @@ -24,11 +24,12 @@ public class AiMessageManager : DomainService /// 消息输入 /// Token Id(Web端传Guid.Empty) /// - public async Task CreateSystemMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null) + 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, tokenId); await _repository.InsertAsync(message); + return message.Id; } /// @@ -38,11 +39,16 @@ public class AiMessageManager : DomainService /// 会话Id /// 消息输入 /// Token Id(Web端传Guid.Empty) + /// /// - public async Task CreateUserMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null) + public async Task CreateUserMessageAsync( Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null,DateTime? createTime=null) { input.Role = "user"; - var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId, input.TokenUsage, tokenId); + var message = new MessageAggregateRoot(userId, sessionId, input.Content, input.Role, input.ModelId, input.TokenUsage, tokenId) + { + CreationTime = createTime??DateTime.Now + }; await _repository.InsertAsync(message); + return message.Id; } } \ No newline at end of file diff --git a/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs b/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs index 5529f1e8..afaa6969 100644 --- a/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs +++ b/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs @@ -361,7 +361,7 @@ namespace Yi.Abp.Web var app = context.GetApplicationBuilder(); app.UseRouting(); - //app.ApplicationServices.GetRequiredService().SqlSugarClient.CodeFirst.InitTables(); + // app.ApplicationServices.GetRequiredService().SqlSugarClient.CodeFirst.InitTables(); // app.ApplicationServices.GetRequiredService().SqlSugarClient.CodeFirst.InitTables(); // app.ApplicationServices.GetRequiredService().SqlSugarClient.CodeFirst.InitTables(); diff --git a/Yi.Ai.Vue3/.build/plugins/fontawesome.ts b/Yi.Ai.Vue3/.build/plugins/fontawesome.ts new file mode 100644 index 00000000..22207eda --- /dev/null +++ b/Yi.Ai.Vue3/.build/plugins/fontawesome.ts @@ -0,0 +1,23 @@ +import type { Plugin } from 'vite'; +import { config, library } from '@fortawesome/fontawesome-svg-core'; +import { fas } from '@fortawesome/free-solid-svg-icons'; + +/** + * Vite 插件:配置 FontAwesome + * 预注册所有图标,避免运行时重复注册 + */ +export default function fontAwesomePlugin(): Plugin { + // 在模块加载时配置 FontAwesome + library.add(fas); + + return { + name: 'vite-plugin-fontawesome', + config() { + return { + define: { + // 确保 FontAwesome 在客户端正确初始化 + }, + }; + }, + }; +} diff --git a/Yi.Ai.Vue3/.build/plugins/index.ts b/Yi.Ai.Vue3/.build/plugins/index.ts index 122c0fe2..bed77970 100644 --- a/Yi.Ai.Vue3/.build/plugins/index.ts +++ b/Yi.Ai.Vue3/.build/plugins/index.ts @@ -10,15 +10,21 @@ import Components from 'unplugin-vue-components/vite'; import viteCompression from 'vite-plugin-compression'; import envTyped from 'vite-plugin-env-typed'; +import fontAwesomePlugin from './fontawesome'; import gitHashPlugin from './git-hash'; +import preloadPlugin from './preload'; import createSvgIcon from './svg-icon'; +import versionHtmlPlugin from './version-html'; const root = path.resolve(__dirname, '../../'); function plugins({ mode, command }: ConfigEnv): PluginOption[] { return [ + versionHtmlPlugin(), // 最先处理 HTML 版本号 gitHashPlugin(), + preloadPlugin(), UnoCSS(), + fontAwesomePlugin(), envTyped({ mode, envDir: root, @@ -35,7 +41,18 @@ function plugins({ mode, command }: ConfigEnv): PluginOption[] { dts: path.join(root, 'types', 'auto-imports.d.ts'), }), Components({ - resolvers: [ElementPlusResolver()], + resolvers: [ + ElementPlusResolver(), + // 自动导入 FontAwesomeIcon 组件 + (componentName) => { + if (componentName === 'FontAwesomeIcon') { + return { + name: 'FontAwesomeIcon', + from: '@/components/FontAwesomeIcon/index.vue', + }; + } + }, + ], dts: path.join(root, 'types', 'components.d.ts'), }), createSvgIcon(command === 'build'), diff --git a/Yi.Ai.Vue3/.build/plugins/preload.ts b/Yi.Ai.Vue3/.build/plugins/preload.ts new file mode 100644 index 00000000..4bccc738 --- /dev/null +++ b/Yi.Ai.Vue3/.build/plugins/preload.ts @@ -0,0 +1,47 @@ +import type { Plugin } from 'vite'; + +/** + * Vite 插件:资源预加载优化 + * 自动添加 Link 标签预加载关键资源 + */ +export default function preloadPlugin(): Plugin { + return { + name: 'vite-plugin-preload-optimization', + apply: 'build', + transformIndexHtml(html, context) { + // 只在生产环境添加预加载 + if (process.env.NODE_ENV === 'development') { + return html; + } + + const bundle = context.bundle || {}; + const preloadLinks: string[] = []; + + // 收集关键资源 + const criticalChunks = ['vue-vendor', 'element-plus', 'pinia']; + const criticalAssets: string[] = []; + + Object.entries(bundle).forEach(([fileName, chunk]) => { + if (chunk.type === 'chunk' && criticalChunks.some(name => fileName.includes(name))) { + criticalAssets.push(`/${fileName}`); + } + }); + + // 生成预加载标签 + criticalAssets.forEach(href => { + if (href.endsWith('.js')) { + preloadLinks.push(``); + } else if (href.endsWith('.css')) { + preloadLinks.push(``); + } + }); + + // 将预加载标签插入到 之前 + if (preloadLinks.length > 0) { + return html.replace('', `${preloadLinks.join('\n ')}\n`); + } + + return html; + }, + }; +} diff --git a/Yi.Ai.Vue3/.build/plugins/version-html.ts b/Yi.Ai.Vue3/.build/plugins/version-html.ts new file mode 100644 index 00000000..674dd304 --- /dev/null +++ b/Yi.Ai.Vue3/.build/plugins/version-html.ts @@ -0,0 +1,20 @@ +import type { Plugin } from 'vite'; +import { APP_VERSION, APP_NAME } from '../../src/config/version'; + +/** + * Vite 插件:在 HTML 中注入版本号 + * 替换 HTML 中的占位符为实际版本号 + */ +export default function versionHtmlPlugin(): Plugin { + return { + name: 'vite-plugin-version-html', + enforce: 'pre', + transformIndexHtml(html) { + // 替换 HTML 中的版本占位符 + return html + .replace(/%APP_NAME%/g, APP_NAME) + .replace(/%APP_VERSION%/g, APP_VERSION) + .replace(/%APP_FULL_NAME%/g, `${APP_NAME} ${APP_VERSION}`); + }, + }; +} diff --git a/Yi.Ai.Vue3/.claude/settings.local.json b/Yi.Ai.Vue3/.claude/settings.local.json index 2379a8d2..89b03152 100644 --- a/Yi.Ai.Vue3/.claude/settings.local.json +++ b/Yi.Ai.Vue3/.claude/settings.local.json @@ -8,7 +8,9 @@ "Bash(timeout /t 5 /nobreak)", "Bash(git checkout:*)", "Bash(npm install marked --save)", - "Bash(pnpm add marked)" + "Bash(pnpm add marked)", + "Bash(pnpm lint:*)", + "Bash(pnpm list:*)" ], "deny": [], "ask": [] diff --git a/Yi.Ai.Vue3/CLAUDE.md b/Yi.Ai.Vue3/CLAUDE.md new file mode 100644 index 00000000..25c6ed7a --- /dev/null +++ b/Yi.Ai.Vue3/CLAUDE.md @@ -0,0 +1,367 @@ +# CLAUDE.md + +本文件为 Claude Code (claude.ai/code) 提供本项目代码开发指导。 + +## 项目简介 + +**意心AI** - 基于 Vue 3.5 + TypeScript 开发的企业级 AI 聊天应用模板,仿豆包/通义 AI 平台。支持流式对话、AI 模型库、文件上传、Mermaid 图表渲染等功能。 + +## 技术栈 + +- **框架**: Vue 3.5+ (Composition API) + TypeScript 5.8+ +- **构建工具**: Vite 6.3+ +- **UI 组件**: Element Plus 2.10.4 + vue-element-plus-x 1.3.7 +- **状态管理**: Pinia 3.0 + pinia-plugin-persistedstate +- **HTTP 请求**: hook-fetch(支持流式/SSE,替代 Axios) +- **CSS**: UnoCSS + SCSS +- **路由**: Vue Router 4 + +## 常用命令 + +```bash +# 安装依赖(必须用 pnpm) +pnpm install + +# 启动开发服务器(端口 17001) +pnpm dev + +# 生产构建 +pnpm build + +# 预览生产构建 +pnpm preview + +# 代码检查与修复 +pnpm lint # ESLint 检查 +pnpm fix # ESLint 自动修复 +pnpm lint:stylelint # 样式检查 + +# 规范提交(使用 cz-git) +pnpm cz +``` + +## 如何新增页面 + +### 1. 创建页面文件 + +页面文件统一放在 `src/pages/` 目录下: + +``` +src/pages/ +├── chat/ # 功能模块文件夹 +│ ├── index.vue # 父级布局页 +│ ├── conversation/ # 子页面文件夹 +│ │ └── index.vue # 子页面 +│ └── image/ +│ └── index.vue +├── console/ +│ └── index.vue +└── your-page/ # 新增页面在这里创建 + └── index.vue +``` + +**单页面示例** (`src/pages/your-page/index.vue`): + +```vue + + + 页面标题 + + + + + + +``` + +### 2. 配置路由 + +路由配置在 `src/routers/modules/staticRouter.ts`。 + +**新增独立页面**(添加到 `layoutRouter` 的 children 中): + +```typescript +{ + path: 'your-page', // URL 路径,最终为 /your-page + name: 'yourPage', // 路由名称,必须唯一 + component: () => import('@/pages/your-page/index.vue'), + meta: { + title: '页面标题', // 页面标题,会显示在浏览器标签 + keepAlive: 0, // 是否缓存页面:0=缓存,1=不缓存 + isDefaultChat: false, // 是否为默认聊天页 + layout: 'default', // 布局类型:default/blankPage + }, +} +``` + +**新增带子路由的功能模块**: + +```typescript +{ + path: 'module-name', + name: 'moduleName', + component: () => import('@/pages/module-name/index.vue'), // 父级布局页 + redirect: '/module-name/sub-page', // 默认重定向 + meta: { + title: '模块标题', + icon: 'HomeFilled', // Element Plus 图标名称 + }, + children: [ + { + path: 'sub-page', + name: 'subPage', + component: () => import('@/pages/module-name/sub-page/index.vue'), + meta: { + title: '子页面标题', + }, + }, + // 带参数的路由 + { + path: 'detail/:id', + name: 'detailPage', + component: () => import('@/pages/module-name/detail/index.vue'), + meta: { + title: '详情页', + }, + }, + ], +} +``` + +**无需布局的独立页面**(添加到 `staticRouter`): + +```typescript +{ + path: '/test/page', + name: 'testPage', + component: () => import('@/pages/test/page.vue'), + meta: { + title: '测试页面', + }, +} +``` + +### 3. 页面 Meta 配置说明 + +| 属性 | 类型 | 说明 | +|------|------|------| +| title | string | 页面标题,显示在浏览器标签页 | +| keepAlive | number | 0=缓存页面,1=不缓存 | +| layout | string | 布局类型:'default' 使用默认布局,'blankPage' 使用空白布局 | +| isDefaultChat | boolean | 是否为默认聊天页面 | +| icon | string | Element Plus 图标名称,用于菜单显示 | +| isHide | string | '0'=在菜单中隐藏,'1'=显示 | +| isKeepAlive | string | '0'=缓存,'1'=不缓存(字符串版) | + +### 4. 布局说明 + +布局组件位于 `src/layouts/`: + +- **default**: 默认布局,包含侧边栏、顶部导航等 +- **blankPage**: 空白布局,仅包含路由出口 + +在路由 meta 中通过 `layout` 字段指定: + +```typescript +meta: { + layout: 'default', // 使用默认布局(有侧边栏) + // 或 + layout: 'blankPage', // 使用空白布局(全屏页面) +} +``` + +### 5. 页面跳转示例 + +```typescript +// 在 script setup 中使用 +const router = useRouter() + +// 跳转页面 +router.push('/chat/conversation') +router.push({ name: 'chatConversation' }) +router.push({ path: '/chat/conversation/:id', params: { id: '123' } }) + +// 获取路由参数 +const route = useRoute() +console.log(route.params.id) +``` + +### 6. 完整新增页面示例 + +假设要新增一个"数据统计"页面: + +**步骤 1**: 创建页面文件 `src/pages/statistics/index.vue` + +```vue + + + + + 数据统计 + + 页面内容 + + + + + + + +``` + +**步骤 2**: 在 `src/routers/modules/staticRouter.ts` 中添加路由 + +```typescript +{ + path: 'statistics', + name: 'statistics', + component: () => import('@/pages/statistics/index.vue'), + meta: { + title: '意心Ai-数据统计', + keepAlive: 0, + isDefaultChat: false, + layout: 'default', + }, +} +``` + +**步骤 3**: 在菜单中添加入口(如需要) + +如需在侧边栏显示,需在相应的位置添加菜单配置。 + +## 核心架构说明 + +### HTTP 请求封装 + +位于 `src/utils/request.ts`,使用 hook-fetch: + +```typescript +import { get, post, put, del } from '@/utils/request' + +// GET 请求 +const { data } = await get('/api/endpoint').json() + +// POST 请求 +const result = await post('/api/endpoint', { key: 'value' }).json() +``` + +特点: +- 自动附加 JWT Token 到请求头 +- 自动处理 401/403 错误 +- 支持 Server-Sent Events (SSE) 流式响应 + +### 状态管理 + +Pinia stores 位于 `src/stores/modules/`: + +| Store | 用途 | +|-------|------| +| user.ts | 用户认证、登录状态 | +| chat.ts | 聊天消息、流式输出 | +| session.ts | 会话列表、当前会话 | +| model.ts | AI 模型配置 | + +使用方式: + +```typescript +const userStore = useUserStore() +userStore.setToken(token, refreshToken) +userStore.logout() +``` + +### 自动导入 + +项目已配置 `unplugin-auto-import`,以下 API 无需手动 import: + +- Vue API: `ref`, `reactive`, `computed`, `watch`, `onMounted` 等 +- Vue Router: `useRoute`, `useRouter` +- Pinia: `createPinia`, `storeToRefs` +- VueUse: `useFetch`, `useStorage` 等 + +### 路径别名 + +| 别名 | 对应路径 | +|------|----------| +| `@/` | `src/` | +| `@components/` | `src/vue-element-plus-y/components/` | + +### 环境变量 + +开发配置在 `.env.development`: + +``` +VITE_WEB_BASE_API=/dev-api # API 前缀 +VITE_API_URL=http://localhost:19001/api/app # 后端地址 +VITE_SSO_SEVER_URL=http://localhost:18001 # SSO 地址 +``` + +Vite 开发服务器会自动将 `/dev-api` 代理到后端 API。 + +## 代码规范 + +### 提交规范 + +使用 `pnpm cz` 进行规范提交,类型包括: +- `feat`: 新功能 +- `fix`: 修复 +- `docs`: 文档 +- `style`: 代码格式 +- `refactor`: 重构 +- `perf`: 性能优化 +- `test`: 测试 +- `build`: 构建 +- `ci`: CI/CD +- `revert`: 回滚 +- `chore`: 其他 + +### Git Hooks + +- **pre-commit**: 自动运行 ESLint 修复 +- **commit-msg**: 校验提交信息格式 + +## 构建优化 + +Vite 配置中的代码分割策略(`vite.config.ts`): + +| Chunk 名称 | 包含内容 | +|-----------|---------| +| vue-vendor | Vue 核心库、Vue Router | +| pinia | Pinia 状态管理 | +| element-plus | Element Plus UI 库 | +| markdown | Markdown 解析相关 | +| utils | Lodash、VueUse 工具库 | +| highlight | 代码高亮库 | +| echarts | 图表库 | +| pdf | PDF 处理库 | + +## 后端集成 + +后端为 .NET 8 项目,本地启动命令: + +```bash +cd E:\devDemo\Yi\Yi.Abp.Net8\src\Yi.Abp.Web +dotnet run +``` + +前端开发时,后端默认运行在 `http://localhost:19001`。 diff --git a/Yi.Ai.Vue3/FONTAWESOME_MIGRATION.md b/Yi.Ai.Vue3/FONTAWESOME_MIGRATION.md new file mode 100644 index 00000000..b5ef1146 --- /dev/null +++ b/Yi.Ai.Vue3/FONTAWESOME_MIGRATION.md @@ -0,0 +1,133 @@ +# FontAwesome 图标迁移指南 + +## 迁移步骤 + +### 1. 在组件中使用 FontAwesomeIcon + +```vue + + + + + + + + +``` + +```vue + + + + + + + + + + + +``` + +### 2. 自动映射工具 + +使用 `getFontAwesomeIcon` 函数自动映射图标名: + +```typescript +import { getFontAwesomeIcon } from '@/utils/icon-mapping'; + +// 将 Element Plus 图标名转换为 FontAwesome 图标名 +const faIcon = getFontAwesomeIcon('Check'); // 返回 'check' +const faIcon2 = getFontAwesomeIcon('ArrowLeft'); // 返回 'arrow-left' +``` + +### 3. Props 说明 + +| Prop | 类型 | 可选值 | 说明 | +|------|------|--------|------| +| icon | string | 任意 FontAwesome 图标名 | 图标名称(不含 fa- 前缀) | +| size | string | xs, sm, lg, xl, 2x, 3x, 4x, 5x | 图标大小 | +| spin | boolean | true/false | 是否旋转 | +| pulse | boolean | true/false | 是否脉冲动画 | +| rotation | number | 0, 90, 180, 270 | 旋转角度 | + +### 4. 常用图标对照表 + +| Element Plus | FontAwesome | +|--------------|-------------| +| Check | check | +| Close | xmark | +| Delete | trash | +| Edit | pen-to-square | +| Plus | plus | +| Search | magnifying-glass | +| Refresh | rotate-right | +| Loading | spinner | +| Download | download | +| ArrowLeft | arrow-left | +| ArrowRight | arrow-right | +| User | user | +| Setting | gear | +| View | eye | +| Hide | eye-slash | +| Lock | lock | +| Share | share-nodes | +| Heart | heart | +| Star | star | +| Clock | clock | +| Calendar | calendar | +| ChatLineRound | comment | +| Bell | bell | +| Document | file | +| Picture | image | + +### 5. 批量迁移示例 + +```vue + + + + + + + + + + + + + + + + + + + + + +``` + +## 注意事项 + +1. **无需手动导入**:FontAwesomeIcon 组件已在 `vite.config.ts` 中配置为自动导入 +2. **图标名格式**:使用小写、带连字符的图标名(如 `magnifying-glass`) +3. **完整图标列表**:访问 [FontAwesome 官网](https://fontawesome.com/search?o=r&m=free) 查看所有可用图标 +4. **渐进步骤**:可以逐步迁移,Element Plus 图标和 FontAwesome 可以共存 + +## 优化建议 + +1. **减少包体积**:迁移后可以移除 `@element-plus/icons-vue` 依赖 +2. **统一图标风格**:FontAwesome 图标风格更统一 +3. **更好的性能**:FontAwesome 按需加载,不会加载未使用的图标 + +## 查找图标 + +- [Solid Icons 搜索](https://fontawesome.com/search?o=r&m=free) +- 图标名格式:`fa-solid fa-icon-name` +- 在代码中使用时只需:`icon="icon-name"` diff --git a/Yi.Ai.Vue3/index.html b/Yi.Ai.Vue3/index.html index 70c5ca53..f62b1dec 100644 --- a/Yi.Ai.Vue3/index.html +++ b/Yi.Ai.Vue3/index.html @@ -17,6 +17,14 @@ + + + + + + + + diff --git a/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.ts b/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.ts new file mode 100644 index 00000000..7e1e0dc6 --- /dev/null +++ b/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.ts @@ -0,0 +1,3 @@ +export { default as FontAwesomeIcon } from './index.vue'; +export { default as FontAwesomeDemo } from './demo.vue'; +export { getFontAwesomeIcon, iconMapping } from '@/utils/icon-mapping'; diff --git a/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.vue b/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.vue new file mode 100644 index 00000000..2bb285bd --- /dev/null +++ b/Yi.Ai.Vue3/src/components/FontAwesomeIcon/index.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/Yi.Ai.Vue3/src/components/MarkedMarkdown/index.vue b/Yi.Ai.Vue3/src/components/MarkedMarkdown/index.vue index b457c6ad..6d0a078b 100644 --- a/Yi.Ai.Vue3/src/components/MarkedMarkdown/index.vue +++ b/Yi.Ai.Vue3/src/components/MarkedMarkdown/index.vue @@ -1,7 +1,7 @@ diff --git a/Yi.Ai.Vue3/src/components/ProductPackage/PackageTab.vue b/Yi.Ai.Vue3/src/components/ProductPackage/PackageTab.vue index 2bf3b79a..100f7ffa 100644 --- a/Yi.Ai.Vue3/src/components/ProductPackage/PackageTab.vue +++ b/Yi.Ai.Vue3/src/components/ProductPackage/PackageTab.vue @@ -1,10 +1,13 @@ diff --git a/Yi.Ai.Vue3/src/components/SupportModelProducts/indexl.vue b/Yi.Ai.Vue3/src/components/SupportModelProducts/indexl.vue index df88ebd9..c73acda0 100644 --- a/Yi.Ai.Vue3/src/components/SupportModelProducts/indexl.vue +++ b/Yi.Ai.Vue3/src/components/SupportModelProducts/indexl.vue @@ -1,4 +1,8 @@ diff --git a/Yi.Ai.Vue3/src/components/userPersonalCenter/components/CardFlipActivity.vue b/Yi.Ai.Vue3/src/components/userPersonalCenter/components/CardFlipActivity.vue index 14e47109..b360aa19 100644 --- a/Yi.Ai.Vue3/src/components/userPersonalCenter/components/CardFlipActivity.vue +++ b/Yi.Ai.Vue3/src/components/userPersonalCenter/components/CardFlipActivity.vue @@ -1,4 +1,4 @@ - @@ -13,7 +7,6 @@ function goToModelLibrary() { 模型库 diff --git a/Yi.Ai.Vue3/src/layouts/components/Header/components/RankingBtn.vue b/Yi.Ai.Vue3/src/layouts/components/Header/components/RankingBtn.vue new file mode 100644 index 00000000..433b2355 --- /dev/null +++ b/Yi.Ai.Vue3/src/layouts/components/Header/components/RankingBtn.vue @@ -0,0 +1,83 @@ + + + + + + + 排行榜 + + + + + + + + + + + + + + diff --git a/Yi.Ai.Vue3/src/layouts/components/Header/index.vue b/Yi.Ai.Vue3/src/layouts/components/Header/index.vue index c6139636..dd99fc29 100644 --- a/Yi.Ai.Vue3/src/layouts/components/Header/index.vue +++ b/Yi.Ai.Vue3/src/layouts/components/Header/index.vue @@ -12,7 +12,6 @@ import Avatar from './components/Avatar.vue'; import BuyBtn from './components/BuyBtn.vue'; import ContactUsBtn from './components/ContactUsBtn.vue'; import LoginBtn from './components/LoginBtn.vue'; -import ModelLibraryBtn from './components/ModelLibraryBtn.vue'; import ThemeBtn from './components/ThemeBtn.vue'; const router = useRouter(); @@ -42,7 +41,7 @@ const mobileMenuVisible = ref(false); const activeIndex = computed(() => { if (route.path.startsWith('/console')) return 'console'; - if (route.path.startsWith('/model-library')) + if (route.path.startsWith('/model-library') || route.path.startsWith('/ranking')) return 'model-library'; if (route.path.includes('/chat/')) return 'chat'; @@ -71,6 +70,19 @@ function handleConsoleClick(e: MouseEvent) { mobileMenuVisible.value = false; } +// 修改模型库菜单的点击事件 +function handleModelLibraryClick(e: MouseEvent) { + e.stopPropagation(); // 阻止事件冒泡 + router.push('/model-library'); + mobileMenuVisible.value = false; +} + +// 跳转到模型监控外部链接 +function goToModelMonitor() { + window.open('http://data.ccnetcore.com:91/?period=24h', '_blank'); + mobileMenuVisible.value = false; +} + // 切换移动端菜单 function toggleMobileMenu() { mobileMenuVisible.value = !mobileMenuVisible.value; @@ -122,10 +134,18 @@ function toggleMobileMenu() { - - - - + + + + 模型库 + + + 模型排行榜 + + + 模型监控 + + @@ -254,11 +274,19 @@ function toggleMobileMenu() { - - - - 模型库 - + + + + + 模型库 + + + 模型排行榜 + + + 模型监控 + + @@ -412,9 +440,10 @@ function toggleMobileMenu() { } } -// 聊天和控制台子菜单 +// 聊天、模型库和控制台子菜单 .chat-submenu, -.console-submenu { +.console-submenu, +.model-library-submenu { :deep(.el-sub-menu__title) { display: flex; align-items: center; @@ -674,4 +703,7 @@ function toggleMobileMenu() { } } } +.el-sub-menu .el-sub-menu__icon-arrow{ + margin-right: -20px; +} diff --git a/Yi.Ai.Vue3/src/layouts/index.vue b/Yi.Ai.Vue3/src/layouts/index.vue index c37bc699..1a92f92d 100644 --- a/Yi.Ai.Vue3/src/layouts/index.vue +++ b/Yi.Ai.Vue3/src/layouts/index.vue @@ -35,30 +35,6 @@ const layout = computed((): LayoutType | 'mobile' => { // 否则使用全局设置的 layout return designStore.layout; }); - -onMounted(() => { - // 更好的做法是等待所有资源加载 - window.addEventListener('load', () => { - const loader = document.getElementById('yixinai-loader'); - if (loader) { - loader.style.opacity = '0'; - setTimeout(() => { - loader.style.display = 'none'; - }, 500); // 匹配过渡时间 - } - }); - - // 设置超时作为兜底 - setTimeout(() => { - const loader = document.getElementById('yixinai-loader'); - if (loader) { - loader.style.opacity = '0'; - setTimeout(() => { - loader.style.display = 'none'; - }, 500); - } - }, 500); // 最多显示0.5秒 -}); diff --git a/Yi.Ai.Vue3/src/main.ts b/Yi.Ai.Vue3/src/main.ts index 501dcb4f..2030ebdc 100644 --- a/Yi.Ai.Vue3/src/main.ts +++ b/Yi.Ai.Vue3/src/main.ts @@ -1,14 +1,14 @@ // 引入ElementPlus所有图标 import * as ElementPlusIconsVue from '@element-plus/icons-vue'; -import { ElMessage } from 'element-plus'; import { createApp } from 'vue'; import ElementPlusX from 'vue-element-plus-x'; +import 'element-plus/dist/index.css'; import App from './App.vue'; +import { logBuildInfo } from './config/version'; import router from './routers'; import store from './stores'; import './styles/index.scss'; import 'virtual:uno.css'; -import 'element-plus/dist/index.css'; import 'virtual:svg-icons-register'; // 创建 Vue 应用 @@ -16,27 +16,78 @@ const app = createApp(App); // 安装插件 app.use(router); -app.use(ElMessage); +app.use(store); app.use(ElementPlusX); -// 注册图标 +// 注册所有 Element Plus 图标(临时方案,后续迁移到 fontawesome) for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component); } -app.use(store); +// 输出构建信息(使用统一版本配置) +logBuildInfo(); -// 输出构建信息 -console.log( - `%c 意心AI 3.3 %c Build Info `, - 'background:#35495e; padding: 4px; border-radius: 3px 0 0 3px; color: #fff', - 'background:#41b883; padding: 4px; border-radius: 0 3px 3px 0; color: #fff', -); -// console.log(`🔹 Git Branch: ${__GIT_BRANCH__}`); -console.log(`🔹 Git Commit: ${__GIT_HASH__}`); -// console.log(`🔹 Commit Date: ${__GIT_DATE__}`); -// console.log(`🔹 Build Time: ${__BUILD_TIME__}`); +import { nextTick } from 'vue'; // 挂载 Vue 应用 -// mount 完成说明应用初始化完毕,此时手动通知 loading 动画结束 app.mount('#app'); + +/** + * 检查页面是否真正渲染完成 + * 改进策略: + * 1. 等待多个 requestAnimationFrame 确保浏览器完成绘制 + * 2. 检查关键元素是否存在且有实际内容 + * 3. 检查关键 CSS 是否已应用 + * 4. 给予最小展示时间,避免闪烁 + */ +function waitForPageRendered(): Promise { + return new Promise((resolve) => { + const minDisplayTime = 800; // 最小展示时间 800ms,避免闪烁 + const maxWaitTime = 8000; // 最大等待时间 8 秒 + const startTime = Date.now(); + + const checkRender = () => { + const elapsed = Date.now() - startTime; + const appElement = document.getElementById('app'); + + // 检查关键条件 + const hasContent = appElement?.children.length > 0; + const hasVisibleHeight = (appElement?.offsetHeight || 0) > 200; + const hasRouterView = document.querySelector('.layout-container') !== null || + document.querySelector('.el-container') !== null || + document.querySelector('#app > div') !== null; + + const isRendered = hasContent && hasVisibleHeight && hasRouterView; + const isMinTimeMet = elapsed >= minDisplayTime; + const isTimeout = elapsed >= maxWaitTime; + + if ((isRendered && isMinTimeMet) || isTimeout) { + // 再多给一帧时间确保稳定 + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + } else { + requestAnimationFrame(checkRender); + } + }; + + // 等待 Vue 更新和浏览器绘制 + nextTick(() => { + requestAnimationFrame(() => { + setTimeout(checkRender, 100); + }); + }); + }); +} + +// 第一阶段:Vue 应用已挂载 +if (typeof window.__hideAppLoader === 'function') { + window.__hideAppLoader('mounted'); +} + +// 等待页面真正渲染完成后再通知第二阶段 +waitForPageRendered().then(() => { + if (typeof window.__hideAppLoader === 'function') { + window.__hideAppLoader('rendered'); + } +}); diff --git a/Yi.Ai.Vue3/src/pages/chat/components/ChatHeader.vue b/Yi.Ai.Vue3/src/pages/chat/components/ChatHeader.vue new file mode 100644 index 00000000..c8629b9e --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/components/ChatHeader.vue @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Yi.Ai.Vue3/src/pages/chat/components/ChatSender.vue b/Yi.Ai.Vue3/src/pages/chat/components/ChatSender.vue new file mode 100644 index 00000000..09a9a22d --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/components/ChatSender.vue @@ -0,0 +1,206 @@ + + + + + emit('submit', v)" + @cancel="emit('cancel')" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Yi.Ai.Vue3/src/pages/chat/components/DeleteModeToolbar.vue b/Yi.Ai.Vue3/src/pages/chat/components/DeleteModeToolbar.vue new file mode 100644 index 00000000..a4b5780a --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/components/DeleteModeToolbar.vue @@ -0,0 +1,90 @@ + + + + + + 已选择 {{ selectedCount }} 条消息 + + + 确认删除 + + + 取消 + + + + + + diff --git a/Yi.Ai.Vue3/src/pages/chat/components/MessageItem.vue b/Yi.Ai.Vue3/src/pages/chat/components/MessageItem.vue new file mode 100644 index 00000000..afd1b489 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/components/MessageItem.vue @@ -0,0 +1,588 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 发送 + + + 取消 + + + + + + + + + + + + + + + + + + + {{ file.name }} + + + + + + {{ item.content }} + + + + + + + + + + + + diff --git a/Yi.Ai.Vue3/src/pages/chat/components/index.ts b/Yi.Ai.Vue3/src/pages/chat/components/index.ts new file mode 100644 index 00000000..306d5172 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/components/index.ts @@ -0,0 +1,6 @@ +// Chat 页面组件统一导出 + +export { default as ChatHeader } from './ChatHeader.vue'; +export { default as ChatSender } from './ChatSender.vue'; +export { default as MessageItem } from './MessageItem.vue'; +export { default as DeleteModeToolbar } from './DeleteModeToolbar.vue'; diff --git a/Yi.Ai.Vue3/src/pages/chat/layouts/chatDefaul/index.vue b/Yi.Ai.Vue3/src/pages/chat/layouts/chatDefaul/index.vue index 3eefcc93..02e29158 100644 --- a/Yi.Ai.Vue3/src/pages/chat/layouts/chatDefaul/index.vue +++ b/Yi.Ai.Vue3/src/pages/chat/layouts/chatDefaul/index.vue @@ -1,61 +1,77 @@ - - - - - - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - handleImagePreview(image.url)" - > - - - - - - - - {{ file.name }} - - - - - {{ item.content }} - - - + + + - - - + + + + + - - - - - - - - - - - - - - - - - - + + filesStore.deleteFileByIndex(index)" + /> - + - - - + @@ -967,281 +617,139 @@ onUnmounted(() => { diff --git a/Yi.Ai.Vue3/src/pages/chat/styles/bubble.scss b/Yi.Ai.Vue3/src/pages/chat/styles/bubble.scss new file mode 100644 index 00000000..d78e5482 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/styles/bubble.scss @@ -0,0 +1,169 @@ +// 气泡列表相关样式 (需要 :deep 穿透) + +// 基础气泡列表样式 +@mixin bubble-list-base { + :deep(.el-bubble-list) { + padding-top: 24px; + + @media (max-width: 768px) { + padding-top: 16px; + } + } +} + +// 气泡基础样式 +@mixin bubble-base { + :deep(.el-bubble) { + padding: 0 12px 24px; + + // 隐藏头像 + .el-avatar { + display: none !important; + } + + // 用户消息样式 + &[class*="end"] { + width: 100%; + max-width: 100%; + + .el-bubble-content-wrapper { + flex: none; + max-width: fit-content; + } + + .el-bubble-content { + width: fit-content; + max-width: 100%; + } + } + + @media (max-width: 768px) { + padding: 0 8px 16px; + } + + @media (max-width: 480px) { + padding: 0 6px; + padding-bottom: 12px; + } + } +} + +// AI消息样式 +@mixin bubble-ai-style { + :deep(.el-bubble[class*="start"]) { + width: 100%; + max-width: 100%; + + .el-bubble-content-wrapper { + flex: auto; + } + + .el-bubble-content { + width: 100%; + max-width: 100%; + padding: 0; + background: transparent !important; + border: none !important; + box-shadow: none !important; + } + } +} + +// 用户编辑模式样式 +@mixin bubble-edit-mode { + :deep(.el-bubble[class*="end"]) { + &:has(.edit-message-wrapper-full) { + .el-bubble-content-wrapper { + flex: auto; + max-width: 100%; + } + + .el-bubble-content { + width: 100%; + max-width: 100%; + } + } + } +} + +// 删除模式气泡样式 +@mixin bubble-delete-mode { + :deep(.el-bubble-list.delete-mode) { + .el-bubble { + &[class*="end"] .el-bubble-content-wrapper { + flex: auto; + max-width: 100%; + } + + .el-bubble-content { + position: relative; + min-height: 44px; + padding: 12px 16px; + background-color: #f5f7fa; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background-color: #e8f0fe; + border-color: #c6dafc; + } + } + + &:has(.el-checkbox.is-checked) .el-bubble-content { + background-color: #d2e3fc; + border-color: #8ab4f8; + } + } + + .delete-checkbox-inline { + position: absolute; + left: 12px; + top: 12px; + z-index: 2; + + :deep(.el-checkbox) { + --el-checkbox-input-height: 20px; + --el-checkbox-input-width: 20px; + } + } + + .ai-content-wrapper, + .user-content-wrapper { + margin-left: 36px; + width: calc(100% - 36px); + } + + .user-content-wrapper { + align-items: flex-start; + + .edit-message-wrapper-full { + width: 100%; + max-width: 100%; + } + } + } +} + +// Typewriter 样式 +@mixin typewriter-style { + :deep(.el-typewriter) { + overflow: hidden; + border-radius: 12px; + } +} + +// Markdown 容器样式 +@mixin markdown-container { + :deep(.elx-xmarkdown-container) { + padding: 8px 4px; + } +} + +// 代码块头部样式 +@mixin code-header { + :deep(.markdown-elxLanguage-header-div) { + top: -25px !important; + } +} diff --git a/Yi.Ai.Vue3/src/pages/chat/styles/index.scss b/Yi.Ai.Vue3/src/pages/chat/styles/index.scss new file mode 100644 index 00000000..b8306d20 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/styles/index.scss @@ -0,0 +1,5 @@ +// Chat 页面公共样式统一导入 + +@forward './variables'; +@forward './mixins'; +@forward './bubble'; diff --git a/Yi.Ai.Vue3/src/pages/chat/styles/mixins.scss b/Yi.Ai.Vue3/src/pages/chat/styles/mixins.scss new file mode 100644 index 00000000..f80b6586 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/styles/mixins.scss @@ -0,0 +1,102 @@ +// 聊天页面公共 mixins + +// 响应式 +@mixin respond-to($breakpoint) { + @if $breakpoint == tablet { + @media (max-width: 768px) { + @content; + } + } @else if $breakpoint == mobile { + @media (max-width: 480px) { + @content; + } + } +} + +// 弹性布局 +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +@mixin flex-column { + display: flex; + flex-direction: column; +} + +// 文本省略 +@mixin text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// 多行文本省略 +@mixin text-ellipsis-multi($lines: 2) { + display: -webkit-box; + -webkit-line-clamp: $lines; + -webkit-box-orient: vertical; + overflow: hidden; +} + +// 滚动按钮样式 +@mixin scroll-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + @include flex-center; + width: 22px; + height: 22px; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.08); + color: rgba(0, 0, 0, 0.4); + background-color: #fff; + font-size: 10px; + cursor: pointer; + z-index: 10; + transition: all 0.2s ease; + + &:hover { + background-color: #f3f4f6; + border-color: rgba(0, 0, 0, 0.15); + color: rgba(0, 0, 0, 0.6); + } +} + +// 操作按钮样式 +@mixin action-btn { + width: 24px; + height: 24px; + padding: 0; + font-size: 13px; + color: #555; + background: transparent; + border: none; + border-radius: 4px; + transition: all 0.2s ease; + + &:hover { + color: #409eff; + background: #f0f7ff; + } + + &:active { + background: #e6f2ff; + } + + &[disabled] { + color: #bbb; + background: transparent; + } + + .el-icon { + font-size: 13px; + } +} diff --git a/Yi.Ai.Vue3/src/pages/chat/styles/variables.scss b/Yi.Ai.Vue3/src/pages/chat/styles/variables.scss new file mode 100644 index 00000000..ef70f4ae --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/chat/styles/variables.scss @@ -0,0 +1,56 @@ +// 聊天页面公共样式变量 + +// 布局 +$chat-header-height: 60px; +$chat-header-height-mobile: 50px; +$chat-header-height-small: 48px; + +$chat-max-width: 1000px; +$chat-padding: 20px; +$chat-padding-mobile: 12px; +$chat-padding-small: 8px; + +// 气泡列表 +$bubble-padding-y: 24px; +$bubble-padding-x: 12px; +$bubble-gap: 24px; + +$bubble-padding-y-mobile: 16px; +$bubble-padding-x-mobile: 8px; +$bubble-gap-mobile: 16px; + +// 用户消息 +$user-image-max-size: 200px; +$user-image-max-size-mobile: 150px; +$user-image-max-size-small: 120px; + +// 颜色 +$color-text-primary: #333; +$color-text-secondary: #888; +$color-text-tertiary: #bbb; + +$color-border: rgba(0, 0, 0, 0.08); +$color-border-hover: rgba(0, 0, 0, 0.15); + +$color-bg-hover: #f3f4f6; +$color-bg-light: #f5f7fa; +$color-bg-lighter: #e8f0fe; +$color-bg-selected: #d2e3fc; +$color-border-selected: #8ab4f8; + +$color-primary: #409eff; +$color-primary-light: #f0f7ff; +$color-primary-lighter: #e6f2ff; + +// 删除模式 +$color-delete-bg: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%); +$color-delete-border: #fed7aa; +$color-delete-text: #ea580c; + +// 动画 +$transition-fast: 0.2s ease; +$transition-normal: 0.3s ease; + +// 响应式断点 +$breakpoint-tablet: 768px; +$breakpoint-mobile: 480px; diff --git a/Yi.Ai.Vue3/src/pages/modelLibrary/index.vue b/Yi.Ai.Vue3/src/pages/modelLibrary/index.vue index 2f31fac4..c9ee0c45 100644 --- a/Yi.Ai.Vue3/src/pages/modelLibrary/index.vue +++ b/Yi.Ai.Vue3/src/pages/modelLibrary/index.vue @@ -249,6 +249,9 @@ onMounted(() => { 探索并接入全球顶尖AI模型,覆盖文本、图像、嵌入等多个领域 + + 尊享Token = 实际消耗Token * 当前模型倍率 + @@ -297,7 +300,7 @@ onMounted(() => { - + 点击查看 diff --git a/Yi.Ai.Vue3/src/pages/ranking/index.vue b/Yi.Ai.Vue3/src/pages/ranking/index.vue new file mode 100644 index 00000000..f9bafc41 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/ranking/index.vue @@ -0,0 +1,1322 @@ + + + + + + + + + + + + + 意心Ai全球大模型实时排行榜(编程) + 基于综合性能、用户活跃度、代码质量等多维度评估 + + + + + 点击测测当前你的Ai编程能力 + + + + + + + + + + + + 模型排行(编程) + + Top {{ modelRankingList.length }} + + + + + + + + + + + + + + + {{ index + 1 }} + + + + + + + + + + {{ item.name }} + + {{ item.provider }} + | + {{ item.description }} + + + + + + + + {{ item.score }} + 分 + + + + + + + + + + + + + 工具排行(编程) + + Top {{ toolRankingList.length }} + + + + + + + + + + + + + + + {{ index + 1 }} + + + + + + + + + + {{ item.name }} + + {{ item.provider }} + | + {{ item.description }} + + + + + + + + {{ item.score }} + 分 + + + + + + + + + + + + + + 1 + 请选择你正在使用的大模型 + + + + {{ index + 1 }} + + + + + + + + {{ item.name }} + + {{ item.provider }} + | + {{ item.description }} + + + {{ item.score }}分 + + + + + + + + 2 + 请选择你正在使用的编程工具 + + 已选模型:{{ selectedModel.name }} + + + + + {{ index + 1 }} + + + + + + + + {{ item.name }} + + {{ item.provider }} + | + {{ item.description }} + + + {{ item.score }}分 + + + + + + + + + 计算完成 + + + + + 大模型 + {{ selectedModel?.name }} + {{ selectedModel?.score }}分 × 65% + + + + + 编程工具 + {{ selectedTool?.name }} + {{ selectedTool?.score }}分 × 35% + + + + + AI编程能力综合评分 + {{ calculatedScore }}分 + 恭喜你,陪伴你的AI是一个{{ scoreDescription }} + + + + + + + + + + + + + + + diff --git a/Yi.Ai.Vue3/src/pages/test/fontawesome.vue b/Yi.Ai.Vue3/src/pages/test/fontawesome.vue new file mode 100644 index 00000000..0c4521a0 --- /dev/null +++ b/Yi.Ai.Vue3/src/pages/test/fontawesome.vue @@ -0,0 +1,29 @@ + + + + + FontAwesome 图标测试页面 + 如果看到以下图标正常显示,说明 FontAwesome 配置成功! + + + + + diff --git a/Yi.Ai.Vue3/src/routers/index.ts b/Yi.Ai.Vue3/src/routers/index.ts index be40af42..42881c36 100644 --- a/Yi.Ai.Vue3/src/routers/index.ts +++ b/Yi.Ai.Vue3/src/routers/index.ts @@ -5,24 +5,69 @@ import { createRouter, createWebHistory } from 'vue-router'; import { ROUTER_WHITE_LIST } from '@/config'; import { checkPagePermission } from '@/config/permission'; import { errorRouter, layoutRouter, staticRouter } from '@/routers/modules/staticRouter'; -import { useDesignStore, useUserStore } from '@/stores'; +import { useUserStore } from '@/stores'; +import { useDesignStore } from '@/stores/modules/design'; -// 创建页面加载进度条,提升用户体验。 +// 创建页面加载进度条,提升用户体验 const { start, done } = useNProgress(0, { - showSpinner: false, // 不显示旋转器 - trickleSpeed: 200, // 进度条增长速度(毫秒) - minimum: 0.3, // 最小进度值(30%) - easing: 'ease', // 动画缓动函数 - speed: 500, // 动画速度 + showSpinner: false, + trickleSpeed: 200, + minimum: 0.3, + easing: 'ease', + speed: 500, }); + // 创建路由实例 const router = createRouter({ - history: createWebHistory(), // 使用 HTML5 History 模式 - routes: [...layoutRouter, ...staticRouter, ...errorRouter], // 合并所有路由 - strict: false, // 不严格匹配尾部斜杠 - scrollBehavior: () => ({ left: 0, top: 0 }), // 路由切换时滚动到顶部 + history: createWebHistory(), + routes: [...layoutRouter, ...staticRouter, ...errorRouter], + strict: false, + scrollBehavior: () => ({ left: 0, top: 0 }), }); +// 预加载标记,防止重复预加载 +const preloadedComponents = new Set(); + +/** + * 预加载路由组件 + * 提前加载可能访问的路由组件,减少路由切换时的等待时间 + */ +function preloadRouteComponents() { + // 预加载核心路由组件 + const coreRoutes = [ + '/chat/conversation', + '/chat/image', + '/chat/video', + '/chat/agent', + '/console/user', + '/model-library', + ]; + + // 延迟预加载,避免影响首屏加载 + setTimeout(() => { + coreRoutes.forEach(path => { + const route = router.resolve(path); + if (route.matched.length > 0) { + const component = route.matched[route.matched.length - 1].components?.default; + if (typeof component === 'function' && !preloadedComponents.has(component)) { + preloadedComponents.add(component); + // 异步预加载,不阻塞主线程 + requestIdleCallback?.(() => { + (component as () => Promise)().catch(() => {}); + }) || setTimeout(() => { + (component as () => Promise)().catch(() => {}); + }, 100); + } + } + }); + }, 2000); +} + +// 首屏加载完成后开始预加载 +if (typeof window !== 'undefined') { + window.addEventListener('load', preloadRouteComponents); +} + // 路由前置守卫 router.beforeEach( async ( @@ -30,54 +75,67 @@ router.beforeEach( _from: RouteLocationNormalized, next: NavigationGuardNext, ) => { - // 1. 获取状态管理 - const userStore = useUserStore(); - const designStore = useDesignStore(); // 必须在守卫内部调用 - // 2. 设置布局(根据路由meta中的layout配置) - designStore._setLayout(to.meta?.layout || 'default'); - - // 3. 开始显示进度条 + // 1. 开始显示进度条 start(); - // 4. 设置页面标题 + // 2. 设置页面标题 document.title = (to.meta.title as string) || (import.meta.env.VITE_WEB_TITLE as string); - // 3、权限 预留 - // 3、判断是访问登陆页,有Token访问当前页面,token过期访问接口,axios封装则自动跳转登录页面,没有Token重置路由到登陆页。 - // if (to.path.toLocaleLowerCase() === LOGIN_URL) { - // // 有Token访问当前页面 - // if (userStore.token) { - // return next(from.fullPath); - // } - // else { - // ElMessage.error('账号身份已过期,请重新登录'); - // } - // // 没有Token重置路由到登陆页。 - // // resetRouter(); // 预留 - // return next(); - // } - // 4、判断访问页面是否在路由白名单地址[静态路由]中,如果存在直接放行。 + // 3. 设置布局(使用 setTimeout 避免阻塞导航) + const layout = to.meta?.layout || 'default'; + setTimeout(() => { + try { + const designStore = useDesignStore(); + designStore._setLayout(layout); + } catch (e) { + // 忽略 store 初始化错误 + } + }, 0); + + // 4. 检查路由是否存在(404 处理) + // 如果 to.matched 为空且 to.name 不存在,说明路由未匹配 + if (to.matched.length === 0 || (to.matched.length === 1 && to.matched[0].path === '/:pathMatch(.*)*')) { + // 404 路由已定义在 errorRouter 中,这里不需要额外处理 + } + // 5. 白名单检查(跳过权限验证) - if (ROUTER_WHITE_LIST.includes(to.path)) + if (ROUTER_WHITE_LIST.some(path => { + // 支持通配符匹配 + if (path.includes(':')) { + const pattern = path.replace(/:\w+/g, '[^/]+'); + const regex = new RegExp(`^${pattern}$`); + return regex.test(to.path); + } + return path === to.path; + })) { return next(); + } - // 6. Token 检查(用户认证),没有重定向到 login 页面。 - if (!userStore.token) - userStore.logout(); + // 6. 获取用户状态(延迟加载,避免阻塞) + let userStore; + try { + userStore = useUserStore(); + } catch (e) { + // Store 未初始化,允许继续 + return next(); + } - // 7. 页面权限检查 + // 7. Token 检查(用户认证) + if (!userStore.token) { + userStore.clearUserInfo(); + return next({ path: '/', replace: true }); + } + + // 8. 页面权限检查 const userName = userStore.userInfo?.user?.userName; const hasPermission = checkPagePermission(to.path, userName); if (!hasPermission) { - // 用户无权访问该页面,跳转到403页面 ElMessage.warning('您没有权限访问该页面'); - return next('/403'); + return next({ path: '/403', replace: true }); } - // 其余逻辑 预留... - - // 8. 放行路由 + // 9. 放行路由 next(); }, ); diff --git a/Yi.Ai.Vue3/src/routers/modules/staticRouter.ts b/Yi.Ai.Vue3/src/routers/modules/staticRouter.ts index 99d72ee6..39337a03 100644 --- a/Yi.Ai.Vue3/src/routers/modules/staticRouter.ts +++ b/Yi.Ai.Vue3/src/routers/modules/staticRouter.ts @@ -1,10 +1,22 @@ import type { RouteRecordRaw } from 'vue-router'; +// 预加载辅助函数 +function preloadComponent(importFn: () => Promise) { + return () => { + // 在开发环境下直接返回 + if (import.meta.env.DEV) { + return importFn(); + } + // 生产环境下可以添加缓存逻辑 + return importFn(); + }; +} + // LayoutRouter[布局路由] export const layoutRouter: RouteRecordRaw[] = [ { path: '/', - component: () => import('@/layouts/index.vue'), + component: preloadComponent(() => import('@/layouts/index.vue')), children: [ // 将首页重定向逻辑放在这里 { @@ -17,16 +29,12 @@ export const layoutRouter: RouteRecordRaw[] = [ path: 'chat', name: 'chat', component: () => import('@/pages/chat/index.vue'), + redirect: '/chat/conversation', meta: { title: 'AI应用', icon: 'HomeFilled', }, children: [ - // chat 根路径重定向到 conversation - { - path: '', - redirect: '/chat/conversation', - }, { path: 'conversation', name: 'chatConversation', @@ -98,6 +106,19 @@ export const layoutRouter: RouteRecordRaw[] = [ }, }, + // 排行榜 + { + path: 'ranking', + name: 'ranking', + component: () => import('@/pages/ranking/index.vue'), + meta: { + title: '意心Ai全球大模型实时排行榜(编程)', + keepAlive: 0, + isDefaultChat: false, + layout: 'default', + }, + }, + // 支付结果 { path: 'pay-result', @@ -140,17 +161,13 @@ export const layoutRouter: RouteRecordRaw[] = [ path: 'console', name: 'console', component: () => import('@/pages/console/index.vue'), + redirect: '/console/user', meta: { title: '意心Ai-控制台', icon: 'Setting', layout: 'default', }, children: [ - // console 根路径重定向到 user - { - path: '', - redirect: '/console/user', - }, { path: 'user', name: 'consoleUser', @@ -244,8 +261,18 @@ export const layoutRouter: RouteRecordRaw[] = [ ], }, ]; -// staticRouter[静态路由] 预留 -export const staticRouter: RouteRecordRaw[] = []; +// staticRouter[静态路由] +export const staticRouter: RouteRecordRaw[] = [ + // FontAwesome 测试页面 + { + path: '/test/fontawesome', + name: 'testFontAwesome', + component: () => import('@/pages/test/fontawesome.vue'), + meta: { + title: 'FontAwesome图标测试', + }, + }, +]; // errorRouter (错误页面路由) export const errorRouter = [ diff --git a/Yi.Ai.Vue3/src/stores/index.ts b/Yi.Ai.Vue3/src/stores/index.ts index cc1a71eb..a7041c70 100644 --- a/Yi.Ai.Vue3/src/stores/index.ts +++ b/Yi.Ai.Vue3/src/stores/index.ts @@ -8,7 +8,7 @@ store.use(piniaPluginPersistedstate); export default store; export * from './modules/announcement' -// export * from './modules/chat'; +export * from './modules/chat'; export * from './modules/design'; export * from './modules/user'; export * from './modules/guideTour'; diff --git a/Yi.Ai.Vue3/src/stores/modules/chat.ts b/Yi.Ai.Vue3/src/stores/modules/chat.ts index b17373b4..44edf658 100644 --- a/Yi.Ai.Vue3/src/stores/modules/chat.ts +++ b/Yi.Ai.Vue3/src/stores/modules/chat.ts @@ -109,18 +109,22 @@ export const useChatStore = defineStore('chat', () => { return { ...item, - key: item.id, + id: item.id, // 保留后端返回的消息ID + key: item.id ?? Math.random().toString(36).substring(2, 9), placement: isUser ? 'end' : 'start', + // 用户消息:气泡形状;AI消息:无气泡形状,宽度100% isMarkdown: !isUser, - avatar: isUser - ? getUserProfilePicture() - : systemProfilePicture, - avatarSize: '32px', + // 头像不显示(后续可能会显示) + // avatar: isUser ? getUserProfilePicture() : systemProfilePicture, + // avatarSize: '32px', typing: false, reasoning_content: thinkContent, thinkingStatus: 'end', content: finalContent, thinlCollapse: false, + // AI消息使用 noStyle 去除气泡样式 + noStyle: !isUser, + shape: isUser ? 'corner' : undefined, // 保留图片和文件信息(优先使用解析出来的,如果没有则使用原有的) images: images.length > 0 ? images : item.images, files: files.length > 0 ? files : item.files, diff --git a/Yi.Ai.Vue3/src/stores/modules/user.ts b/Yi.Ai.Vue3/src/stores/modules/user.ts index 2cd7f5e4..6ffa4406 100644 --- a/Yi.Ai.Vue3/src/stores/modules/user.ts +++ b/Yi.Ai.Vue3/src/stores/modules/user.ts @@ -1,12 +1,10 @@ import { defineStore } from 'pinia'; -import { useRouter } from 'vue-router'; export const useUserStore = defineStore( 'user', () => { const token = ref(); const refreshToken = ref(); - const router = useRouter(); const setToken = (value: string, refreshValue?: string) => { token.value = value; if (refreshValue) { @@ -30,7 +28,8 @@ export const useUserStore = defineStore( // 如果需要调用接口,可以在这里调用 clearToken(); clearUserInfo(); - router.replace({ name: 'chatConversationWithId' }); + // 不在 logout 中进行路由跳转,由调用方决定跳转逻辑 + // 这样可以避免路由守卫中的循环重定向问题 }; // 新增:登录弹框状态 diff --git a/Yi.Ai.Vue3/src/styles/var.scss b/Yi.Ai.Vue3/src/styles/var.scss index c3f24987..579004dd 100644 --- a/Yi.Ai.Vue3/src/styles/var.scss +++ b/Yi.Ai.Vue3/src/styles/var.scss @@ -215,6 +215,9 @@ --el-button-border-radius-base: var(--border-radius-md); --el-button-hover-bg-color: var(--color-primary-dark); --el-button-active-bg-color: var(--color-primary-darker); + + + --el-padding-sm: 6px } /* ========== 暗色模式变量 ========== */ diff --git a/Yi.Ai.Vue3/src/utils/apiFormatConverter.ts b/Yi.Ai.Vue3/src/utils/apiFormatConverter.ts index 868ea190..19fc1d26 100644 --- a/Yi.Ai.Vue3/src/utils/apiFormatConverter.ts +++ b/Yi.Ai.Vue3/src/utils/apiFormatConverter.ts @@ -230,6 +230,9 @@ export interface UnifiedStreamChunk { total_tokens?: number; }; finish_reason?: string; + messageId?: string; + creationTime?: string; + type?: string; } /** @@ -259,6 +262,17 @@ export function parseCompletionsStreamChunk(chunk: any): UnifiedStreamChunk { result.finish_reason = chunk.choices[0].finish_reason; } + // 解析消息ID和创建时间(UserMessage 或 SystemMessage 类型) + if (chunk.type === 'UserMessage' || chunk.type === 'SystemMessage') { + result.type = chunk.type; + if (chunk.messageId) { + result.messageId = chunk.messageId; + } + if (chunk.creationTime) { + result.creationTime = chunk.creationTime; + } + } + return result; } @@ -297,6 +311,17 @@ export function parseResponsesStreamChunk(chunk: any): UnifiedStreamChunk { }; } + // 解析消息ID和创建时间(UserMessage 或 SystemMessage 类型)- 后端统一封装 + if (chunk.type === 'UserMessage' || chunk.type === 'SystemMessage') { + result.type = chunk.type; + if (chunk.messageId) { + result.messageId = chunk.messageId; + } + if (chunk.creationTime) { + result.creationTime = chunk.creationTime; + } + } + return result; } @@ -328,6 +353,17 @@ export function parseClaudeStreamChunk(chunk: any): UnifiedStreamChunk { } } + // 解析消息ID和创建时间(UserMessage 或 SystemMessage 类型)- 后端统一封装 + if (chunk.type === 'UserMessage' || chunk.type === 'SystemMessage') { + result.type = chunk.type; + if (chunk.messageId) { + result.messageId = chunk.messageId; + } + if (chunk.creationTime) { + result.creationTime = chunk.creationTime; + } + } + return result; } @@ -365,6 +401,17 @@ export function parseGeminiStreamChunk(chunk: any): UnifiedStreamChunk { result.finish_reason = candidate.finishReason; } + // 解析消息ID和创建时间(UserMessage 或 SystemMessage 类型)- 后端统一封装 + if (chunk.type === 'UserMessage' || chunk.type === 'SystemMessage') { + result.type = chunk.type; + if (chunk.messageId) { + result.messageId = chunk.messageId; + } + if (chunk.creationTime) { + result.creationTime = chunk.creationTime; + } + } + return result; } diff --git a/Yi.Ai.Vue3/src/utils/icon-mapping.ts b/Yi.Ai.Vue3/src/utils/icon-mapping.ts new file mode 100644 index 00000000..29face0c --- /dev/null +++ b/Yi.Ai.Vue3/src/utils/icon-mapping.ts @@ -0,0 +1,123 @@ +/** + * Element Plus 图标到 FontAwesome 图标的映射 + * 用于迁移过程中的图标替换 + */ +export const iconMapping: Record = { + // 基础操作 + 'Check': 'check', + 'Close': 'xmark', + 'Delete': 'trash', + 'Edit': 'pen-to-square', + 'Plus': 'plus', + 'Minus': 'minus', + 'Search': 'magnifying-glass', + 'Refresh': 'rotate-right', + 'Loading': 'spinner', + 'Download': 'download', + 'Upload': 'upload', + + // 方向 + 'ArrowLeft': 'arrow-left', + 'ArrowRight': 'arrow-right', + 'ArrowUp': 'arrow-up', + 'ArrowDown': 'arrow-down', + 'ArrowLeftBold': 'arrow-left', + 'ArrowRightBold': 'arrow-right', + 'Expand': 'up-right-and-down-left-from-center', + 'Fold': 'down-left-and-up-right-to-center', + + // 界面 + 'FullScreen': 'expand', + 'View': 'eye', + 'Hide': 'eye-slash', + 'Lock': 'lock', + 'Unlock': 'unlock', + 'User': 'user', + 'Setting': 'gear', + 'Menu': 'bars', + 'MoreFilled': 'ellipsis-vertical', + 'Filter': 'filter', + + // 文件 + 'Document': 'file', + 'Folder': 'folder', + 'Files': 'folder-open', + 'CopyDocument': 'copy', + 'DocumentCopy': 'copy', + 'Picture': 'image', + 'VideoPlay': 'circle-play', + 'Microphone': 'microphone', + + // 状态 + 'CircleCheck': 'circle-check', + 'CircleClose': 'circle-xmark', + 'CircleCloseFilled': 'circle-xmark', + 'SuccessFilled': 'circle-check', + 'WarningFilled': 'triangle-exclamation', + 'InfoFilled': 'circle-info', + 'QuestionFilled': 'circle-question', + + // 功能 + 'Share': 'share-nodes', + 'Star': 'star', + 'Heart': 'heart', + 'Bookmark': 'bookmark', + 'CollectionTag': 'tags', + 'Tag': 'tag', + 'PriceTag': 'tag', + + // 消息 + 'ChatLineRound': 'comment', + 'ChatLineSquare': 'comment', + 'Message': 'envelope', + 'Bell': 'bell', + 'Notification': 'bell', + + // 数据 + 'PieChart': 'chart-pie', + 'TrendCharts': 'chart-line', + 'DataAnalysis': 'chart-simple', + 'List': 'list', + + // 时间 + 'Clock': 'clock', + 'Timer': 'hourglass', + 'Calendar': 'calendar', + + // 购物/支付 + 'ShoppingCart': 'cart-shopping', + 'Coin': 'coins', + 'Wallet': 'wallet', + 'TrophyBase': 'trophy', + + // 开发 + 'Tools': 'screwdriver-wrench', + 'MagicStick': 'wand-magic-sparkles', + 'Monitor': 'desktop', + 'ChromeFilled': 'chrome', + 'ElementPlus': 'code', + + // 安全 + 'Key': 'key', + 'Shield': 'shield', + 'Lock': 'lock', + + // 其他 + 'Box': 'box', + 'Service': 'headset', + 'Camera': 'camera', + 'Postcard': 'address-card', + 'Promotion': 'bullhorn', + 'Reading': 'book-open', + 'ZoomIn': 'magnifying-glass-plus', + 'ZoomOut': 'magnifying-glass-minus', +}; + +/** + * 获取 FontAwesome 图标名称 + * @param elementPlusIcon Element Plus 图标名称 + * @returns FontAwesome 图标名称(不含 fa- 前缀) + */ +export function getFontAwesomeIcon(elementPlusIcon: string): string { + return iconMapping[elementPlusIcon] || elementPlusIcon.toLowerCase(); +} diff --git a/Yi.Ai.Vue3/types/components.d.ts b/Yi.Ai.Vue3/types/components.d.ts index 657d0887..f139484a 100644 --- a/Yi.Ai.Vue3/types/components.d.ts +++ b/Yi.Ai.Vue3/types/components.d.ts @@ -16,6 +16,7 @@ declare module 'vue' { ContactUs: typeof import('./../src/components/ContactUs/index.vue')['default'] DailyTask: typeof import('./../src/components/userPersonalCenter/components/DailyTask.vue')['default'] DeepThinking: typeof import('./../src/components/DeepThinking/index.vue')['default'] + Demo: typeof import('./../src/components/FontAwesomeIcon/demo.vue')['default'] ElAlert: typeof import('element-plus/es')['ElAlert'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElButton: typeof import('element-plus/es')['ElButton'] @@ -67,6 +68,7 @@ declare module 'vue' { ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElUpload: typeof import('element-plus/es')['ElUpload'] FilesSelect: typeof import('./../src/components/FilesSelect/index.vue')['default'] + FontAwesomeIcon: typeof import('./../src/components/FontAwesomeIcon/index.vue')['default'] IconSelect: typeof import('./../src/components/IconSelect/index.vue')['default'] Indexl: typeof import('./../src/components/SupportModelProducts/indexl.vue')['default'] LoginDialog: typeof import('./../src/components/LoginDialog/index.vue')['default'] diff --git a/Yi.Ai.Vue3/types/import_meta.d.ts b/Yi.Ai.Vue3/types/import_meta.d.ts index c98d612e..8f2a798b 100644 --- a/Yi.Ai.Vue3/types/import_meta.d.ts +++ b/Yi.Ai.Vue3/types/import_meta.d.ts @@ -7,7 +7,6 @@ interface ImportMetaEnv { readonly VITE_WEB_BASE_API: string; readonly VITE_API_URL: string; readonly VITE_FILE_UPLOAD_API: string; - readonly VITE_BUILD_COMPRESS: string; readonly VITE_SSO_SEVER_URL: string; readonly VITE_APP_VERSION: string; } diff --git a/Yi.Ai.Vue3/vite.config.ts b/Yi.Ai.Vue3/vite.config.ts index 5aa56f37..7d4fcbd0 100644 --- a/Yi.Ai.Vue3/vite.config.ts +++ b/Yi.Ai.Vue3/vite.config.ts @@ -1,7 +1,7 @@ import { defineConfig, loadEnv } from "vite"; import path from "path"; import plugins from "./.build/plugins"; - +import { APP_VERSION, APP_NAME } from "./src/config/version"; // https://vite.dev/config/ export default defineConfig((cnf) => { @@ -10,6 +10,11 @@ export default defineConfig((cnf) => { const env = loadEnv(mode, process.cwd()); const { VITE_APP_ENV } = env; return { + // 注入全局常量,供 index.html 和项目代码使用 + define: { + __APP_VERSION__: JSON.stringify(APP_VERSION), + __APP_NAME__: JSON.stringify(APP_NAME), + }, base: VITE_APP_ENV === "production" ? "/" : "/", plugins: plugins(cnf), resolve: { @@ -27,6 +32,124 @@ export default defineConfig((cnf) => { }, }, + // 构建优化配置 + build: { + target: 'es2015', + cssTarget: 'chrome80', + // 代码分割策略 + rollupOptions: { + output: { + // 分包策略 - 更精细的分割以提高加载速度 + manualChunks: (id) => { + // Vue 核心库 + if (id.includes('node_modules/vue/') || id.includes('node_modules/@vue/') || id.includes('node_modules/vue-router/')) { + return 'vue-vendor'; + } + // Pinia 状态管理 + if (id.includes('node_modules/pinia/')) { + return 'pinia'; + } + // Element Plus UI 库 + if (id.includes('node_modules/element-plus/') || id.includes('node_modules/@element-plus/')) { + return 'element-plus'; + } + // Markdown 相关 + if (id.includes('node_modules/unified/') || id.includes('node_modules/remark-') || id.includes('node_modules/rehype-') || id.includes('node_modules/marked/')) { + return 'markdown'; + } + // 工具库 + if (id.includes('node_modules/lodash-es/') || id.includes('node_modules/@vueuse/')) { + return 'utils'; + } + // 代码高亮 + if (id.includes('node_modules/highlight.js/') || id.includes('node_modules/shiki/')) { + return 'highlight'; + } + // 图表库 + if (id.includes('node_modules/echarts/')) { + return 'echarts'; + } + // PDF 处理 + if (id.includes('node_modules/pdfjs-dist/')) { + return 'pdf'; + } + // 其他第三方库 + if (id.includes('node_modules/')) { + return 'vendor'; + } + }, + // 文件命名 + chunkFileNames: 'js/[name]-[hash].js', + entryFileNames: 'js/[name]-[hash].js', + assetFileNames: (assetInfo) => { + const name = assetInfo.name || ''; + if (name.endsWith('.css')) { + return 'css/[name]-[hash][extname]'; + } + if (/\.(png|jpe?g|gif|svg|webp|ico)$/.test(name)) { + return 'images/[name]-[hash][extname]'; + } + if (/\.(woff2?|eot|ttf|otf)$/.test(name)) { + return 'fonts/[name]-[hash][extname]'; + } + return '[ext]/[name]-[hash][extname]'; + }, + }, + }, + // 压缩配置 + minify: 'terser', + terserOptions: { + compress: { + drop_console: VITE_APP_ENV === 'production', + drop_debugger: VITE_APP_ENV === 'production', + pure_funcs: VITE_APP_ENV === 'production' ? ['console.log', 'console.info'] : [], + }, + }, + // chunk 大小警告限制 + chunkSizeWarningLimit: 1000, + // 启用 CSS 代码分割 + cssCodeSplit: true, + // 构建后是否生成 source map + sourcemap: VITE_APP_ENV !== 'production', + }, + + // 性能优化 + optimizeDeps: { + include: [ + 'vue', + 'vue-router', + 'pinia', + 'element-plus', + '@element-plus/icons-vue', + 'lodash-es', + '@vueuse/core', + '@fortawesome/vue-fontawesome', + '@fortawesome/fontawesome-svg-core', + '@fortawesome/free-solid-svg-icons', + ], + // 强制预构建依赖 + force: false, + // 排除不需要优化的依赖 + exclude: [], + }, + + // 预加载设置 + preview: { + // 预览服务配置 + }, + + // 实验性功能 + // experimental: { + // // 启用渲染内联 CSS(提高首次加载速度) + // renderBuiltUrl(filename, { hostType }) { + // // 生产环境使用相对路径 + // if (hostType === 'js') { + // return { runtime: `window.__assetsPath(${JSON.stringify(filename)})` }; + // } + // return { relative: true }; + // }, + // }, + server: { port: 17001, open: true,
探索并接入全球顶尖AI模型,覆盖文本、图像、嵌入等多个领域
+ 尊享Token = 实际消耗Token * 当前模型倍率 +
基于综合性能、用户活跃度、代码质量等多维度评估
如果看到以下图标正常显示,说明 FontAwesome 配置成功!