Merge remote-tracking branch 'origin/ai-hub' into ai-hub

This commit is contained in:
chenchun
2026-02-02 18:04:32 +08:00
77 changed files with 6673 additions and 1721 deletions

View File

@@ -0,0 +1,47 @@
using System.Reflection;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.Chat;
/// <summary>
/// 消息创建结果输出
/// </summary>
public class MessageCreatedOutput
{
/// <summary>
/// 消息类型
/// </summary>
[JsonIgnore]
public ChatMessageTypeEnum TypeEnum { get; set; }
/// <summary>
/// 消息类型
/// </summary>
public string Type => TypeEnum.ToString();
/// <summary>
/// 消息ID
/// </summary>
public Guid MessageId { get; set; }
/// <summary>
/// 消息创建时间
/// </summary>
public DateTime CreationTime { get; set; }
}
/// <summary>
/// 消息类型枚举
/// </summary>
public enum ChatMessageTypeEnum
{
/// <summary>
/// 用户消息
/// </summary>
UserMessage,
/// <summary>
/// 系统消息
/// </summary>
SystemMessage
}

View File

@@ -0,0 +1,14 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos;
/// <summary>
/// 排行榜查询输入
/// </summary>
public class RankingGetListInput
{
/// <summary>
/// 排行榜类型0-模型1-工具,不传返回全部
/// </summary>
public RankingTypeEnum? Type { get; set; }
}

View File

@@ -0,0 +1,41 @@
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos;
/// <summary>
/// 排行榜项DTO
/// </summary>
public class RankingItemDto
{
public Guid Id { get; set; }
/// <summary>
/// 名称
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// 描述
/// </summary>
public string Description { get; set; } = null!;
/// <summary>
/// Logo地址
/// </summary>
public string? LogoUrl { get; set; }
/// <summary>
/// 得分
/// </summary>
public decimal Score { get; set; }
/// <summary>
/// 提供者
/// </summary>
public string Provider { get; set; } = null!;
/// <summary>
/// 排行榜类型
/// </summary>
public RankingTypeEnum Type { get; set; }
}

View File

@@ -0,0 +1,16 @@
using Yi.Framework.AiHub.Application.Contracts.Dtos;
namespace Yi.Framework.AiHub.Application.Contracts.IServices;
/// <summary>
/// 排行榜服务接口
/// </summary>
public interface IRankingService
{
/// <summary>
/// 获取排行榜列表(全量返回)
/// </summary>
/// <param name="input">查询条件</param>
/// <returns>排行榜列表</returns>
Task<List<RankingItemDto>> GetListAsync(RankingGetListInput input);
}

View File

@@ -127,54 +127,55 @@ public class AiChatService : ApplicationService
}
/// <summary>
/// 发送消息
/// </summary>
/// <param name="input"></param>
/// <param name="sessionId"></param>
/// <param name="cancellationToken"></param>
[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);
}
// /// <summary>
// /// 发送消息
// /// </summary>
// /// <param name="input"></param>
// /// <param name="sessionId"></param>
// /// <param name="cancellationToken"></param>
// [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);
// }
/// <summary>
/// 发送消息

View File

@@ -46,7 +46,7 @@ public class MessageService : ApplicationService
/// </summary>
/// <param name="input">删除参数包含消息Id列表和是否删除后续消息的开关</param>
[Authorize]
public async Task DeleteAsync([FromBody] MessageDeleteInput input)
public async Task DeleteAsync([FromQuery] MessageDeleteInput input)
{
var userId = CurrentUser.GetId();

View File

@@ -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;
/// <summary>
/// 排行榜服务
/// </summary>
public class RankingService : ApplicationService, IRankingService
{
private readonly ISqlSugarRepository<RankingItemAggregateRoot, Guid> _repository;
public RankingService(ISqlSugarRepository<RankingItemAggregateRoot, Guid> repository)
{
_repository = repository;
}
/// <summary>
/// 获取排行榜列表(全量返回,按得分降序)
/// </summary>
[HttpGet("ranking/list")]
[AllowAnonymous]
public async Task<List<RankingItemDto>> 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<List<RankingItemDto>>();
}
}

View File

@@ -0,0 +1,17 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
/// <summary>
/// 排行榜类型枚举
/// </summary>
public enum RankingTypeEnum
{
/// <summary>
/// 模型
/// </summary>
Model = 0,
/// <summary>
/// 工具
/// </summary>
Tool = 1
}

View File

@@ -53,6 +53,15 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot<Guid>
TotalTokenCount = tokenUsage.TotalTokens ?? 0
};
}
else
{
this.TokenUsage = new TokenUsageValueObject
{
OutputTokenCount = 0,
InputTokenCount = 0,
TotalTokenCount = 0
};
}
this.MessageType = sessionId is null ? MessageTypeEnum.Api : MessageTypeEnum.Web;
}

View File

@@ -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;
/// <summary>
/// 排行榜项聚合根
/// </summary>
[SugarTable("Ai_RankingItem")]
public class RankingItemAggregateRoot : FullAuditedAggregateRoot<Guid>
{
/// <summary>
/// 名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// 描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Logo地址
/// </summary>
public string? LogoUrl { get; set; }
/// <summary>
/// 得分
/// </summary>
public decimal Score { get; set; }
/// <summary>
/// 提供者
/// </summary>
public string? Provider { get; set; }
/// <summary>
/// 排行榜类型0-模型1-工具
/// </summary>
public RankingTypeEnum Type { get; set; }
}

View File

@@ -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,

View File

@@ -24,11 +24,12 @@ public class AiMessageManager : DomainService
/// <param name="input">消息输入</param>
/// <param name="tokenId">Token IdWeb端传Guid.Empty</param>
/// <returns></returns>
public async Task CreateSystemMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null)
public async Task<Guid> 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;
}
/// <summary>
@@ -38,11 +39,16 @@ public class AiMessageManager : DomainService
/// <param name="sessionId">会话Id</param>
/// <param name="input">消息输入</param>
/// <param name="tokenId">Token IdWeb端传Guid.Empty</param>
/// <param name="createTime"></param>
/// <returns></returns>
public async Task CreateUserMessageAsync(Guid? userId, Guid? sessionId, MessageInputDto input, Guid? tokenId = null)
public async Task<Guid> 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;
}
}

View File

@@ -361,7 +361,7 @@ namespace Yi.Abp.Web
var app = context.GetApplicationBuilder();
app.UseRouting();
//app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<SessionAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<RankingItemAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<ActivationCodeRecordAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<UsageStatisticsAggregateRoot>();

View File

@@ -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 在客户端正确初始化
},
};
},
};
}

View File

@@ -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'),

View File

@@ -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(`<link rel="modulepreload" href="${href}" crossorigin>`);
} else if (href.endsWith('.css')) {
preloadLinks.push(`<link rel="preload" href="${href}" as="style">`);
}
});
// 将预加载标签插入到 </head> 之前
if (preloadLinks.length > 0) {
return html.replace('</head>', `${preloadLinks.join('\n ')}\n</head>`);
}
return html;
},
};
}

View File

@@ -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}`);
},
};
}

View File

@@ -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": []

367
Yi.Ai.Vue3/CLAUDE.md Normal file
View File

@@ -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
<template>
<div class="your-page">
<h1>页面标题</h1>
</div>
</template>
<script setup lang="ts">
// 自动导入 Vue API无需手动 import
const route = useRoute()
const router = useRouter()
// 如需使用状态管理
const userStore = useUserStore()
onMounted(() => {
console.log('页面加载')
})
</script>
<style scoped lang="scss">
.your-page {
padding: 20px;
}
</style>
```
### 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
<template>
<div class="statistics-page">
<el-card>
<template #header>
<span>数据统计</span>
</template>
<div>页面内容</div>
</el-card>
</div>
</template>
<script setup lang="ts">
const { data, loading } = useFetch('/api/statistics').get().json()
</script>
<style scoped lang="scss">
.statistics-page {
padding: 20px;
}
</style>
```
**步骤 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`

View File

@@ -0,0 +1,133 @@
# FontAwesome 图标迁移指南
## 迁移步骤
### 1. 在组件中使用 FontAwesomeIcon
```vue
<!-- 旧方式Element Plus 图标 -->
<template>
<el-icon>
<Check />
</el-icon>
</template>
<script setup lang="ts">
import { Check } from '@element-plus/icons-vue';
</script>
```
```vue
<!-- 新方式FontAwesome -->
<template>
<FontAwesomeIcon icon="check" />
</template>
<!-- 或带 props -->
<template>
<FontAwesomeIcon icon="check" size="lg" />
<FontAwesomeIcon icon="spinner" spin />
<FontAwesomeIcon icon="magnifying-glass" size="xl" />
</template>
```
### 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
<!-- 迁移前 -->
<template>
<div>
<el-icon><Check /></el-icon>
<el-icon><Close /></el-icon>
<el-icon><Delete /></el-icon>
</div>
</template>
<script setup lang="ts">
import { Check, Close, Delete } from '@element-plus/icons-vue';
</script>
<!-- 迁移后 -->
<template>
<div>
<FontAwesomeIcon icon="check" />
<FontAwesomeIcon icon="xmark" />
<FontAwesomeIcon icon="trash" />
</div>
</template>
<script setup lang="ts">
// 不需要 importFontAwesomeIcon 组件已自动导入
</script>
```
## 注意事项
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"`

View File

@@ -17,6 +17,14 @@
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<!-- DNS 预解析和预连接 -->
<link rel="dns-prefetch" href="//api.yourdomain.com">
<link rel="preconnect" href="//api.yourdomain.com" crossorigin>
<!-- 预加载关键资源 -->
<link rel="preload" href="/src/main.ts" as="script" crossorigin>
<link rel="modulepreload" href="/src/main.ts">
<style>
/* 全局样式 */
@@ -112,16 +120,172 @@
<body>
<!-- 加载动画容器 -->
<div id="yixinai-loader" class="loader-container">
<div class="loader-title">意心Ai 3.5</div>
<div class="loader-title">%APP_FULL_NAME%</div>
<div class="loader-subtitle">海外地址仅首次访问预计加载约10秒无需梯子</div>
<div class="loader-logo">
<div class="pulse-box"></div>
</div>
<div class="loader-progress-bar">
<div id="loader-progress" class="loader-progress"></div>
</div>
<div id="loader-text" class="loader-text" style="font-size: 0.875rem; margin-top: 0.5rem; color: #666;">加载中...</div>
</div>
<div id="app"></div>
<script>
// 资源加载进度跟踪 - 增强版
(function() {
const progressBar = document.getElementById('loader-progress');
const loaderText = document.getElementById('loader-text');
const loader = document.getElementById('yixinai-loader');
let progress = 0;
let resourcesLoaded = false;
let vueAppMounted = false;
let appRendered = false;
// 更新进度条
function updateProgress(value, text) {
progress = Math.min(value, 99);
if (progressBar) progressBar.style.width = progress.toFixed(1) + '%';
if (loaderText) loaderText.textContent = text;
}
// 阶段管理
const stages = {
init: { weight: 15, name: '初始化' },
resources: { weight: 35, name: '加载资源' },
scripts: { weight: 25, name: '执行脚本' },
render: { weight: 15, name: '渲染页面' },
complete: { weight: 10, name: '启动应用' }
};
let completedStages = new Set();
let currentStage = 'init';
function calculateProgress() {
let totalProgress = 0;
for (const [key, stage] of Object.entries(stages)) {
if (completedStages.has(key)) {
totalProgress += stage.weight;
} else if (key === currentStage) {
// 当前阶段完成一部分
totalProgress += stage.weight * 0.5;
}
}
return Math.min(totalProgress, 99);
}
// 阶段完成
function completeStage(stageName, nextStage) {
completedStages.add(stageName);
currentStage = nextStage || stageName;
const stage = stages[stageName];
updateProgress(calculateProgress(), stage ? `${stage.name}完成` : '加载中...');
}
// 监听资源加载 - 使用更可靠的方式
const resourceTimings = [];
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
resourceTimings.push(...entries);
// 统计未完成资源
const pendingResources = performance.getEntriesByType('resource')
.filter(r => !r.responseEnd || r.responseEnd === 0).length;
if (pendingResources === 0 && resourceTimings.length > 0) {
completeStage('resources', 'scripts');
} else {
updateProgress(calculateProgress(), `加载资源中... (${resourceTimings.length} 已加载)`);
}
});
try {
observer.observe({ entryTypes: ['resource'] });
} catch (e) {
// 降级处理
}
// 初始进度
let initProgress = 0;
function simulateInitProgress() {
if (initProgress < stages.init.weight) {
initProgress += 1;
updateProgress(initProgress, '正在初始化...');
if (initProgress < stages.init.weight) {
setTimeout(simulateInitProgress, 30);
} else {
completeStage('init', 'resources');
}
}
}
simulateInitProgress();
// 页面资源加载完成
window.addEventListener('load', () => {
completeStage('resources', 'scripts');
resourcesLoaded = true;
// 给脚本执行时间
setTimeout(() => {
completeStage('scripts', 'render');
}, 300);
checkAndHideLoader();
});
// 暴露全局方法供 Vue 应用调用 - 分阶段调用
window.__hideAppLoader = function(stage) {
if (stage === 'mounted') {
vueAppMounted = true;
completeStage('scripts', 'render');
} else if (stage === 'rendered') {
appRendered = true;
completeStage('render', 'complete');
}
checkAndHideLoader();
};
// 检查是否可以隐藏加载动画
function checkAndHideLoader() {
// 需要满足资源加载完成、Vue 挂载、页面渲染完成
if (resourcesLoaded && vueAppMounted && appRendered) {
completeStage('complete', '');
updateProgress(100, '加载完成');
// 确保最小显示时间,避免闪烁
const minDisplayTime = 1000;
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, minDisplayTime - elapsed);
setTimeout(() => {
if (loader) {
loader.style.opacity = '0';
loader.style.transition = 'opacity 0.5s ease';
setTimeout(() => {
loader.remove();
delete window.__hideAppLoader;
}, 500);
}
}, remaining);
}
}
const startTime = Date.now();
// 超时保护:最多显示 30 秒
setTimeout(() => {
if (loader && loader.parentNode) {
console.warn('加载超时,强制隐藏加载动画');
vueAppMounted = true;
resourcesLoaded = true;
appRendered = true;
checkAndHideLoader();
}
}, 30000);
})();
</script>
<script type="module" src="/src/main.ts"></script>
</body>

View File

@@ -34,6 +34,9 @@
"@floating-ui/core": "^1.7.2",
"@floating-ui/dom": "^1.7.2",
"@floating-ui/vue": "^1.1.7",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"@jsonlee_12138/enum": "^1.0.4",
"@shikijs/transformers": "^3.7.0",
"@vueuse/core": "^13.5.0",

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,19 @@
import type { ChatMessageVo, GetChatListParams, SendDTO } from './types';
import { get, post } from '@/utils/request';
import { del, get, post } from '@/utils/request';
// 删除消息接口
export interface DeleteMessageParams {
ids: (number | string)[];
isDeleteSubsequent?: boolean;
}
export function deleteMessages(data: DeleteMessageParams) {
const idsQuery = data.ids.map(id => `ids=${encodeURIComponent(id)}`).join('&');
const subsequentQuery = data.isDeleteSubsequent !== undefined ? `isDeleteSubsequent=${data.isDeleteSubsequent}` : '';
const query = [idsQuery, subsequentQuery].filter(Boolean).join('&');
const url = `/message${query ? `?${query}` : ''}`;
return del<void>(url).json();
}
// 发送消息(旧接口)
export function send(data: SendDTO) {

View File

@@ -125,7 +125,7 @@ export interface GetChatListParams {
/**
* 主键
*/
id?: number;
id?: number | string;
/**
* 排序的方向desc或者asc
*/
@@ -195,7 +195,7 @@ export interface ChatMessageVo {
/**
* 主键
*/
id?: number;
id?: number | string;
/**
* 模型名称
*/

View File

@@ -0,0 +1,15 @@
import type { RankingGetListInput, RankingItemDto } from './types';
import { get } from '@/utils/request';
// 获取排行榜列表(公开接口,无需登录)
export function getRankingList(params?: RankingGetListInput) {
const queryParams = new URLSearchParams();
if (params?.type !== undefined) {
queryParams.append('Type', params.type.toString());
}
const queryString = queryParams.toString();
const url = queryString ? `/ranking/list?${queryString}` : '/ranking/list';
return get<RankingItemDto[]>(url).json();
}

View File

@@ -0,0 +1,21 @@
// 排行榜类型枚举
export enum RankingTypeEnum {
Model = 0,
Tool = 1,
}
// 排行榜项
export interface RankingItemDto {
id: string;
name: string;
description: string;
logoUrl?: string;
score: number;
provider: string;
type: RankingTypeEnum;
}
// 排行榜查询参数
export interface RankingGetListInput {
type?: RankingTypeEnum;
}

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
/**
* FontAwesome 图标演示组件
* 展示不同大小和样式的图标
*/
const commonIcons = [
'check',
'xmark',
'plus',
'minus',
'trash',
'pen-to-square',
'magnifying-glass',
'rotate-right',
'download',
'upload',
'user',
'gear',
'eye',
'eye-slash',
'lock',
'folder',
'file',
'image',
'comment',
'bell',
'heart',
'star',
'clock',
'calendar',
'share-nodes',
];
const sizes = ['xs', 'sm', 'lg', 'xl', '2x'] as const;
</script>
<template>
<div class="font-awesome-demo">
<h2>FontAwesome 图标演示</h2>
<section>
<h3>基础图标</h3>
<div class="icon-grid">
<div v-for="icon in commonIcons" :key="icon" class="icon-item">
<FontAwesomeIcon :icon="icon" />
<span>{{ icon }}</span>
</div>
</div>
</section>
<section>
<h3>不同尺寸</h3>
<div class="size-demo">
<div v-for="size in sizes" :key="size" class="size-item">
<FontAwesomeIcon icon="star" :size="size" />
<span>{{ size }}</span>
</div>
</div>
</section>
<section>
<h3>动画效果</h3>
<div class="animation-demo">
<div class="anim-item">
<FontAwesomeIcon icon="spinner" spin />
<span>spin</span>
</div>
<div class="anim-item">
<FontAwesomeIcon icon="circle-notch" spin />
<span>circle-notch spin</span>
</div>
<div class="anim-item">
<FontAwesomeIcon icon="heart" pulse />
<span>pulse</span>
</div>
</div>
</section>
<section>
<h3>旋转</h3>
<div class="rotation-demo">
<div class="rot-item">
<FontAwesomeIcon icon="arrow-up" :rotation="0" />
<span>0°</span>
</div>
<div class="rot-item">
<FontAwesomeIcon icon="arrow-up" :rotation="90" />
<span>90°</span>
</div>
<div class="rot-item">
<FontAwesomeIcon icon="arrow-up" :rotation="180" />
<span>180°</span>
</div>
<div class="rot-item">
<FontAwesomeIcon icon="arrow-up" :rotation="270" />
<span>270°</span>
</div>
</div>
</section>
<section>
<h3>实际应用示例</h3>
<div class="examples">
<button class="example-btn">
<FontAwesomeIcon icon="magnifying-glass" />
搜索
</button>
<button class="example-btn primary">
<FontAwesomeIcon icon="check" />
确认
</button>
<button class="example-btn danger">
<FontAwesomeIcon icon="trash" />
删除
</button>
<button class="example-btn">
<FontAwesomeIcon icon="download" />
下载
</button>
<button class="example-btn">
<FontAwesomeIcon icon="share-nodes" />
分享
</button>
</div>
</section>
</div>
</template>
<style scoped lang="scss">
.font-awesome-demo {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
h2 {
margin-bottom: 30px;
color: var(--el-text-color-primary);
}
h3 {
margin: 30px 0 15px;
color: var(--el-text-color-regular);
border-bottom: 1px solid var(--el-border-color);
padding-bottom: 10px;
}
section {
margin-bottom: 40px;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 15px;
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 15px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
transition: all 0.3s;
&:hover {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
transform: translateY(-2px);
}
span {
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
.size-demo,
.animation-demo,
.rotation-demo {
display: flex;
gap: 30px;
flex-wrap: wrap;
.size-item,
.anim-item,
.rot-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
min-width: 80px;
span {
margin-top: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
.examples {
display: flex;
gap: 15px;
flex-wrap: wrap;
.example-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-bg-color);
color: var(--el-text-color-primary);
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
&.primary {
background-color: var(--el-color-primary);
color: white;
border-color: var(--el-color-primary);
&:hover {
background-color: var(--el-color-primary-light-3);
}
}
&.danger {
background-color: var(--el-color-danger);
color: white;
border-color: var(--el-color-danger);
&:hover {
background-color: var(--el-color-danger-light-3);
}
}
}
}
}
</style>

View File

@@ -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';

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
interface Props {
/** FontAwesome 图标名称(不含 fa- 前缀) */
icon: string;
/** 图标大小 */
size?: 'xs' | 'sm' | 'lg' | 'xl' | '2x' | '3x' | '4x' | '5x';
/** 旋转动画 */
spin?: boolean;
/** 脉冲动画 */
pulse?: boolean;
/** 旋转角度 */
rotation?: 0 | 90 | 180 | 270;
}
const props = withDefaults(defineProps<Props>(), {
size: undefined,
spin: false,
pulse: false,
rotation: undefined,
});
</script>
<template>
<FontAwesomeIcon
:icon="`fa-solid fa-${icon}`"
:size="size"
:spin="spin"
:pulse="pulse"
:rotation="rotation"
/>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { Marked } from 'marked';
import { marked } from 'marked';
import hljs from 'highlight.js';
import DOMPurify from 'dompurify';
import { ElDrawer } from 'element-plus';
@@ -27,9 +27,6 @@ const drawerVisible = ref(false);
const previewHtml = ref('');
const iframeRef = ref<HTMLIFrameElement | null>(null);
// 创建 marked 实例
const marked = new Marked();
// 配置 marked
marked.setOptions({
gfm: true,

View File

@@ -8,8 +8,10 @@ import { useModelStore } from '@/stores/modules/model';
import { showProductPackage } from '@/utils/product-package.ts';
import { isUserVip } from '@/utils/user';
import { modelList as localModelList } from './modelData';
import { useRouter } from 'vue-router';
const modelStore = useModelStore();
const router = useRouter();
const { isMobile } = useResponsive();
const dialogVisible = ref(false);
const activeTab = ref('provider'); // 'provider' | 'api'
@@ -233,7 +235,7 @@ function handleModelClick(item: GetSessionListVO) {
}
function goToModelLibrary() {
window.location.href = '/model-library';
router.push('/model-library');
}
/* -------------------------------

View File

@@ -1,9 +1,15 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const emit = defineEmits(['close']);
const router = useRouter();
function goToActivation() {
emit('close');
window.location.href = '/console/activation';
// 使用 router 进行跳转,避免完整页面刷新
setTimeout(() => {
router.push('/console/activation');
}, 300); // 等待对话框关闭动画完成
}
</script>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { ArrowRight, Box, CircleCheck, Loading, Right, Service } from '@element-plus/icons-vue';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { promotionConfig } from '@/config/constants.ts';
import { useUserStore } from '@/stores';
import { showContactUs } from '@/utils/contact-us.ts';
const router = useRouter();
interface PackageItem {
id: number;
name: string;
@@ -66,7 +69,7 @@ function contactService() {
}
function goToModelLibrary() {
window.location.href = '/model-library';
router.push('/model-library');
}
const selectedPackage = computed(() => {

View File

@@ -2,15 +2,17 @@
import type { GoodsItem } from '@/api/pay';
import { ElMessage } from 'element-plus';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { createOrder, getOrderStatus } from '@/api';
import { getGoodsList, GoodsCategoryType } from '@/api/pay';
import ProductPage from '@/pages/products/index.vue';
import { useUserStore } from '@/stores';
import NewbieGuide from './NewbieGuide.vue';
import ActivationGuide from './ActivationGuide.vue';
import NewbieGuide from './NewbieGuide.vue';
import PackageTab from './PackageTab.vue';
const emit = defineEmits(['close']);
const router = useRouter();
// 商品数据类型定义
interface PackageItem {
@@ -171,7 +173,7 @@ const benefitsData2 = {
qy: [
{ name: '需先成为意心会员后方可购买使用', value: '' },
{ name: '意心会员过期后尊享Token包会临时冻结', value: '' },
{ name: '可重复购买将自动累积Token在个人中心查看', value: '' },
{ name: '尊享Token = 实际消耗Token * 当前模型倍率,模型倍率可前往【模型库】查看', value: '' },
{ name: 'Token长期有效无限流限制', value: '' },
{ name: '几乎是全网最低价让人人用的起Agent', value: '' },
{ name: '附带claude code独家教程手把手对接', value: '' },
@@ -321,8 +323,10 @@ function onClose() {
function goToActivation() {
close();
// 使用 window.location 进行跳转,避免 router 注入问题
window.location.href = '/console/activation';
// 使用 router 进行跳转,避免完整页面刷新
setTimeout(() => {
router.push('/console/activation');
}, 300); // 等待对话框关闭动画完成
}
</script>

View File

@@ -1,4 +1,8 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
const models = [
{ name: 'DeepSeek-R1', price: '2', desc: '国产开源,深度思索模式,不过幻读问题比较大,同时具备思考响应链,在开源模型中永远的神!' },
{ name: 'DeepSeek-chat', price: '1', desc: '国产开源,简单聊天模式,对于中文文章语义体验较好,但响应速度一般' },
@@ -27,7 +31,7 @@ const models = [
];
function goToModelLibrary() {
window.location.href = '/model-library';
router.push('/model-library');
}
</script>

View File

@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script lang="ts" setup xmlns="http://www.w3.org/1999/html">
/**
* 翻牌抽奖活动组件
* 功能说明:
@@ -914,7 +914,13 @@ function getCardClass(record: CardFlipRecord): string[] {
</div>
</div>
<div class="lucky-label">
翻牌幸运值
<div class="lucky-main-text">
<span class="fire-icon">🔥</span>幸运值{{ luckyValue }}%
</div>
<div class="lucky-sub-text">
<span v-if="luckyValue < 100" class="lucky-highlight bounce">继续翻后面奖励超高</span>
<span v-else class="lucky-highlight full">幸运值满奖励MAX</span>
</div>
</div>
</div>
@@ -1283,20 +1289,23 @@ function getCardClass(record: CardFlipRecord): string[] {
/* 幸运值悬浮球 */
.lucky-float-ball {
position: fixed;
position: absolute;
left: 50%;
bottom: 16px;
transform: translateX(-50%);
z-index: 999;
bottom: 20px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
&:hover {
transform: translateX(-50%) scale(1.1);
transform: translateX(-50%) scale(1.05);
}
@media (max-width: 768px) {
bottom: 15px;
bottom: 12px;
.lucky-circle {
width: 70px;
@@ -1312,16 +1321,14 @@ function getCardClass(record: CardFlipRecord): string[] {
}
.lucky-label {
font-size: 11px;
margin-top: 4px;
}
}
&.lucky-full {
animation: lucky-celebration 2s ease-in-out infinite;
.lucky-circle {
box-shadow: 0 0 20px rgba(255, 215, 0, 0.8), 0 0 40px rgba(255, 215, 0, 0.6);
animation: lucky-celebration 2s ease-in-out infinite;
}
.lucky-content {
@@ -1392,11 +1399,109 @@ function getCardClass(record: CardFlipRecord): string[] {
.lucky-label {
text-align: center;
margin-top: 6px;
font-size: 12px;
font-weight: bold;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
letter-spacing: 1px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
width: max-content;
min-width: 100px;
max-width: 140px;
margin-left: auto;
margin-right: auto;
.lucky-main-text {
font-size: 14px;
font-weight: bold;
color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
letter-spacing: 1px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
.fire-icon {
font-size: 14px;
animation: fireShake 0.5s ease-in-out infinite;
display: inline-block;
}
}
.lucky-sub-text {
font-size: 11px;
font-weight: 600;
width: 100%;
display: flex;
justify-content: center;
}
.lucky-highlight {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
white-space: nowrap;
text-align: center;
&.bounce {
background: linear-gradient(90deg, #ff6b6b 0%, #ffd93d 50%, #ff6b6b 100%);
background-size: 200% 100%;
color: #fff;
box-shadow: 0 2px 10px rgba(255, 107, 107, 0.5);
animation: gradientMove 2s linear infinite, bounceHint 1s ease-in-out infinite;
}
&.full {
background: linear-gradient(135deg, #FFD700 0%, #ff6b9d 50%, #c06c84 100%);
background-size: 200% 100%;
color: #fff;
box-shadow: 0 0 15px rgba(255, 215, 0, 0.8);
animation: gradientMove 2s linear infinite, glowPulse 1.5s ease-in-out infinite;
}
}
@media (max-width: 768px) {
margin-top: 4px;
gap: 3px;
min-width: 80px;
max-width: 120px;
.lucky-main-text {
font-size: 12px;
.fire-icon {
font-size: 12px;
}
}
.lucky-highlight {
font-size: 10px;
padding: 2px 8px;
}
}
}
@keyframes fireShake {
0%, 100% { transform: rotate(-5deg) scale(1); }
50% { transform: rotate(5deg) scale(1.1); }
}
@keyframes bounceHint {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@keyframes gradientMove {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
@keyframes glowPulse {
0%, 100% { box-shadow: 0 0 10px rgba(255, 215, 0, 0.6); }
50% { box-shadow: 0 0 20px rgba(255, 215, 0, 1), 0 0 30px rgba(255, 107, 157, 0.5); }
}
@keyframes lucky-celebration {

View File

@@ -0,0 +1,6 @@
// Chat 相关 composables 统一导出
export * from './useImageCompression';
export * from './useFilePaste';
export * from './useFileParsing';
export * from './useChatSender';

View File

@@ -0,0 +1,301 @@
import { useHookFetch } from 'hook-fetch/vue';
import { ElMessage } from 'element-plus';
import { ref, computed } from 'vue';
import type { AnyObject } from 'typescript-api-pro';
import { deleteMessages, unifiedSend } from '@/api';
import { useModelStore } from '@/stores/modules/model';
import { convertToApiFormat, parseStreamChunk, type UnifiedMessage } from '@/utils/apiFormatConverter';
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
export type MessageRole = 'ai' | 'user' | 'assistant' | string;
export interface MessageItem extends BubbleProps {
key: number | string;
id?: number | string;
role: MessageRole;
avatar?: string;
showAvatar?: boolean;
thinkingStatus?: ThinkingStatus;
thinlCollapse?: boolean;
reasoning_content?: string;
images?: Array<{ url: string; name?: string }>;
files?: Array<{ name: string; size: number }>;
creationTime?: string;
tokenUsage?: { prompt: number; completion: number; total: number };
}
export interface UseChatSenderOptions {
sessionId: string;
onError?: (error: any) => void;
onMessageComplete?: () => void;
}
// 创建统一发送请求的包装函数
function unifiedSendWrapper(params: any) {
const { data, apiType, modelId, sessionId } = params;
return unifiedSend(data, apiType, modelId, sessionId);
}
/**
* Composable: 聊天发送逻辑
*/
export function useChatSender(options: UseChatSenderOptions) {
const { sessionId, onError, onMessageComplete } = options;
const modelStore = useModelStore();
const isSending = ref(false);
const isThinking = ref(false);
const currentRequestApiType = ref('');
// 临时ID计数器
let tempIdCounter = -1;
const { stream, loading: isLoading, cancel } = useHookFetch({
request: unifiedSendWrapper,
onError: async (error) => {
isLoading.value = false;
if (error.status === 403) {
const data = await error.response.json();
ElMessage.error(data.error.message);
return Promise.reject(data);
}
if (error.status === 401) {
ElMessage.error('登录已过期,请重新登录!');
// 需要访问 userStore这里通过回调处理
onError?.(error);
}
},
});
/**
* 处理流式响应的数据块
*/
function handleDataChunk(
chunk: AnyObject,
messages: MessageItem[],
onUpdate: (messages: MessageItem[]) => void,
) {
try {
const parsed = parseStreamChunk(
chunk,
currentRequestApiType.value || 'Completions',
);
const latest = messages[messages.length - 1];
if (!latest) return;
// 处理 token 使用情况
if (parsed.usage) {
latest.tokenUsage = {
prompt: parsed.usage.prompt_tokens || 0,
completion: parsed.usage.completion_tokens || 0,
total: parsed.usage.total_tokens || 0,
};
}
// 处理推理内容
if (parsed.reasoning_content) {
latest.thinkingStatus = 'thinking';
latest.loading = true;
latest.thinlCollapse = true;
if (!latest.reasoning_content) latest.reasoning_content = '';
latest.reasoning_content += parsed.reasoning_content;
}
// 处理普通内容
if (parsed.content) {
const thinkStart = parsed.content.includes('<think>');
const thinkEnd = parsed.content.includes('</think>');
if (thinkStart) isThinking.value = true;
if (thinkEnd) isThinking.value = false;
if (isThinking.value) {
latest.thinkingStatus = 'thinking';
latest.loading = true;
latest.thinlCollapse = true;
if (!latest.reasoning_content) latest.reasoning_content = '';
latest.reasoning_content += parsed.content
.replace('<think>', '')
.replace('</think>', '');
} else {
latest.thinkingStatus = 'end';
latest.loading = false;
if (!latest.content) latest.content = '';
latest.content += parsed.content;
}
}
onUpdate([...messages]);
}
catch (err) {
console.error('解析数据时出错:', err);
}
}
/**
* 发送消息
*/
async function sendMessage(
chatContent: string,
messages: MessageItem[],
imageFiles: any[],
textFiles: any[],
onUpdate: (messages: MessageItem[]) => void,
): Promise<void> {
if (isSending.value) return;
isSending.value = true;
currentRequestApiType.value = modelStore.currentModelInfo.modelApiType || 'Completions';
try {
// 添加用户消息和AI消息
const tempId = tempIdCounter--;
const userMessage: MessageItem = {
key: tempId,
id: tempId,
role: 'user',
placement: 'end',
isMarkdown: false,
loading: false,
content: chatContent,
images: imageFiles.length > 0
? imageFiles.map(f => ({ url: f.base64!, name: f.name }))
: undefined,
files: textFiles.length > 0
? textFiles.map(f => ({ name: f.name!, size: f.fileSize! }))
: undefined,
shape: 'corner',
};
const aiTempId = tempIdCounter--;
const aiMessage: MessageItem = {
key: aiTempId,
id: aiTempId,
role: 'assistant',
placement: 'start',
isMarkdown: true,
loading: true,
content: '',
reasoning_content: '',
thinkingStatus: 'start',
thinlCollapse: false,
noStyle: true,
};
messages = [...messages, userMessage, aiMessage];
onUpdate(messages);
// 组装消息内容
const messagesContent = messages.slice(0, -1).slice(-6).map((item: MessageItem) => {
const baseMessage: any = { role: item.role };
if (item.role === 'user' && item.key === messages.length - 2) {
const contentArray: any[] = [];
if (item.content) {
contentArray.push({ type: 'text', text: item.content });
}
// 添加文本文件内容
if (textFiles.length > 0) {
let fileContent = '\n\n';
textFiles.forEach((fileItem, index) => {
fileContent += `<ATTACHMENT_FILE>\n`;
fileContent += `<FILE_INDEX>File ${index + 1}</FILE_INDEX>\n`;
fileContent += `<FILE_NAME>${fileItem.name}</FILE_NAME>\n`;
fileContent += `<FILE_CONTENT>\n${fileItem.fileContent}\n</FILE_CONTENT>\n`;
fileContent += `</ATTACHMENT_FILE>\n`;
if (index < textFiles.length - 1) fileContent += '\n';
});
contentArray.push({ type: 'text', text: fileContent });
}
// 添加图片
imageFiles.forEach((fileItem) => {
if (fileItem.base64) {
contentArray.push({
type: 'image_url',
image_url: { url: fileItem.base64, name: fileItem.name },
});
}
});
baseMessage.content = contentArray.length > 1 || imageFiles.length > 0 || textFiles.length > 0
? contentArray
: item.content;
} else {
baseMessage.content = (item.role === 'ai' || item.role === 'assistant') && item.content.length > 10000
? `${item.content.substring(0, 10000)}...(内容过长,已省略)`
: item.content;
}
return baseMessage;
});
const apiType = modelStore.currentModelInfo.modelApiType || 'Completions';
const convertedRequest = convertToApiFormat(
messagesContent as UnifiedMessage[],
apiType,
modelStore.currentModelInfo.modelId ?? '',
true,
);
const modelId = modelStore.currentModelInfo.modelId ?? '';
for await (const chunk of stream({
data: convertedRequest,
apiType,
modelId,
sessionId,
})) {
handleDataChunk(chunk.result as AnyObject, messages, onUpdate);
}
}
catch (err: any) {
if (err.name !== 'AbortError') {
console.error('Fetch error:', err);
onError?.(err);
}
}
finally {
isSending.value = false;
const latest = messages[messages.length - 1];
if (latest) {
latest.typing = false;
latest.loading = false;
if (latest.thinkingStatus === 'thinking') {
latest.thinkingStatus = 'end';
}
}
onUpdate([...messages]);
onMessageComplete?.();
}
}
/**
* 取消发送
*/
function cancelSend(messages: MessageItem[], onUpdate: (messages: MessageItem[]) => void) {
cancel();
isSending.value = false;
const latest = messages[messages.length - 1];
if (latest) {
latest.typing = false;
latest.loading = false;
if (latest.thinkingStatus === 'thinking') {
latest.thinkingStatus = 'end';
}
}
onUpdate([...messages]);
}
return {
isSending,
isLoading,
sendMessage,
cancelSend,
handleDataChunk,
};
}

View File

@@ -0,0 +1,254 @@
import mammoth from 'mammoth';
import * as pdfjsLib from 'pdfjs-dist';
import * as XLSX from 'xlsx';
import { ElMessage } from 'element-plus';
// 配置 PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
export interface ParseOptions {
maxTextFileLength?: number;
maxWordLength?: number;
maxExcelRows?: number;
maxPdfPages?: number;
}
export interface ParsedResult {
content: string;
isTruncated: boolean;
totalSize?: number;
extractedSize?: number;
}
// 文本文件扩展名列表
const TEXT_EXTENSIONS = [
'txt', 'log', 'md', 'markdown', 'json', 'xml', 'yaml', 'yml', 'toml',
'ini', 'conf', 'config', 'js', 'jsx', 'ts', 'tsx', 'vue', 'html', 'htm',
'css', 'scss', 'sass', 'less', 'java', 'c', 'cpp', 'h', 'hpp', 'cs',
'py', 'rb', 'go', 'rs', 'swift', 'kt', 'php', 'sh', 'bash', 'sql',
'csv', 'tsv',
];
/**
* 判断是否为文本文件
*/
export function isTextFile(file: File): boolean {
if (file.type.startsWith('text/')) return true;
const ext = file.name.split('.').pop()?.toLowerCase();
return ext ? TEXT_EXTENSIONS.includes(ext) : false;
}
/**
* 读取文本文件
*/
export function readTextFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsText(file, 'UTF-8');
});
}
/**
* 解析 Excel 文件
*/
export async function parseExcel(
file: File,
maxRows = 100,
): Promise<{ content: string; totalRows: number; extractedRows: number }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
let result = '';
let totalRows = 0;
let extractedRows = 0;
workbook.SheetNames.forEach((sheetName, index) => {
const worksheet = workbook.Sheets[sheetName];
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
const sheetTotalRows = range.e.r - range.s.r + 1;
totalRows += sheetTotalRows;
const rowsToExtract = Math.min(sheetTotalRows, maxRows);
extractedRows += rowsToExtract;
const limitedData: any[][] = [];
for (let row = range.s.r; row < range.s.r + rowsToExtract; row++) {
const rowData: any[] = [];
for (let col = range.s.c; col <= range.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
const cell = worksheet[cellAddress];
rowData.push(cell ? cell.v : '');
}
limitedData.push(rowData);
}
const csvData = limitedData.map(row => row.join(',')).join('\n');
if (workbook.SheetNames.length > 1)
result += `=== Sheet: ${sheetName} ===\n`;
result += csvData;
if (index < workbook.SheetNames.length - 1)
result += '\n\n';
});
resolve({ content: result, totalRows, extractedRows });
}
catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
/**
* 解析 Word 文档
*/
export async function parseWord(
file: File,
maxLength = 30000,
): Promise<{ content: string; totalLength: number; extracted: boolean }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const arrayBuffer = e.target?.result as ArrayBuffer;
const result = await mammoth.extractRawText({ arrayBuffer });
const fullText = result.value;
const totalLength = fullText.length;
if (totalLength > maxLength) {
const truncated = fullText.substring(0, maxLength);
resolve({ content: truncated, totalLength, extracted: true });
}
else {
resolve({ content: fullText, totalLength, extracted: false });
}
}
catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
/**
* 解析 PDF 文件
*/
export async function parsePDF(
file: File,
maxPages = 10,
): Promise<{ content: string; totalPages: number; extractedPages: number }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const typedArray = new Uint8Array(e.target?.result as ArrayBuffer);
const pdf = await pdfjsLib.getDocument(typedArray).promise;
const totalPages = pdf.numPages;
const pagesToExtract = Math.min(totalPages, maxPages);
let fullText = '';
for (let i = 1; i <= pagesToExtract; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map((item: any) => item.str).join(' ');
fullText += `${pageText}\n`;
}
resolve({ content: fullText, totalPages, extractedPages: pagesToExtract });
}
catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
/**
* 解析文件内容(自动识别类型)
*/
export async function parseFileContent(
file: File,
options: ParseOptions = {},
): Promise<ParsedResult> {
const {
maxTextFileLength = 50000,
maxWordLength = 30000,
maxExcelRows = 100,
maxPdfPages = 10,
} = options;
const fileName = file.name.toLowerCase();
// 文本文件
if (isTextFile(file) && !fileName.endsWith('.pdf')) {
const content = await readTextFile(file);
const isTruncated = content.length > maxTextFileLength;
return {
content: isTruncated ? content.substring(0, maxTextFileLength) : content,
isTruncated,
totalSize: content.length,
extractedSize: isTruncated ? maxTextFileLength : content.length,
};
}
// Excel 文件
if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls') || fileName.endsWith('.csv')) {
const result = await parseExcel(file, maxExcelRows);
return {
content: result.content,
isTruncated: result.totalRows > maxExcelRows,
totalSize: result.totalRows,
extractedSize: result.extractedRows,
};
}
// Word 文件
if (fileName.endsWith('.docx') || fileName.endsWith('.doc')) {
const result = await parseWord(file, maxWordLength);
return {
content: result.content,
isTruncated: result.extracted,
totalSize: result.totalLength,
extractedSize: result.extracted ? maxWordLength : result.totalLength,
};
}
// PDF 文件
if (fileName.endsWith('.pdf')) {
const result = await parsePDF(file, maxPdfPages);
return {
content: result.content,
isTruncated: result.totalPages > maxPdfPages,
totalSize: result.totalPages,
extractedSize: result.extractedPages,
};
}
throw new Error(`不支持的文件类型: ${file.name}`);
}
/**
* Composable: 文件解析
*/
export function useFileParsing(options: ParseOptions = {}) {
return {
isTextFile,
readTextFile,
parseExcel,
parseWord,
parsePDF,
parseFileContent,
};
}

View File

@@ -0,0 +1,144 @@
import { ElMessage } from 'element-plus';
import { useImageCompression, type CompressionLevel } from './useImageCompression';
import type { FileItem } from '@/stores/modules/files';
export interface UseFilePasteOptions {
/** 最大文件大小 (字节) */
maxFileSize?: number;
/** 最大总内容长度 */
maxTotalContentLength?: number;
/** 压缩级别配置 */
compressionLevels?: CompressionLevel[];
/** 获取当前文件列表总长度 */
getCurrentTotalLength: () => number;
/** 添加文件到列表 */
addFiles: (files: FileItem[]) => void;
/** 是否只接受图片 (默认true) */
imagesOnly?: boolean;
}
/**
* 从剪贴板数据项中提取文件
*/
function extractFilesFromItems(items: DataTransferItemList): File[] {
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
return files;
}
/**
* Composable: 处理粘贴事件中的文件
*/
export function useFilePaste(options: UseFilePasteOptions) {
const {
maxFileSize = 3 * 1024 * 1024,
maxTotalContentLength = 150000,
compressionLevels,
getCurrentTotalLength,
addFiles,
imagesOnly = true,
} = options;
const { tryCompressToLimit } = useImageCompression();
/**
* 处理单个粘贴的文件
*/
async function processPastedFile(
file: File,
currentTotalLength: number,
): Promise<FileItem | null> {
// 验证文件大小
if (file.size > maxFileSize) {
ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`);
return null;
}
const isImage = file.type.startsWith('image/');
if (isImage) {
try {
const result = await tryCompressToLimit(
file,
currentTotalLength,
maxTotalContentLength,
compressionLevels,
);
if (!result) {
ElMessage.error(`${file.name} 图片内容过大,请压缩后上传`);
return null;
}
return {
uid: crypto.randomUUID(),
name: file.name,
fileSize: file.size,
file,
maxWidth: '200px',
showDelIcon: true,
imgPreview: true,
imgVariant: 'square',
url: result.base64,
isUploaded: true,
base64: result.base64,
fileType: 'image',
};
}
catch (error) {
console.error('处理图片失败:', error);
ElMessage.error(`${file.name} 处理失败`);
return null;
}
}
else if (!imagesOnly) {
// 如果不是仅图片模式,可以在这里处理其他类型文件
ElMessage.warning(`${file.name} 不支持粘贴,请使用上传按钮`);
return null;
}
return null;
}
/**
* 处理粘贴事件
*/
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
const files = extractFilesFromItems(items);
if (files.length === 0) return;
event.preventDefault();
let totalContentLength = getCurrentTotalLength();
const newFiles: FileItem[] = [];
for (const file of files) {
const processedFile = await processPastedFile(file, totalContentLength);
if (processedFile) {
newFiles.push(processedFile);
totalContentLength += Math.floor((processedFile.base64?.length || 0) * 0.5);
}
}
if (newFiles.length > 0) {
addFiles(newFiles);
ElMessage.success(`已添加 ${newFiles.length} 个文件`);
}
}
return {
handlePaste,
processPastedFile,
};
}

View File

@@ -0,0 +1,127 @@
import { ElMessage } from 'element-plus';
export interface CompressionLevel {
maxWidth: number;
maxHeight: number;
quality: number;
}
export const DEFAULT_COMPRESSION_LEVELS: CompressionLevel[] = [
{ maxWidth: 800, maxHeight: 800, quality: 0.6 },
{ maxWidth: 600, maxHeight: 600, quality: 0.5 },
{ maxWidth: 400, maxHeight: 400, quality: 0.4 },
];
/**
* 压缩图片
* @param file - 要压缩的图片文件
* @param maxWidth - 最大宽度
* @param maxHeight - 最大高度
* @param quality - 压缩质量 (0-1)
* @returns 压缩后的 Blob
*/
export function compressImage(
file: File,
maxWidth = 1024,
maxHeight = 1024,
quality = 0.8,
): Promise<Blob> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = width * ratio;
height = height * ratio;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
}
else {
reject(new Error('压缩失败'));
}
},
file.type,
quality,
);
};
img.onerror = reject;
img.src = e.target?.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* 将 Blob 转换为 base64
* @param blob - Blob 对象
* @returns base64 字符串
*/
export function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/**
* 尝试多级压缩直到满足大小限制
* @param file - 图片文件
* @param currentTotalLength - 当前已使用的总长度
* @param maxTotalLength - 最大允许总长度
* @returns 压缩结果或 null如果无法满足限制
*/
export async function tryCompressToLimit(
file: File,
currentTotalLength: number,
maxTotalLength: number,
compressionLevels = DEFAULT_COMPRESSION_LEVELS,
): Promise<{ blob: Blob; base64: string; estimatedLength: number } | null> {
for (const level of compressionLevels) {
const compressedBlob = await compressImage(
file,
level.maxWidth,
level.maxHeight,
level.quality,
);
const base64 = await blobToBase64(compressedBlob);
const estimatedLength = Math.floor(base64.length * 0.5);
if (currentTotalLength + estimatedLength <= maxTotalLength) {
return { blob: compressedBlob, base64, estimatedLength };
}
}
return null;
}
/**
* Composable: 使用图片压缩
*/
export function useImageCompression() {
return {
compressImage,
blobToBase64,
tryCompressToLimit,
DEFAULT_COMPRESSION_LEVELS,
};
}

View File

@@ -0,0 +1,2 @@
// Composables 统一导出
export * from './chat';

View File

@@ -8,7 +8,7 @@ export const contactConfig = {
// 二维码图片路径
images: {
customerService: ' https://ccnetcore.com/prod-api/wwwroot/aihub/wx.png ', // 客服微信二维码
communityGroup: ' https://ccnetcore.com/prod-api/wwwroot/aihub/jlq.png', // 交流群二维码
communityGroup: ' https://ccnetcore.com/prod-api/wwwroot/aihub/jlq_yxai.png', // 交流群二维码
afterSalesGroup: '', // 售后群二维码
},
};

View File

@@ -12,4 +12,18 @@ export const COLLAPSE_THRESHOLD: number = 600;
export const SIDE_BAR_WIDTH: number = 280;
// 路由白名单地址[本地存在的路由 staticRouter.ts 中]
export const ROUTER_WHITE_LIST: string[] = ['/chat', '/chat/conversation', '/chat/image', '/chat/video', '/model-library', '/403', '/404'];
// 包含所有无需登录即可访问的公开路径
export const ROUTER_WHITE_LIST: string[] = [
'/chat',
'/chat/conversation',
'/chat/image',
'/chat/video',
'/chat/agent',
'/model-library',
'/products',
'/pay-result',
'/activity/:id',
'/announcement/:id',
'/403',
'/404',
];

View File

@@ -0,0 +1,61 @@
/**
* 应用版本配置
* 集中管理应用版本信息
*
* ⚠️ 注意修改此处版本号即可vite.config.ts 会自动读取
*/
// 主版本号 - 修改此处即可同步更新所有地方的版本显示
export const APP_VERSION = '3.6.0';
// 应用名称
export const APP_NAME = '意心AI';
// 完整名称(名称 + 版本)
export const APP_FULL_NAME = `${APP_NAME} ${APP_VERSION}`;
// 构建信息(由 vite 注入)
declare const __GIT_BRANCH__: string;
declare const __GIT_HASH__: string;
declare const __GIT_DATE__: string;
declare const __BUILD_TIME__: string;
// 版本信息(由 vite 注入)
declare const __APP_VERSION__: string;
declare const __APP_NAME__: string;
export interface BuildInfo {
version: string;
name: string;
gitBranch: string;
gitHash: string;
gitDate: string;
buildTime: string;
}
// 获取完整构建信息
export function getBuildInfo(): BuildInfo {
return {
version: APP_VERSION,
name: APP_NAME,
gitBranch: typeof __GIT_BRANCH__ !== 'undefined' ? __GIT_BRANCH__ : 'unknown',
gitHash: typeof __GIT_HASH__ !== 'undefined' ? __GIT_HASH__ : 'unknown',
gitDate: typeof __GIT_DATE__ !== 'undefined' ? __GIT_DATE__ : 'unknown',
buildTime: typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : new Date().toISOString(),
};
}
// 在控制台输出构建信息
export function logBuildInfo(): void {
console.log(
`%c ${APP_NAME} ${APP_VERSION} %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',
);
const info = getBuildInfo();
console.log(`🔹 Version: ${info.version}`);
// console.log(`🔹 Git Branch: ${info.gitBranch}`);
console.log(`🔹 Git Commit: ${info.gitHash}`);
// console.log(`🔹 Commit Date: ${info.gitDate}`);
// console.log(`🔹 Build Time: ${info.buildTime}`);
}

View File

@@ -20,12 +20,29 @@ const isCollapsed = computed(() => designStore.isCollapseConversationList);
// 判断是否为新建对话状态(没有选中任何会话)
const isNewChatState = computed(() => !sessionStore.currentSession);
const isLoading = ref(false);
onMounted(async () => {
await sessionStore.requestSessionList();
if (conversationsList.value.length > 0 && sessionId.value) {
const currentSessionRes = await get_session(`${sessionId.value}`);
sessionStore.setCurrentSession(currentSessionRes.data);
onMounted(() => {
// 使用 requestIdleCallback 或 setTimeout 延迟加载数据
// 避免阻塞首屏渲染
const loadData = async () => {
isLoading.value = true;
try {
await sessionStore.requestSessionList();
if (conversationsList.value.length > 0 && sessionId.value) {
const currentSessionRes = await get_session(`${sessionId.value}`);
sessionStore.setCurrentSession(currentSessionRes.data);
}
} finally {
isLoading.value = false;
}
};
// 优先使用 requestIdleCallback如果不支持则使用 setTimeout
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => loadData(), { timeout: 1000 });
} else {
setTimeout(loadData, 100);
}
});

View File

@@ -1,11 +1,5 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
function goToModelLibrary() {
router.push('/model-library');
}
// 这是一个纯展示组件,点击事件由父组件 el-menu-item 处理
</script>
<template>
@@ -13,7 +7,6 @@ function goToModelLibrary() {
<div
class="model-library-btn"
title="查看模型库"
@click="goToModelLibrary"
>
<!-- PC端显示文字 -->
<span class="pc-text">模型库</span>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
// 这是一个纯展示组件,点击事件由父组件 el-menu-item 处理
</script>
<template>
<div class="ranking-btn-container" data-tour="ranking-btn">
<div
class="ranking-btn"
title="查看排行榜"
>
<!-- PC端显示文字 -->
<span class="pc-text">排行榜</span>
<!-- 移动端显示图标 -->
<svg
class="mobile-icon"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
</div>
</div>
</template>
<style scoped lang="scss">
.ranking-btn-container {
display: flex;
align-items: center;
.ranking-btn {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
color: #606266;
transition: all 0.2s;
&:hover {
color: #606266;
transform: translateY(-1px);
}
// PC端显示文字隐藏图标
.pc-text {
display: inline;
margin: 0 12px;
}
.mobile-icon {
display: none;
}
}
}
// 移动端显示图标,隐藏文字
@media (max-width: 768px) {
.ranking-btn-container {
.ranking-btn {
.pc-text {
display: none;
}
.mobile-icon {
display: inline;
}
}
}
}
</style>

View File

@@ -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() {
<AnnouncementBtn :is-menu-item="true" />
</el-menu-item>
<!-- 模型库 -->
<el-menu-item index="/model-library" class="custom-menu-item">
<ModelLibraryBtn :is-menu-item="true" />
</el-menu-item>
<!-- 模型库下拉菜单 -->
<el-sub-menu index="model-library" class="model-library-submenu" popper-class="custom-popover">
<template #title>
<span class="menu-title" @click="handleModelLibraryClick">模型库</span>
</template>
<el-menu-item index="/ranking">
模型排行榜
</el-menu-item>
<el-menu-item index="no-route" @click="goToModelMonitor">
模型监控
</el-menu-item>
</el-sub-menu>
<!-- AI教程 -->
<el-menu-item class="custom-menu-item" index="no-route">
@@ -254,11 +274,19 @@ function toggleMobileMenu() {
</el-menu-item>
</el-sub-menu>
<!-- 模型库 -->
<el-menu-item index="/model-library">
<el-icon><Box /></el-icon>
<span>模型库</span>
</el-menu-item>
<!-- 模型库下拉菜单 -->
<el-sub-menu index="model-library">
<template #title>
<el-icon><Box /></el-icon>
<span>模型库</span>
</template>
<el-menu-item index="/ranking">
模型排行榜
</el-menu-item>
<el-menu-item index="no-route" @click="goToModelMonitor">
模型监控
</el-menu-item>
</el-sub-menu>
<!-- 控制台 -->
<el-sub-menu index="console">
@@ -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;
}
</style>

View File

@@ -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秒
});
</script>
<template>

View File

@@ -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<void> {
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');
}
});

View File

@@ -0,0 +1,126 @@
<!-- 聊天页面头部组件 -->
<script setup lang="ts">
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
import CreateChat from '@/layouts/components/Header/components/CreateChat.vue';
import TitleEditing from '@/layouts/components/Header/components/TitleEditing.vue';
import { useSessionStore } from '@/stores/modules/session';
import { computed } from 'vue';
const props = defineProps<{
/** 是否显示标题编辑 */
showTitle?: boolean;
/** 额外的左侧内容 */
leftExtra?: boolean;
}>();
const sessionStore = useSessionStore();
const currentSession = computed(() => sessionStore.currentSession);
</script>
<template>
<div class="chat-header">
<div class="chat-header__content">
<div class="chat-header__left">
<Collapse />
<CreateChat />
<div
v-if="showTitle && currentSession"
class="chat-header__divider"
/>
</div>
<div v-if="showTitle" class="chat-header__center">
<TitleEditing />
</div>
<div v-if="$slots.right" class="chat-header__right">
<slot name="right" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.chat-header {
display: flex;
align-items: center;
width: 100%;
height: 60px;
flex-shrink: 0;
&__content {
display: flex;
align-items: center;
width: 100%;
height: 100%;
}
&__left {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
padding-left: 20px;
}
&__divider {
width: 1px;
height: 30px;
background-color: rgba(217, 217, 217);
}
&__center {
flex: 1;
min-width: 0;
margin-left: 12px;
overflow: hidden;
}
&__right {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
padding-right: 20px;
}
}
// 响应式
@media (max-width: 768px) {
.chat-header {
height: 50px;
&__left {
padding-left: 8px;
gap: 8px;
}
&__center {
margin-left: 8px;
}
&__right {
padding-right: 12px;
}
}
}
@media (max-width: 480px) {
.chat-header {
height: 48px;
&__left {
padding-left: 4px;
gap: 6px;
}
&__center {
margin-left: 6px;
}
&__right {
padding-right: 8px;
}
}
}
</style>

View File

@@ -0,0 +1,206 @@
<!-- 聊天发送区域组件 -->
<script setup lang="ts">
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import { ArrowLeftBold, ArrowRightBold, Loading } from '@element-plus/icons-vue';
import { ElIcon } from 'element-plus';
import { watch, nextTick, ref } from 'vue';
import { Sender } from 'vue-element-plus-x';
import ModelSelect from '@/components/ModelSelect/index.vue';
import { useFilesStore } from '@/stores/modules/files';
const props = defineProps<{
/** 是否加载中 */
loading?: boolean;
/** 是否显示发送按钮 */
showSend?: boolean;
/** 最小行数 */
minRows?: number;
/** 最大行数 */
maxRows?: number;
/** 是否只读模式 */
readOnly?: boolean;
}>();
const emit = defineEmits<{
(e: 'submit', value: string): void;
(e: 'cancel'): void;
}>();
const modelValue = defineModel<string>({ default: '' });
const filesStore = useFilesStore();
const senderRef = ref<InstanceType<typeof Sender> | null>(null);
/**
* 删除文件卡片
*/
function handleDeleteCard(_item: FilesCardProps, index: number) {
filesStore.deleteFileByIndex(index);
}
/**
* 监听文件列表变化,自动展开/收起 Sender 头部
*/
watch(
() => filesStore.filesList.length,
(val) => {
nextTick(() => {
if (val > 0) {
senderRef.value?.openHeader();
} else {
senderRef.value?.closeHeader();
}
});
},
);
defineExpose({
senderRef,
focus: () => senderRef.value?.focus(),
blur: () => senderRef.value?.blur(),
});
</script>
<template>
<Sender
ref="senderRef"
v-model="modelValue"
class="chat-sender"
:auto-size="{
maxRows: maxRows ?? 6,
minRows: minRows ?? 2,
}"
variant="updown"
clearable
allow-speech
:loading="loading"
:read-only="readOnly"
@submit="(v) => emit('submit', v)"
@cancel="emit('cancel')"
>
<!-- 头部文件附件区域 -->
<template #header>
<div class="chat-sender__header">
<Attachments
:items="filesStore.filesList"
:hide-upload="true"
@delete-card="handleDeleteCard"
>
<!-- 左侧滚动按钮 -->
<template #prev-button="{ show, onScrollLeft }">
<div
v-if="show"
class="chat-sender__scroll-btn chat-sender__scroll-btn--prev"
@click="onScrollLeft"
>
<ElIcon><ArrowLeftBold /></ElIcon>
</div>
</template>
<!-- 右侧滚动按钮 -->
<template #next-button="{ show, onScrollRight }">
<div
v-if="show"
class="chat-sender__scroll-btn chat-sender__scroll-btn--next"
@click="onScrollRight"
>
<ElIcon><ArrowRightBold /></ElIcon>
</div>
</template>
</Attachments>
</div>
</template>
<!-- 前缀文件选择和模型选择 -->
<template #prefix>
<div class="chat-sender__prefix">
<FilesSelect />
<ModelSelect />
</div>
</template>
<!-- 后缀加载动画 -->
<template #suffix>
<ElIcon v-if="loading" class="chat-sender__loading">
<Loading />
</ElIcon>
</template>
</Sender>
</template>
<style scoped lang="scss">
.chat-sender {
width: 100%;
&__header {
padding: 12px 12px 0 12px;
}
&__prefix {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
flex: none;
width: fit-content;
overflow: hidden;
}
&__scroll-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: 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);
}
&--prev {
left: 8px;
}
&--next {
right: 8px;
}
}
&__loading {
margin-left: 8px;
color: var(--el-color-primary);
animation: rotating 2s linear infinite;
}
}
@keyframes rotating {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// 响应式
@media (max-width: 768px) {
.chat-sender {
&__prefix {
flex-wrap: wrap;
gap: 6px;
}
}
}
</style>

View File

@@ -0,0 +1,90 @@
<!-- 删除模式工具栏 -->
<script setup lang="ts">
import { ElButton } from 'element-plus';
const props = defineProps<{
selectedCount: number;
}>();
const emit = defineEmits<{
(e: 'confirm'): void;
(e: 'cancel'): void;
}>();
</script>
<template>
<div class="delete-toolbar">
<span class="delete-toolbar__count">已选择 {{ selectedCount }} 条消息</span>
<div class="delete-toolbar__actions">
<ElButton type="danger" size="small" @click="emit('confirm')">
确认删除
</ElButton>
<ElButton size="small" @click="emit('cancel')">
取消
</ElButton>
</div>
</div>
</template>
<style scoped lang="scss">
.delete-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: 12px;
background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%);
border: 1px solid #fed7aa;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&__count {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: #ea580c;
&::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background: #ea580c;
border-radius: 50%;
}
}
&__actions {
display: flex;
gap: 6px;
}
}
// 深度选择器样式单独处理
:deep(.el-button) {
padding: 6px 12px;
font-size: 12px;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background: #fff;
border-color: #fed7aa;
}
}
:deep(.el-button--danger) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border: none;
color: #fff;
&:hover {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.2);
border-color: transparent;
}
}
</style>

View File

@@ -0,0 +1,588 @@
<!-- 单条消息组件 -->
<script setup lang="ts">
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import { Delete, Document, DocumentCopy, Edit, Refresh } from '@element-plus/icons-vue';
import { ElButton, ElCheckbox, ElIcon, ElInput, ElTooltip } from 'element-plus';
import MarkedMarkdown from '@/components/MarkedMarkdown/index.vue';
import type { MessageItem } from '@/composables/chat';
const props = defineProps<{
item: MessageItem;
isDeleteMode: boolean;
isEditing: boolean;
editContent: string;
isSending: boolean;
isSelected: boolean;
}>();
/**
* 格式化时间
* 将 ISO 时间字符串格式化为 yyyy-MM-dd HH:mm:ss
*/
function formatTime(time: string | undefined): string {
if (!time) return '';
try {
const date = new Date(time);
if (Number.isNaN(date.getTime())) return time;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
catch {
return time;
}
}
const emit = defineEmits<{
(e: 'toggleSelection', item: MessageItem): void;
(e: 'edit', item: MessageItem): void;
(e: 'cancelEdit'): void;
(e: 'submitEdit', item: MessageItem): void;
(e: 'update:editContent', value: string): void;
(e: 'copy', item: MessageItem): void;
(e: 'regenerate', item: MessageItem): void;
(e: 'delete', item: MessageItem): void;
(e: 'imagePreview', url: string): void;
}>();
/**
* 检查消息是否有有效ID
*/
function hasValidId(item: MessageItem): boolean {
return item.id !== undefined && (typeof item.id === 'string' || (typeof item.id === 'number' && item.id > 0));
}
/**
* 处理思考链状态变化
*/
function handleThinkingChange(payload: { value: boolean; status: ThinkingStatus }) {
// 可以在这里添加处理逻辑
}
</script>
<template>
<div
class="message-wrapper"
:class="{
'message-wrapper--ai': item.role !== 'user',
'message-wrapper--user': item.role === 'user',
'message-wrapper--delete-mode': isDeleteMode,
'message-wrapper--selected': isSelected && isDeleteMode,
}"
@click="isDeleteMode && item.id && emit('toggleSelection', item)"
>
<!-- 删除模式勾选框 -->
<div v-if="isDeleteMode && item.id" class="message-wrapper__checkbox">
<ElCheckbox
:model-value="isSelected"
@click.stop
@update:model-value="emit('toggleSelection', item)"
/>
</div>
<!-- 消息内容区域 -->
<div class="message-wrapper__content">
<!-- 思考链仅AI消息 -->
<template v-if="item.reasoning_content && !isDeleteMode && item.role !== 'user'">
<Thinking
v-model="item.thinlCollapse"
:content="item.reasoning_content"
:status="item.thinkingStatus"
class="message-wrapper__thinking"
@change="handleThinkingChange"
/>
</template>
<!-- AI 消息内容 -->
<template v-if="item.role !== 'user'">
<div class="message-content message-content--ai">
<MarkedMarkdown
v-if="item.content"
class="message-content__markdown"
:content="item.content"
/>
</div>
</template>
<!-- 用户消息内容 -->
<template v-if="item.role === 'user'">
<div
class="message-content message-content--user"
:class="{ 'message-content--editing': isEditing }"
>
<!-- 编辑模式 -->
<template v-if="isEditing">
<div class="message-content__edit-wrapper">
<ElInput
:model-value="editContent"
type="textarea"
:autosize="{ minRows: 3, maxRows: 10 }"
placeholder="编辑消息内容"
@update:model-value="emit('update:editContent', $event)"
/>
<div class="message-content__edit-actions">
<ElButton
type="primary"
size="small"
@click.stop="emit('submitEdit', item)"
>
发送
</ElButton>
<ElButton size="small" @click.stop="emit('cancelEdit')">
取消
</ElButton>
</div>
</div>
</template>
<!-- 正常显示模式 -->
<template v-else>
<!-- 图片列表 -->
<div v-if="item.images && item.images.length > 0" class="message-content__images">
<img
v-for="(image, index) in item.images"
:key="index"
:src="image.url"
:alt="image.name || '图片'"
class="message-content__image"
@click.stop="emit('imagePreview', image.url)"
>
</div>
<!-- 文件列表 -->
<div v-if="item.files && item.files.length > 0" class="message-content__files">
<div
v-for="(file, index) in item.files"
:key="index"
class="message-content__file-item"
>
<ElIcon class="message-content__file-icon">
<Document />
</ElIcon>
<span class="message-content__file-name">{{ file.name }}</span>
</div>
</div>
<!-- 文本内容 -->
<div v-if="item.content" class="message-content__text">
{{ item.content }}
</div>
</template>
</div>
</template>
</div>
<!-- 操作栏非删除模式 -->
<div v-if="!isDeleteMode" class="message-wrapper__footer">
<div
class="message-wrapper__footer-content"
:class="{ 'message-wrapper__footer-content--ai': item.role !== 'user', 'message-wrapper__footer-content--user': item.role === 'user' }"
>
<!-- 时间和token信息 -->
<div class="message-wrapper__info">
<span v-if="item.creationTime" class="message-wrapper__time">
{{ formatTime(item.creationTime) }}
</span>
<span
v-if="item.role !== 'user' && item?.tokenUsage?.total"
class="message-wrapper__token"
>
{{ item?.tokenUsage?.total }} tokens
</span>
</div>
<!-- 操作按钮组 -->
<div class="message-wrapper__actions">
<ElTooltip content="复制" placement="top">
<ElButton text @click="emit('copy', item)">
<ElIcon><DocumentCopy /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
v-if="item.role !== 'user'"
content="重新生成"
placement="top"
>
<ElButton text :disabled="isSending" @click="emit('regenerate', item)">
<ElIcon><Refresh /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip
v-if="item.role === 'user' && hasValidId(item)"
content="编辑"
placement="top"
>
<ElButton
text
:disabled="isSending"
@click="emit('edit', item)"
>
<ElIcon><Edit /></ElIcon>
</ElButton>
</ElTooltip>
<ElTooltip content="删除" placement="top">
<ElButton text @click="emit('delete', item)">
<ElIcon><Delete /></ElIcon>
</ElButton>
</ElTooltip>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
// 消息包装器 - 删除模式时整行有背景
.message-wrapper {
position: relative;
width: 100%;
min-width: 100%;
padding: 12px 16px;
border-radius: 8px;
transition: all 0.2s ease;
box-sizing: border-box;
// 删除模式下的样式
&--delete-mode {
cursor: pointer;
background-color: #f5f7fa;
border: 1px solid transparent;
margin-bottom: 8px;
&:hover {
background-color: #e8f0fe;
border-color: #c6dafc;
}
}
// 选中状态
&--selected {
background-color: #d2e3fc !important;
border-color: #8ab4f8 !important;
}
// 勾选框
&__checkbox {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
:deep(.el-checkbox) {
--el-checkbox-input-height: 20px;
--el-checkbox-input-width: 20px;
}
}
// 内容区域 - 删除模式时有左边距给勾选框
&__content {
width: 100%;
.message-wrapper--delete-mode & {
padding-left: 36px;
}
}
// 思考链
&__thinking {
margin-bottom: 12px;
}
// 底部操作栏
&__footer {
margin-top: 8px;
}
&__footer-content {
display: flex;
align-items: center;
gap: 12px;
// AI消息操作栏在左侧
&--ai {
justify-content: flex-start;
flex-direction: row-reverse;
.message-wrapper__info {
margin-left: 0;
margin-right: auto;
}
.message-wrapper__actions {
margin-left: 0;
}
}
// 用户消息:操作栏在右侧,时间和操作按钮一起
&--user {
justify-content: flex-end;
.message-wrapper__info {
margin-right: 0;
order: 1;
}
.message-wrapper__actions {
order: 0;
}
}
}
&__info {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #888;
}
&__time,
&__token {
display: flex;
align-items: center;
color: #888;
}
&__token::before {
content: '';
width: 3px;
height: 3px;
margin-right: 8px;
background: #bbb;
border-radius: 50%;
}
&__actions {
display: flex;
align-items: center;
gap: 2px;
:deep(.el-button) {
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;
}
}
}
}
// 消息内容
.message-content {
// AI消息无气泡背景
&--ai {
width: 100%;
padding: 0;
background: transparent;
}
// 用户消息:有灰色气泡背景
&--user {
display: flex;
flex-direction: column;
gap: 8px;
width: fit-content;
max-width: 80%;
margin-left: auto;
padding: 12px 16px;
background-color: #f5f5f5;
border-radius: 12px;
}
// 编辑模式 - 宽度100%
&--editing {
width: 100%;
max-width: 100%;
margin-left: 0;
background-color: transparent;
}
&__markdown {
background-color: transparent;
}
&__images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
&__image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
object-fit: cover;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.05);
}
}
&__files {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 4px;
}
&__file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 6px;
font-size: 13px;
}
&__file-icon {
font-size: 16px;
color: #409eff;
}
&__file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__text {
white-space: pre-wrap;
line-height: 1.6;
color: #333;
}
&__edit-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
:deep(.el-textarea__inner) {
min-height: 80px !important;
font-size: 14px;
line-height: 1.6;
}
}
&__edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
}
// 响应式
@media (max-width: 768px) {
.message-content {
&__image {
max-width: 150px;
max-height: 150px;
}
&__file-item {
padding: 6px 10px;
font-size: 12px;
}
&__file-icon {
font-size: 14px;
}
}
.message-wrapper {
&__footer-content {
flex-wrap: wrap;
}
&__info {
font-size: 11px;
}
&__footer-content--user,
&__footer-content--ai {
flex-direction: row;
.message-wrapper__info {
order: 1;
margin: 0;
margin-right: auto;
}
.message-wrapper__actions {
order: 2;
}
}
}
}
@media (max-width: 480px) {
.message-content {
&__image {
max-width: 120px;
max-height: 120px;
}
&--user {
max-width: 90%;
}
}
.message-wrapper {
padding: 10px 12px;
&__checkbox {
left: 8px;
top: 8px;
}
&--delete-mode &__content {
padding-left: 28px;
}
&__time,
&__token {
font-size: 10px;
}
}
}
.message-wrapper__actions .el-button {
margin-left: 0;
}
</style>

View File

@@ -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';

View File

@@ -1,61 +1,77 @@
<!-- 默认消息列表页 -->
<script setup lang="ts">
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import { ArrowLeftBold, ArrowRightBold, Loading } from '@element-plus/icons-vue';
import { useDebounceFn } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import { ElIcon, ElMessage } from 'element-plus';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { Sender } from 'vue-element-plus-x';
import ModelSelect from '@/components/ModelSelect/index.vue';
import WelecomeText from '@/components/WelecomeText/index.vue';
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
import CreateChat from '@/layouts/components/Header/components/CreateChat.vue';
import { ChatHeader } from '@/pages/chat/components';
import { useUserStore } from '@/stores';
import { useFilesStore } from '@/stores/modules/files';
import { useSessionStore } from '@/stores/modules/session';
import { useFilePaste } from '@/composables/chat';
// Store 实例
const userStore = useUserStore();
const sessionStore = useSessionStore();
const filesStore = useFilesStore();
// 计算属性
const currentSession = computed(() => sessionStore.currentSession);
// 响应式数据
const senderValue = ref(''); // 输入框内容
const senderRef = ref(); // Sender 组件引用
const isSending = ref(false); // 发送状态标志
const senderValue = ref('');
const senderRef = ref<InstanceType<typeof Sender> | null>(null);
const isSending = ref(false);
// 文件处理相关常量
const MAX_FILE_SIZE = 3 * 1024 * 1024;
const MAX_TOTAL_CONTENT_LENGTH = 150000;
// 使用文件粘贴 composable
const { handlePaste } = useFilePaste({
maxFileSize: MAX_FILE_SIZE,
maxTotalContentLength: MAX_TOTAL_CONTENT_LENGTH,
getCurrentTotalLength: () => {
let total = 0;
filesStore.filesList.forEach((f) => {
if (f.fileType === 'text' && f.fileContent) {
total += f.fileContent.length;
}
if (f.fileType === 'image' && f.base64) {
total += Math.floor(f.base64.length * 0.5);
}
});
return total;
},
addFiles: (files) => filesStore.setFilesList([...filesStore.filesList, ...files]),
});
/**
* 防抖发送消息函数
*/
const debouncedSend = useDebounceFn(
async () => {
// 1. 验证输入
// 验证输入
if (!senderValue.value.trim()) {
ElMessage.warning('消息内容不能为空');
return;
}
// 2. 检查是否正在发送
// 检查是否正在发送
if (isSending.value) {
ElMessage.warning('请等待上一条消息发送完成');
return;
}
// 3. 准备发送数据
// 准备发送数据
const content = senderValue.value.trim();
isSending.value = true;
try {
// 4. 保存到本地存储(可选,用于页面刷新后恢复)
// 保存到本地存储
localStorage.setItem('chatContent', content);
// 5. 创建会话
// 创建会话
await sessionStore.createSessionList({
userId: userStore.userInfo?.userId as number,
sessionContent: content,
@@ -63,20 +79,17 @@ const debouncedSend = useDebounceFn(
remark: content.slice(0, 10),
});
// 6. 清空输入框
// 清空输入框
senderValue.value = '';
}
catch (error: any) {
} catch (error: any) {
console.error('发送消息失败:', error);
ElMessage.error(error.message || '发送消息失败');
}
finally {
// 7. 重置发送状态
} finally {
isSending.value = false;
}
},
800, // 防抖延迟
{ leading: true, trailing: false }, // 立即执行第一次,忽略后续快速点击
800,
{ leading: true, trailing: false },
);
/**
@@ -86,15 +99,6 @@ function handleSend() {
debouncedSend();
}
/**
* 删除文件卡片
* @param _item 文件项
* @param index 文件索引
*/
function handleDeleteCard(_item: FilesCardProps, index: number) {
filesStore.deleteFileByIndex(index);
}
/**
* 监听文件列表变化,自动展开/收起 Sender 头部
*/
@@ -104,181 +108,14 @@ watch(
nextTick(() => {
if (val > 0) {
senderRef.value?.openHeader();
}
else {
} else {
senderRef.value?.closeHeader();
}
});
},
);
/**
* 压缩图片
*/
function compressImage(file: File, maxWidth = 1024, maxHeight = 1024, quality = 0.8): Promise<Blob> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = width * ratio;
height = height * ratio;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
}
else {
reject(new Error('压缩失败'));
}
},
file.type,
quality,
);
};
img.onerror = reject;
img.src = e.target?.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* 将 Blob 转换为 base64
*/
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/**
* 处理粘贴事件
*/
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items)
return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
if (files.length === 0)
return;
event.preventDefault();
// 计算已有文件的总内容长度
let totalContentLength = 0;
filesStore.filesList.forEach((f) => {
if (f.fileType === 'text' && f.fileContent) {
totalContentLength += f.fileContent.length;
}
if (f.fileType === 'image' && f.base64) {
totalContentLength += Math.floor(f.base64.length * 0.5);
}
});
const arr: any[] = [];
for (const file of files) {
// 验证文件大小
if (file.size > MAX_FILE_SIZE) {
ElMessage.error(`文件 ${file.name} 超过 3MB 限制,已跳过`);
continue;
}
const isImage = file.type.startsWith('image/');
if (isImage) {
try {
const compressionLevels = [
{ maxWidth: 800, maxHeight: 800, quality: 0.6 },
{ maxWidth: 600, maxHeight: 600, quality: 0.5 },
{ maxWidth: 400, maxHeight: 400, quality: 0.4 },
];
let compressedBlob: Blob | null = null;
let base64 = '';
for (const level of compressionLevels) {
compressedBlob = await compressImage(file, level.maxWidth, level.maxHeight, level.quality);
base64 = await blobToBase64(compressedBlob);
const estimatedLength = Math.floor(base64.length * 0.5);
if (totalContentLength + estimatedLength <= MAX_TOTAL_CONTENT_LENGTH) {
totalContentLength += estimatedLength;
break;
}
compressedBlob = null;
}
if (!compressedBlob) {
ElMessage.error(`${file.name} 图片内容过大,请压缩后上传`);
continue;
}
arr.push({
uid: crypto.randomUUID(),
name: file.name,
fileSize: file.size,
file,
maxWidth: '200px',
showDelIcon: true,
imgPreview: true,
imgVariant: 'square',
url: base64,
isUploaded: true,
base64,
fileType: 'image',
});
}
catch (error) {
console.error('处理图片失败:', error);
ElMessage.error(`${file.name} 处理失败`);
}
}
else {
ElMessage.warning(`${file.name} 不支持粘贴,请使用上传按钮`);
}
}
if (arr.length > 0) {
filesStore.setFilesList([...filesStore.filesList, ...arr]);
ElMessage.success(`已添加 ${arr.length} 个文件`);
}
}
// 监听粘贴事件
// 生命周期
onMounted(() => {
document.addEventListener('paste', handlePaste);
});
@@ -290,17 +127,11 @@ onUnmounted(() => {
<template>
<div class="chat-default">
<!-- 头部导航栏 -->
<div class="chat-header">
<div class="header-content">
<div class="header-left">
<Collapse />
<CreateChat />
</div>
</div>
</div>
<!-- 头部 -->
<ChatHeader />
<div class="chat-default-wrap">
<!-- 内容区域 -->
<div class="chat-default__content">
<!-- 欢迎文本 -->
<WelecomeText />
@@ -308,12 +139,8 @@ onUnmounted(() => {
<Sender
ref="senderRef"
v-model="senderValue"
class="chat-default-sender"
data-tour="chat-sender"
:auto-size="{
maxRows: 9,
minRows: 3,
}"
class="chat-default__sender"
:auto-size="{ maxRows: 9, minRows: 3 }"
variant="updown"
clearable
allow-speech
@@ -322,22 +149,20 @@ onUnmounted(() => {
>
<!-- 头部文件附件区域 -->
<template #header>
<div class="sender-header">
<div class="chat-default__sender-header">
<Attachments
:items="filesStore.filesList"
:hide-upload="true"
@delete-card="handleDeleteCard"
@delete-card="(_, index) => filesStore.deleteFileByIndex(index)"
>
<!-- 左侧滚动按钮 -->
<template #prev-button="{ show, onScrollLeft }">
<div
v-if="show"
class="scroll-btn prev-btn"
class="chat-default__scroll-btn chat-default__scroll-btn--prev"
@click="onScrollLeft"
>
<el-icon>
<ArrowLeftBold />
</el-icon>
<el-icon><ArrowLeftBold /></el-icon>
</div>
</template>
@@ -345,12 +170,10 @@ onUnmounted(() => {
<template #next-button="{ show, onScrollRight }">
<div
v-if="show"
class="scroll-btn next-btn"
class="chat-default__scroll-btn chat-default__scroll-btn--next"
@click="onScrollRight"
>
<el-icon>
<ArrowRightBold />
</el-icon>
<el-icon><ArrowRightBold /></el-icon>
</div>
</template>
</Attachments>
@@ -359,7 +182,7 @@ onUnmounted(() => {
<!-- 前缀:文件选择和模型选择 -->
<template #prefix>
<div class="sender-prefix">
<div class="chat-default__sender-prefix">
<FilesSelect />
<ModelSelect />
</div>
@@ -367,9 +190,9 @@ onUnmounted(() => {
<!-- 后缀:发送加载动画 -->
<template #suffix>
<el-icon v-if="isSending" class="loading-icon">
<ElIcon v-if="isSending" class="chat-default__loading">
<Loading />
</el-icon>
</ElIcon>
</template>
</Sender>
</div>
@@ -385,97 +208,94 @@ onUnmounted(() => {
align-items: center;
padding: 0 20px;
.chat-header {
width: 100%;
//max-width: 1000px;
height: 60px;
@media (max-width: 768px) {
padding: 0 12px;
}
&__content {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
justify-content: center;
width: 100%;
max-width: 800px;
min-height: 450px;
padding: 20px;
box-sizing: border-box;
.header-content {
width: 100%;
display: flex;
align-items: center;
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
@media (max-width: 768px) {
padding: 12px;
min-height: calc(100vh - 120px);
}
}
}
.chat-default-wrap {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 800px;
min-height: 450px;
padding: 20px;
box-sizing: border-box;
.chat-default-sender {
&__sender {
width: 100%;
margin-top: 30px;
}
}
.sender-header {
padding: 12px 12px 0 12px;
}
.sender-prefix {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
flex: none;
width: fit-content;
overflow: hidden;
}
.scroll-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: 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);
@media (max-width: 768px) {
margin-top: 20px;
}
}
&.prev-btn {
left: 8px;
&__sender-header {
padding: 12px 12px 0 12px;
}
&.next-btn {
right: 8px;
}
}
&__sender-prefix {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
flex: none;
width: fit-content;
overflow: hidden;
.loading-icon {
margin-left: 8px;
color: var(--el-color-primary);
animation: rotating 2s linear infinite;
@media (max-width: 768px) {
flex-wrap: wrap;
gap: 6px;
}
}
&__scroll-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: 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);
}
&--prev {
left: 8px;
}
&--next {
right: 8px;
}
}
&__loading {
margin-left: 8px;
color: var(--el-color-primary);
animation: rotating 2s linear infinite;
}
}
@keyframes rotating {
@@ -486,29 +306,4 @@ onUnmounted(() => {
transform: rotate(360deg);
}
}
// 响应式设计
@media (max-width: 768px) {
.chat-default {
padding: 0 12px;
.chat-header {
height: 50px;
}
}
.chat-default-wrap {
padding: 12px;
min-height: calc(100vh - 120px);
.chat-default-sender {
margin-top: 20px;
}
}
.sender-prefix {
flex-wrap: wrap;
gap: 6px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
// Chat 页面公共样式统一导入
@forward './variables';
@forward './mixins';
@forward './bubble';

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -249,6 +249,9 @@ onMounted(() => {
<p class="banner-subtitle">
探索并接入全球顶尖AI模型覆盖文本图像嵌入等多个领域
</p>
<p class="banner-subtitle">
尊享Token = 实际消耗Token * 当前模型倍率
</p>
</div>
<!-- 统计信息卡片 -->
<div class="stats-cards">
@@ -297,7 +300,7 @@ onMounted(() => {
<!-- 点击引导手势 -->
<div class="click-hint">
<svg class="hand-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 11V6C9 5.44772 9.44772 5 10 5C10.5523 5 11 5.44772 11 6V11M9 11V16.5C9 17.8807 10.1193 19 11.5 19H12.5C13.8807 19 15 17.8807 15 16.5V11M9 11H7.5C6.67157 11 6 11.6716 6 12.5C6 13.3284 6.67157 14 7.5 14H9M15 11V8C15 7.44772 15.4477 7 16 7C16.5523 7 17 7.44772 17 8V11M15 11H17.5C18.3284 11 19 11.6716 19 12.5C19 13.3284 18.3284 14 17.5 14H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 11V6C9 5.44772 9.44772 5 10 5C10.5523 5 11 5.44772 11 6V11M9 11V16.5C9 17.8807 10.1193 19 11.5 19H12.5C13.8807 19 15 17.8807 15 16.5V11M9 11H7.5C6.67157 11 6 11.6716 6 12.5C6 13.3284 6.67157 14 7.5 14H9M15 11V8C15 7.44772 15.4477 7 16 7C16.5523 7 17 7.44772 17 8V11M15 11H17.5C18.3284 11 19 11.6716 19 12.5C19 13.3284 18.3284 14 17.5 14H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="hint-text">点击查看</span>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import FontAwesomeDemo from '@/components/FontAwesomeIcon/demo.vue';
</script>
<template>
<div class="fontawesome-test-page">
<h1>FontAwesome 图标测试页面</h1>
<p>如果看到以下图标正常显示说明 FontAwesome 配置成功</p>
<FontAwesomeDemo />
</div>
</template>
<style scoped lang="scss">
.fontawesome-test-page {
padding: 40px;
min-height: 100vh;
background-color: var(--el-bg-color-page);
h1 {
color: var(--el-text-color-primary);
margin-bottom: 10px;
}
p {
color: var(--el-text-color-regular);
margin-bottom: 30px;
}
}
</style>

View File

@@ -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<any>)().catch(() => {});
}) || setTimeout(() => {
(component as () => Promise<any>)().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();
},
);

View File

@@ -1,10 +1,22 @@
import type { RouteRecordRaw } from 'vue-router';
// 预加载辅助函数
function preloadComponent(importFn: () => Promise<any>) {
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 = [

View File

@@ -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';

View File

@@ -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,

View File

@@ -1,12 +1,10 @@
import { defineStore } from 'pinia';
import { useRouter } from 'vue-router';
export const useUserStore = defineStore(
'user',
() => {
const token = ref<string>();
const refreshToken = ref<string | undefined>();
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 中进行路由跳转,由调用方决定跳转逻辑
// 这样可以避免路由守卫中的循环重定向问题
};
// 新增:登录弹框状态

View File

@@ -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
}
/* ========== 暗色模式变量 ========== */

View File

@@ -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;
}

View File

@@ -0,0 +1,123 @@
/**
* Element Plus 图标到 FontAwesome 图标的映射
* 用于迁移过程中的图标替换
*/
export const iconMapping: Record<string, string> = {
// 基础操作
'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();
}

View File

@@ -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']

View File

@@ -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;
}

View File

@@ -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,