feat: 集成AI知识库管理系统

- 新增AI知识库管理功能模块
- 实现知识库的增删改查功能
- 实现文档管理(文本/文件/链接)
- 实现向量化功能和测试
- 支持卡片式布局展示
- 完整的响应式设计
- 修复所有TypeScript类型错误
- 添加依赖:marked@^16.4.0, less@^4.2.2
- 打包测试通过

主要文件:
- src/views/teacher/ai-knowledge-naive-ui/ - AI知识库管理模块
- docs/ - 相关API文档
- package.json - 新增依赖配置
This commit is contained in:
小张 2025-10-14 17:33:25 +08:00
parent 5de4c9596f
commit 5455490811
25 changed files with 4623 additions and 1 deletions

View File

@ -1,5 +1,6 @@
---
type: "manual"
type: "always_apply"
description: "Example description"
---
1、在接下来的每一个步骤当中请帮我实现对页面的响应式设计

148
docs/ai-model-api-debug.md Normal file
View File

@ -0,0 +1,148 @@
# AI模型字典API调试指南
## 问题描述
在调用AI模型字典API时遇到错误
```
Sign签名校验失败时间戳为空
```
## 已实施的修复措施
### 1. **添加时间戳参数到URL**
```typescript
// 修改前
return request.get('/sys/dict/getDictItems/airag_model%20where%20model_type%20=%20\'LLM\',name,id')
// 修改后
const timestamp = Date.now()
return request.get(`/sys/dict/getDictItems/airag_model%20where%20model_type%20=%20'LLM',name,id?_t=${timestamp}`)
```
### 2. **增强请求头时间戳**
```typescript
// 添加多种时间戳格式
const timestamp = Date.now().toString()
config.headers['X-Request-Time'] = timestamp
config.headers['timestamp'] = timestamp
config.headers['X-Timestamp'] = timestamp
```
### 3. **增强错误调试**
- 在AiAppSetting.vue中添加详细的调试日志
- 在AiModelTest.vue中添加直接请求测试功能
- 显示完整的错误信息和响应数据
## 测试步骤
### 1. 访问测试页面
```
http://localhost:5173/ai-model-test
```
### 2. 检查登录状态
- 确保用户已登录并有有效的token
- 在测试页面中查看"用户Token"状态
### 3. 测试API调用
1. 点击"加载AI模型列表"按钮使用封装的API
2. 点击"直接请求测试"按钮使用原生axios
3. 查看控制台日志和响应数据
### 4. 检查AI应用设置
1. 访问 `/ai/app` 页面
2. 点击任意AI应用卡片
3. 切换到"模型配置"选项卡
4. 查看控制台日志
## 调试信息
### 请求信息
- **URL**: `/sys/dict/getDictItems/airag_model%20where%20model_type%20=%20'LLM',name,id?_t={timestamp}`
- **方法**: GET
- **Base URL**: `/jeecgboot` (或环境变量配置)
### 请求头
```javascript
{
'Content-Type': 'application/json',
'X-Access-Token': '{user_token}',
'X-Request-Time': '{timestamp}',
'timestamp': '{timestamp}',
'X-Timestamp': '{timestamp}'
}
```
### 预期响应格式
```json
{
"success": true,
"message": "",
"code": 0,
"result": [
{
"value": "1890232564262739969",
"text": "OpenAI",
"color": null,
"jsonObject": null,
"label": "OpenAI",
"title": "OpenAI"
}
],
"timestamp": 1759136645858
}
```
## 可能的解决方案
### 1. **检查后端签名算法**
如果问题仍然存在,可能需要:
- 检查后端期望的签名算法
- 添加必要的签名参数
- 确认时间戳格式要求
### 2. **检查接口权限**
- 确认该接口是否需要特定的用户权限
- 检查token是否有效
- 验证用户角色权限
### 3. **检查接口路径**
- 确认接口路径是否正确
- 检查URL编码是否正确
- 验证查询参数格式
## 备用方案
如果API调用仍然失败系统会自动使用备用数据
```typescript
modelOptions.value = [
{ label: 'GPT-3.5 Turbo', value: 'gpt-3.5-turbo' },
{ label: 'GPT-4', value: 'gpt-4' },
{ label: 'Claude-3', value: 'claude-3' }
]
```
## 下一步调试
1. **查看网络请求**
- 打开浏览器开发者工具
- 查看Network选项卡
- 检查实际发送的请求和响应
2. **检查后端日志**
- 查看后端服务器日志
- 确认请求是否到达后端
- 检查签名验证逻辑
3. **联系后端开发**
- 确认接口的正确调用方式
- 获取签名算法详细信息
- 确认必需的请求参数
## 文件修改记录
- `src/api/modules/system.ts`: 添加字典API和时间戳参数
- `src/api/request.ts`: 增强时间戳请求头
- `src/views/Ai/component/AiAppSetting.vue`: 添加详细调试日志
- `src/views/AiModelTest.vue`: 创建专门的测试页面
- `src/router/index.ts`: 添加测试页面路由

234
docs/ai-model-dict-api.md Normal file
View File

@ -0,0 +1,234 @@
# AI模型字典API集成
## 概述
本文档说明如何在AI应用设置弹窗中集成真实的AI模型字典API替换原有的模拟数据。
## API接口信息
### 获取AI模型字典
- **接口地址**: `/sys/dict/getDictItems/airag_model%20where%20model_type%20=%20'LLM',name,id`
- **请求方法**: GET
- **接口说明**: 获取LLM类型的AI模型字典数据
### 响应格式
```json
{
"success": true,
"message": "",
"code": 0,
"result": [
{
"value": "1890232564262739969",
"text": "OpenAI",
"color": null,
"jsonObject": null,
"label": "OpenAI",
"title": "OpenAI"
},
{
"value": "1897481367743143938",
"text": "deepseek",
"color": null,
"jsonObject": null,
"label": "deepseek",
"title": "deepseek"
},
{
"value": "1897883052995006466",
"text": "智谱",
"color": null,
"jsonObject": null,
"label": "智谱",
"title": "智谱"
},
{
"value": "1970031008335876097",
"text": "测试",
"color": null,
"jsonObject": null,
"label": "测试",
"title": "测试"
}
],
"timestamp": 1759136645858
}
```
## 实现的功能
### 1. **API模块扩展** (`src/api/modules/system.ts`)
添加了字典相关的API接口
```typescript
// 字典项接口
export interface DictItem {
value: string
text: string
color: string | null
jsonObject: any | null
label: string
title: string
}
export const SystemApi = {
// 获取字典项
getDictItems(dictCode: string, params?: string): Promise<ApiResponse<DictItem[]>>
// 获取AI模型字典项LLM类型
getAiModelDict(): Promise<ApiResponse<DictItem[]>>
}
```
### 2. **AI应用设置组件更新** (`src/views/Ai/component/AiAppSetting.vue`)
修改了 `loadModelOptions` 函数:
```typescript
// 加载模型选项
const loadModelOptions = async () => {
loadingModels.value = true
try {
const response = await SystemApi.getAiModelDict()
if (response.data.code === 200 || response.data.code === 0) {
modelOptions.value = response.data.data.map(item => ({
label: item.text,
value: item.value
}))
console.log('✅ AI模型列表加载成功:', modelOptions.value)
} else {
throw new Error(response.data.message || '获取模型列表失败')
}
} catch (error: any) {
console.error('❌ 加载模型列表失败:', error)
message.error(error.message || '加载模型列表失败')
// 失败时使用备用数据
modelOptions.value = [
{ label: 'GPT-3.5 Turbo', value: 'gpt-3.5-turbo' },
{ label: 'GPT-4', value: 'gpt-4' },
{ label: 'Claude-3', value: 'claude-3' }
]
} finally {
loadingModels.value = false
}
}
```
### 3. **测试页面** (`src/views/AiModelTest.vue`)
创建了专门的测试页面来验证API调用
- 实时加载AI模型数据
- 显示API响应状态
- 测试选择器功能
- 查看原始数据
## 使用流程
### 1. 点击AI应用卡片
在AI应用列表页面点击任意应用卡片
```vue
<n-card
class="app-card"
hoverable
@click="handleEditApp(item)"
>
```
### 2. 打开设置弹窗
系统会调用 `handleEditApp` 函数,打开应用设置弹窗:
```typescript
const handleEditApp = (app: AiApp) => {
isEditMode.value = true
currentApp.value = { ...app }
showSettingModal.value = true
}
```
### 3. 自动加载模型数据
弹窗打开时,会自动调用 `loadModelOptions()` 函数加载AI模型数据
```typescript
onMounted(() => {
loadModelOptions() // 自动加载模型选项
loadFlowOptions()
loadSelectedKnowledges()
})
```
### 4. 显示模型选择器
在"模型配置"选项卡中用户可以看到从后端加载的真实AI模型列表
- OpenAI
- deepseek
- 智谱
- 测试
## 测试方法
### 1. 访问测试页面
```
http://localhost:5173/ai-model-test
```
### 2. 测试功能
- 点击"加载AI模型列表"按钮
- 查看加载状态和结果
- 测试选择器功能
- 查看原始API响应数据
### 3. 验证AI应用设置
- 访问 `/ai/app` 页面
- 点击任意AI应用卡片
- 在弹窗中切换到"模型配置"选项卡
- 验证AI模型下拉选择器是否显示真实数据
## 错误处理
系统包含完整的错误处理机制:
1. **网络请求失败**: 显示错误消息并使用备用数据
2. **API响应错误**: 解析错误信息并提示用户
3. **数据格式错误**: 容错处理,确保界面正常显示
4. **加载状态**: 显示加载指示器,提升用户体验
## 数据映射
API返回的字典项会被映射为选择器选项
```typescript
// API数据格式
{
"value": "1890232564262739969",
"text": "OpenAI",
"label": "OpenAI",
"title": "OpenAI"
}
// 映射为选择器选项
{
label: "OpenAI", // 显示文本
value: "1890232564262739969" // 选择值
}
```
## 扩展说明
### 添加其他字典类型
可以在 `SystemApi` 中添加其他字典类型的获取方法:
```typescript
// 获取其他类型的字典
getOtherDict(): Promise<ApiResponse<DictItem[]>> {
return request.get('/sys/dict/getDictItems/other_dict_code')
}
```
### 自定义字典查询
使用通用的 `getDictItems` 方法:
```typescript
// 自定义查询条件
const response = await SystemApi.getDictItems('dict_code', 'custom_params')
```
这样就完成了AI模型字典API的集成用户在AI应用设置弹窗中可以看到真实的AI模型数据了

197
docs/dynamic-menu-routes.md Normal file
View File

@ -0,0 +1,197 @@
# 动态菜单路由系统
## 概述
本系统实现了基于后端接口的动态菜单路由加载功能,支持从后端获取菜单配置并自动生成前端路由。
## 接口说明
### 获取首页菜单
- **接口**: `/aiol/aiolMenu/getIndexMenus`
- **方法**: GET
- **返回格式**:
```json
{
"success": true,
"message": "",
"code": 200,
"result": [
{
"id": "1",
"name": "首页",
"path": "/",
"type": "index",
"icon": null,
"parentId": null,
"sortOrder": 99,
"izVisible": 1,
"permissionKey": null
}
],
"timestamp": 1759134778382
}
```
### 获取学生菜单
- **接口**: `/aiol/aiolMenu/getStudentMenus`
- **方法**: GET
- **返回格式**: 同上type为"student"
## 核心文件
### 1. API模块 (`src/api/modules/menu.ts`)
```typescript
export interface MenuItem {
id: string
name: string
path: string
type: string
icon: string | null
parentId: string | null
sortOrder: number
izVisible: number
permissionKey: string | null
}
export class MenuApi {
static async getIndexMenus(): Promise<ApiResponse<MenuItem[]>>
static async getStudentMenus(): Promise<ApiResponse<MenuItem[]>>
}
```
### 2. 路由工具 (`src/utils/routeUtils.ts`)
```typescript
// 根据菜单数据生成路由配置
export function generateRoutesFromMenus(menus: MenuItem[]): RouteRecordRaw[]
// 生成导航菜单数据
export function generateNavMenus(menus: MenuItem[])
// 检查路由是否存在于菜单中
export function isRouteInMenus(path: string, menus: MenuItem[]): boolean
```
### 3. 状态管理 (`src/stores/menu.ts`)
```typescript
export const useMenuStore = defineStore('menu', () => {
// 状态
const indexMenus = ref<MenuItem[]>([])
const studentMenus = ref<MenuItem[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// 计算属性
const navMenus = computed(() => generateNavMenus(indexMenus.value))
const visibleIndexMenus = computed(() => ...)
// 方法
const fetchIndexMenus = async () => { ... }
const fetchStudentMenus = async () => { ... }
})
```
### 4. 路由配置 (`src/router/index.ts`)
在路由守卫中自动加载动态菜单:
```typescript
router.beforeEach(async (to, from, next) => {
// 加载动态菜单路由(仅在首次访问时)
if (!isMenuLoaded) {
await loadMenuRoutes()
}
// ...
})
```
## 使用方法
### 1. 在组件中使用菜单状态
```vue
<script setup lang="ts">
import { useMenuStore } from '@/stores/menu'
const menuStore = useMenuStore()
// 获取菜单数据
await menuStore.fetchIndexMenus()
// 使用菜单数据
console.log(menuStore.navMenus)
</script>
```
### 2. 使用动态导航组件
```vue
<template>
<DynamicNavigation />
</template>
<script setup lang="ts">
import DynamicNavigation from '@/components/layout/DynamicNavigation.vue'
</script>
```
### 3. 检查路由权限
```typescript
import { useMenuStore } from '@/stores/menu'
const menuStore = useMenuStore()
// 检查路由是否可见
const isVisible = menuStore.isRouteVisible('/courses', 'index')
```
## 路径映射
当前支持的路径映射:
| 菜单名称 | 路径 | 组件 |
|---------|------|------|
| 首页 | / | Home |
| 热门好课 | /courses | Courses |
| 专题训练 | /special-training | SpecialTraining |
| 师资力量 | /faculty | Faculty |
| 精选资源 | /resources | Resources |
| 活动 | /activities | Activities |
| AI体验 | /ai/app | AiAppList |
## 测试页面
访问 `/menu-test` 可以查看动态菜单系统的测试页面,包含:
- 菜单状态监控
- 菜单数据展示
- 路由跳转测试
- 动态导航组件演示
## 响应式设计
动态导航组件支持响应式设计:
- 桌面端:垂直菜单布局
- 移动端:水平滚动菜单布局
## 错误处理
系统包含完整的错误处理机制:
- 网络请求失败重试
- 加载状态显示
- 错误信息提示
- 手动重试功能
## 扩展说明
### 添加新的路径映射
`src/utils/routeUtils.ts` 中的 `componentMap` 添加新的路径映射:
```typescript
const componentMap: Record<string, () => Promise<any>> = {
'/new-path': () => import('@/views/NewComponent.vue'),
// ...
}
```
### 自定义菜单图标
`DynamicNavigation.vue` 中的 `iconMap` 添加新的图标映射:
```typescript
const iconMap: Record<string, any> = {
'new-icon': NewIconComponent,
// ...
}
```

217
package-lock.json generated
View File

@ -18,6 +18,7 @@
"ckplayer": "^3.1.2",
"dplayer": "^1.27.1",
"echarts": "5.6.0",
"marked": "^16.4.0",
"naive-ui": "^2.42.0",
"naive-ui-editor": "^1.0.6",
"pinia": "^3.0.3",
@ -33,6 +34,7 @@
"@types/dplayer": "^1.25.5",
"@types/node": "^24.0.15",
"@vitejs/plugin-vue": "^6.0.0",
"less": "^4.4.2",
"sass-embedded": "^1.93.2",
"typescript": "^5.8.3",
"vite": "^7.0.0",
@ -2909,6 +2911,20 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/errno": {
"version": "0.1.8",
"resolved": "https://registry.npmmirror.com/errno/-/errno-0.1.8.tgz",
"integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"prr": "~1.0.1"
},
"bin": {
"errno": "cli.js"
}
},
"node_modules/error-stack-parser-es": {
"version": "0.1.5",
"resolved": "https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-0.1.5.tgz",
@ -3474,6 +3490,34 @@
"@babel/runtime": "^7.12.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/image-size": {
"version": "0.5.5",
"resolved": "https://registry.npmmirror.com/image-size/-/image-size-0.5.5.tgz",
"integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==",
"dev": true,
"license": "MIT",
"optional": true,
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz",
@ -3760,6 +3804,53 @@
"dev": true,
"license": "MIT"
},
"node_modules/less": {
"version": "4.4.2",
"resolved": "https://registry.npmmirror.com/less/-/less-4.4.2.tgz",
"integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
"tslib": "^2.3.0"
},
"bin": {
"lessc": "bin/lessc"
},
"engines": {
"node": ">=14"
},
"optionalDependencies": {
"errno": "^0.1.1",
"graceful-fs": "^4.1.2",
"image-size": "~0.5.0",
"make-dir": "^2.1.0",
"mime": "^1.4.1",
"needle": "^3.1.0",
"source-map": "~0.6.0"
}
},
"node_modules/less/node_modules/copy-anything": {
"version": "2.0.6",
"resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-2.0.6.tgz",
"integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-what": "^3.14.1"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/less/node_modules/is-what": {
"version": "3.14.1",
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-3.14.1.tgz",
"integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
@ -3834,6 +3925,44 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-2.1.0.tgz",
"integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"pify": "^4.0.1",
"semver": "^5.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmmirror.com/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/marked": {
"version": "16.4.0",
"resolved": "https://registry.npmmirror.com/marked/-/marked-16.4.0.tgz",
"integrity": "sha512-CTPAcRBq57cn3R8n3hwc2REddc28hjR7RzDXQ+lXLmMJYqn20BaI2cGw6QjgZGIgVfp2Wdfw4aMzgNteQ6qJgQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3872,6 +4001,20 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"license": "MIT",
"optional": true,
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
@ -4000,6 +4143,24 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/needle": {
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/needle/-/needle-3.3.1.tgz",
"integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.3",
"sax": "^1.2.4"
},
"bin": {
"needle": "bin/needle"
},
"engines": {
"node": ">= 4.4.x"
}
},
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz",
@ -4123,6 +4284,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-node-version": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/parse-node-version/-/parse-node-version-1.0.1.tgz",
"integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@ -4172,6 +4343,17 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/pinia": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.3.tgz",
@ -4268,6 +4450,14 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/prr": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/prr/-/prr-1.0.1.tgz",
"integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/quill/-/quill-2.0.3.tgz",
@ -4401,6 +4591,14 @@
"tslib": "^2.1.0"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmmirror.com/sass/-/sass-1.93.2.tgz",
@ -4772,6 +4970,14 @@
"node": ">=14.0.0"
}
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
"dev": true,
"license": "ISC",
"optional": true
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
@ -4918,6 +5124,17 @@
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@ -23,6 +23,7 @@
"ckplayer": "^3.1.2",
"dplayer": "^1.27.1",
"echarts": "5.6.0",
"marked": "^16.4.0",
"naive-ui": "^2.42.0",
"naive-ui-editor": "^1.0.6",
"pinia": "^3.0.3",
@ -38,6 +39,7 @@
"@types/dplayer": "^1.25.5",
"@types/node": "^24.0.15",
"@vitejs/plugin-vue": "^6.0.0",
"less": "^4.4.2",
"sass-embedded": "^1.93.2",
"typescript": "^5.8.3",
"vite": "^7.0.0",

View File

@ -0,0 +1,540 @@
<template>
<div class="knowledge-container">
<!-- 查询区域 -->
<n-card class="search-card" :bordered="false">
<n-form
ref="searchFormRef"
:model="searchForm"
label-placement="left"
:label-width="80"
class="search-form"
>
<n-grid :cols="24" :x-gap="16">
<n-form-item-gi :span="6" label="知识库名称" path="name">
<n-input
v-model:value="searchForm.name"
placeholder="请输入知识库名称"
clearable
@keyup.enter="handleSearch"
/>
</n-form-item-gi>
<n-form-item-gi :span="6" label="创建人" path="createBy">
<n-input
v-model:value="searchForm.createBy"
placeholder="请输入创建人"
clearable
@keyup.enter="handleSearch"
/>
</n-form-item-gi>
<n-form-item-gi :span="6">
<n-space>
<n-button type="primary" @click="handleSearch">
<template #icon>
<n-icon><SearchOutlined /></n-icon>
</template>
查询
</n-button>
<n-button @click="handleReset">
<template #icon>
<n-icon><ReloadOutlined /></n-icon>
</template>
重置
</n-button>
</n-space>
</n-form-item-gi>
</n-grid>
</n-form>
</n-card>
<!-- 知识库卡片区域 -->
<n-card class="knowledge-cards-container" :bordered="false">
<n-grid :cols="6" :x-gap="20" :y-gap="20" responsive="screen">
<!-- 创建知识库卡片 -->
<n-grid-item>
<n-card
class="add-knowledge-card"
hoverable
@click="handleAddKnowledge"
>
<div class="add-knowledge-content">
<n-icon size="24" class="add-icon">
<PlusOutlined />
</n-icon>
<span class="add-text">创建知识库</span>
</div>
</n-card>
</n-grid-item>
<!-- 知识库列表卡片 -->
<n-grid-item v-for="item in knowledgeList" :key="item.id">
<n-card
class="knowledge-card"
hoverable
@click="handleDocClick(item.id)"
>
<div class="knowledge-header">
<div class="header-content">
<img class="header-img" src="/images/ai/ai.png" alt="知识库" />
<div class="header-text">
<div class="knowledge-name">{{ item.name }}</div>
<div class="knowledge-desc">{{ item.descr || '暂无描述' }}</div>
</div>
</div>
<n-dropdown
trigger="click"
:options="getDropdownOptions(item)"
@select="(key: string) => handleDropdownSelect(key, item)"
>
<n-button quaternary circle size="small">
<template #icon>
<n-icon><MoreOutlined /></n-icon>
</template>
</n-button>
</n-dropdown>
</div>
<div class="knowledge-footer">
<div class="knowledge-meta">
<n-tag size="small" type="info">
文档: {{ item.docCount || 0 }}
</n-tag>
<n-tag size="small" type="success">
向量: {{ item.vectorCount || 0 }}
</n-tag>
</div>
<div class="knowledge-creator">
创建者: {{ item.createBy_dictText || item.createBy }}
</div>
<div class="knowledge-time">
{{ formatDate(item.createTime || '') }}
</div>
</div>
</n-card>
</n-grid-item>
</n-grid>
<!-- 分页 -->
<div class="pagination-container" v-if="pagination.total > 0">
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 30, 50]"
show-size-picker
show-quick-jumper
:show-total="(total: number) => `共 ${total} 条`"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</n-card>
<!-- 知识库表单弹窗 -->
<KnowledgeBaseModal
v-model:show="showKnowledgeModal"
:knowledge-data="currentKnowledge"
:is-edit="isEditMode"
@success="handleModalSuccess"
/>
<!-- 知识库文档弹窗 -->
<KnowledgeDocListModal
v-model:show="showDocModal"
:knowledge-id="currentKnowledgeId"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'
import { useMessage, useDialog, NIcon } from 'naive-ui'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
SyncOutlined
} from '@vicons/antd'
import {
getKnowledgeList,
deleteKnowledge,
rebuildKnowledgeVector
} from './api/knowledge'
import type {
KnowledgeBase,
KnowledgeSearchForm,
Pagination
} from './types/knowledge'
import { formatDate } from './utils/common'
import KnowledgeBaseModal from './components/KnowledgeBaseModal.vue'
import KnowledgeDocListModal from './components/KnowledgeDocListModal.vue'
//
const searchFormRef = ref()
//
const message = useMessage()
const dialog = useDialog()
//
const knowledgeList = ref<KnowledgeBase[]>([])
const searchForm = reactive<KnowledgeSearchForm>({
name: '',
createBy: ''
})
const pagination = reactive<Pagination>({
page: 1,
pageSize: 10,
total: 0
})
//
const showKnowledgeModal = ref(false)
const showDocModal = ref(false)
const isEditMode = ref(false)
const currentKnowledge = ref<Partial<KnowledgeBase>>({})
const currentKnowledgeId = ref('')
//
const loading = ref(false)
//
const getDropdownOptions = (_item: KnowledgeBase) => [
{
label: '查看文档',
key: 'view',
icon: () => h(NIcon, null, { default: () => h(EyeOutlined) })
},
{
label: '编辑',
key: 'edit',
icon: () => h(NIcon, null, { default: () => h(EditOutlined) })
},
{
label: '向量化',
key: 'rebuild',
icon: () => h(NIcon, null, { default: () => h(SyncOutlined) })
},
{
label: '删除',
key: 'delete',
icon: () => h(NIcon, null, { default: () => h(DeleteOutlined) }),
props: {
style: 'color: #e74c3c;'
}
}
]
//
onMounted(() => {
loadKnowledgeList()
})
//
const loadKnowledgeList = async () => {
try {
loading.value = true
const params = {
...searchForm,
pageNo: pagination.page,
pageSize: pagination.pageSize
}
const response = await getKnowledgeList(params)
if (response.success) {
knowledgeList.value = response.result.records || []
pagination.total = response.result.total || 0
} else {
message.error(response.message || '获取知识库列表失败')
}
} catch (error) {
message.error('获取知识库列表失败')
console.error('Load knowledge list error:', error)
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.page = 1
loadKnowledgeList()
}
//
const handleReset = () => {
searchForm.name = ''
searchForm.createBy = ''
pagination.page = 1
loadKnowledgeList()
}
//
const handlePageChange = (page: number) => {
pagination.page = page
loadKnowledgeList()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
pagination.page = 1
loadKnowledgeList()
}
//
const handleAddKnowledge = () => {
isEditMode.value = false
currentKnowledge.value = {}
showKnowledgeModal.value = true
}
//
const handleDocClick = (knowledgeId: string) => {
currentKnowledgeId.value = knowledgeId
showDocModal.value = true
}
//
const handleDropdownSelect = (key: string, item: KnowledgeBase) => {
switch (key) {
case 'view':
handleDocClick(item.id)
break
case 'edit':
handleEdit(item)
break
case 'rebuild':
handleRebuild(item)
break
case 'delete':
handleDelete(item)
break
}
}
//
const handleEdit = (item: KnowledgeBase) => {
isEditMode.value = true
currentKnowledge.value = { ...item }
showKnowledgeModal.value = true
}
//
const handleDelete = (item: KnowledgeBase) => {
dialog.warning({
title: '确认删除',
content: `是否删除名称为"${item.name}"的知识库?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
try {
await deleteKnowledge({ id: item.id })
message.success('删除成功')
loadKnowledgeList()
} catch (error) {
message.error('删除失败')
}
}
})
}
//
const handleRebuild = (item: KnowledgeBase) => {
dialog.info({
title: '确认向量化',
content: `是否对知识库"${item.name}"进行向量化?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
try {
const response = await rebuildKnowledgeVector({ knowIds: item.id })
if (response.success) {
message.success('向量化成功')
loadKnowledgeList()
} else {
message.warning('向量化失败')
}
} catch (error) {
message.warning('向量化失败')
}
}
})
}
//
const handleModalSuccess = () => {
loadKnowledgeList()
}
</script>
<style scoped lang="less">
.knowledge-container {
padding: 16px;
background-color: #f5f5f5;
min-height: 100vh;
.search-card {
margin-bottom: 16px;
.search-form {
.n-form-item {
margin-bottom: 0;
}
}
}
.knowledge-cards-container {
.n-grid {
min-height: 400px;
}
.add-knowledge-card {
height: 200px;
border: 2px dashed #d9d9d9;
background-color: #fafafa;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
background-color: #f0f8ff;
}
.add-knowledge-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
.add-icon {
margin-bottom: 8px;
color: #1890ff;
}
.add-text {
font-size: 14px;
font-weight: 500;
}
}
}
.knowledge-card {
height: 200px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.knowledge-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
.header-content {
display: flex;
align-items: center;
flex: 1;
.header-img {
width: 40px;
height: 40px;
margin-right: 12px;
border-radius: 6px;
}
.header-text {
flex: 1;
min-width: 0;
.knowledge-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.knowledge-desc {
font-size: 12px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.knowledge-footer {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
.knowledge-meta {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.knowledge-creator {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.knowledge-time {
font-size: 12px;
color: #999;
}
}
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 24px;
padding: 16px 0;
}
}
}
//
@media (max-width: 1200px) {
.knowledge-container .knowledge-cards-container .n-grid {
grid-template-columns: repeat(4, 1fr) !important;
}
}
@media (max-width: 992px) {
.knowledge-container .knowledge-cards-container .n-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
}
@media (max-width: 768px) {
.knowledge-container .knowledge-cards-container .n-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
}
@media (max-width: 576px) {
.knowledge-container .knowledge-cards-container .n-grid {
grid-template-columns: 1fr !important;
}
}
</style>

View File

@ -0,0 +1,314 @@
# 部署指南
## 快速开始
### 1. 环境要求
- Node.js >= 16.0.0
- npm >= 8.0.0 或 yarn >= 1.22.0 或 pnpm >= 7.0.0
### 2. 安装依赖
```bash
# 使用 npm
npm install
# 使用 yarn
yarn install
# 使用 pnpm
pnpm install
```
### 3. 环境配置
创建 `.env` 文件:
```env
# API基础地址
VITE_API_BASE_URL=http://localhost:8080/api
# 上传文件地址
VITE_UPLOAD_URL=http://localhost:8080/api/sys/common/upload
# 静态资源地址
VITE_STATIC_URL=http://localhost:8080/api/sys/common/static
```
### 4. 开发模式
```bash
npm run dev
```
访问 http://localhost:3000
### 5. 生产构建
```bash
npm run build
```
构建产物在 `dist` 目录中。
## 集成到现有项目
### 1. 作为组件库使用
```bash
npm install ai-knowledge-naive-ui
```
```vue
<template>
<div>
<AiKnowledgeBaseList />
</div>
</template>
<script setup lang="ts">
import { AiKnowledgeBaseList } from 'ai-knowledge-naive-ui'
</script>
```
### 2. 直接复制源码
`ai-knowledge-naive-ui` 目录复制到你的项目中,然后:
```vue
<template>
<div>
<AiKnowledgeBaseList />
</div>
</template>
<script setup lang="ts">
import AiKnowledgeBaseList from './ai-knowledge-naive-ui/AiKnowledgeBaseList.vue'
</script>
```
## 配置说明
### HTTP请求配置
修改 `utils/http.ts` 中的配置:
```typescript
export const http = new HttpClient({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 60000,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
```
### 认证配置
如果你的API需要认证修改 `utils/http.ts` 中的请求拦截器:
```typescript
// 请求拦截器
this.instance.interceptors.request.use(
(config) => {
// 添加token等认证信息
const token = localStorage.getItem('token')
// 或者从你的状态管理中获取
// const token = useAuthStore().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
```
### 路由配置
如果你使用Vue Router可以这样配置路由
```typescript
import { createRouter, createWebHistory } from 'vue-router'
import AiKnowledgeBaseList from './ai-knowledge-naive-ui/AiKnowledgeBaseList.vue'
const routes = [
{
path: '/knowledge',
name: 'Knowledge',
component: AiKnowledgeBaseList,
meta: {
title: 'AI知识库管理'
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
```
## 主题配置
### 使用Naive UI主题
```vue
<template>
<n-config-provider :theme="theme">
<AiKnowledgeBaseList />
</n-config-provider>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { darkTheme } from 'naive-ui'
const isDark = ref(false)
const theme = computed(() => isDark.value ? darkTheme : null)
</script>
```
### 自定义主题
```vue
<template>
<n-config-provider :theme-overrides="themeOverrides">
<AiKnowledgeBaseList />
</n-config-provider>
</template>
<script setup lang="ts">
import type { GlobalThemeOverrides } from 'naive-ui'
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#1890ff',
primaryColorHover: '#40a9ff',
primaryColorPressed: '#096dd9'
}
}
</script>
```
## 权限控制
### 路由守卫
```typescript
router.beforeEach((to, from, next) => {
// 检查用户是否有访问知识库的权限
const hasPermission = checkPermission('knowledge:view')
if (to.path.startsWith('/knowledge') && !hasPermission) {
next('/403')
} else {
next()
}
})
```
### 组件级权限
```vue
<template>
<div v-if="hasKnowledgePermission">
<AiKnowledgeBaseList />
</div>
<div v-else>
<n-result status="403" title="无权限访问" description="您没有访问AI知识库的权限" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const hasKnowledgePermission = computed(() =>
authStore.hasPermission('knowledge:view')
)
</script>
```
## 性能优化
### 懒加载
```typescript
// 路由懒加载
const routes = [
{
path: '/knowledge',
name: 'Knowledge',
component: () => import('./ai-knowledge-naive-ui/AiKnowledgeBaseList.vue')
}
]
```
### 组件懒加载
```vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const AiKnowledgeBaseList = defineAsyncComponent(
() => import('./ai-knowledge-naive-ui/AiKnowledgeBaseList.vue')
)
</script>
```
## 故障排除
### 常见问题
1. **API请求失败**
- 检查 `.env` 文件中的API地址配置
- 确认后端服务是否正常运行
- 检查网络连接和CORS配置
2. **组件样式异常**
- 确认已正确引入Naive UI的样式
- 检查CSS预处理器配置
- 确认没有样式冲突
3. **TypeScript类型错误**
- 确认已安装所有必要的类型定义包
- 检查tsconfig.json配置
- 更新依赖包到最新版本
4. **文件上传失败**
- 检查上传接口地址配置
- 确认文件大小和类型限制
- 检查服务器上传配置
### 调试技巧
1. 开启开发者工具的网络面板查看API请求
2. 使用Vue DevTools检查组件状态
3. 查看浏览器控制台的错误信息
4. 使用断点调试JavaScript代码
## 更新升级
### 版本更新
```bash
# 检查可更新的包
npm outdated
# 更新依赖
npm update
# 更新到最新版本
npm install ai-knowledge-naive-ui@latest
```
### 迁移指南
当有重大版本更新时请查看CHANGELOG.md文件了解变更内容和迁移步骤。

View File

@ -0,0 +1,186 @@
# AI知识库管理系统 - Naive UI版本
这是将原有的Ant Design Vue + JeecgBoot项目中的AI知识库管理功能转换为Naive UI + TypeScript + Vue3版本的实现。
## 📁 文件结构
```
ai-knowledge-naive-ui/
├── AiKnowledgeBaseList.vue # 主页面组件 - 知识库列表
├── api/
│ └── knowledge.ts # AI知识库相关API
├── components/
│ ├── KnowledgeBaseModal.vue # 知识库表单组件
│ ├── KnowledgeDocListModal.vue # 知识库文档列表组件
│ ├── KnowledgeDocTextModal.vue # 文档编辑组件
│ └── TextDescModal.vue # 文本详情组件
├── types/
│ └── knowledge.ts # 类型定义
├── utils/
│ ├── http.ts # HTTP请求工具
│ ├── clipboard.ts # 剪贴板工具
│ ├── file.ts # 文件处理工具
│ └── common.ts # 通用工具函数
└── README.md # 说明文档
```
## 🔄 组件转换对照
### 原组件 → 新组件
| 原组件 | 新组件 | 说明 |
|--------|--------|------|
| `a-card` | `n-card` | 卡片组件 |
| `a-form` | `n-form` | 表单组件 |
| `a-input` | `n-input` | 输入框组件 |
| `a-select` | `n-select` | 选择器组件 |
| `a-button` | `n-button` | 按钮组件 |
| `a-modal` | `n-modal` | 弹窗组件 |
| `a-pagination` | `n-pagination` | 分页组件 |
| `a-tag` | `n-tag` | 标签组件 |
| `a-tooltip` | `n-tooltip` | 提示组件 |
| `a-dropdown` | `n-dropdown` | 下拉菜单组件 |
| `a-upload` | `n-upload` | 上传组件 |
| `a-table` | `n-data-table` | 表格组件 |
| `a-layout` | `n-layout` | 布局组件 |
| `a-menu` | `n-menu` | 菜单组件 |
## 🚀 使用方法
### 1. 安装依赖
```bash
npm install naive-ui @vicons/antd marked
# 或
yarn add naive-ui @vicons/antd marked
# 或
pnpm add naive-ui @vicons/antd marked
```
### 2. 配置Naive UI
在你的主应用中配置Naive UI
```typescript
// main.ts
import { createApp } from 'vue'
import naive from 'naive-ui'
import App from './App.vue'
const app = createApp(App)
app.use(naive)
app.mount('#app')
```
### 3. 使用组件
```vue
<template>
<AiKnowledgeBaseList />
</template>
<script setup lang="ts">
import AiKnowledgeBaseList from './ai-knowledge-naive-ui/AiKnowledgeBaseList.vue'
</script>
```
## 🔧 配置说明
### HTTP请求配置
`utils/http.ts` 中配置你的API基础地址
```typescript
const http = new HttpClient({
baseURL: 'http://your-api-domain.com/api', // 修改为你的API地址
timeout: 60000
})
```
### 环境变量配置
创建 `.env` 文件:
```env
VITE_API_BASE_URL=http://localhost:8080/api
```
## 📋 功能特性
### 知识库管理
- ✅ 知识库列表展示(卡片式布局)
- ✅ 创建/编辑知识库
- ✅ 删除知识库(带确认)
- ✅ 知识库向量化
- ✅ 搜索和分页
### 文档管理
- ✅ 文档列表展示(表格形式)
- ✅ 支持文本、文件、URL三种文档类型
- ✅ 文档编辑和删除
- ✅ 批量操作(删除、向量化)
- ✅ 清空所有文档
- ✅ 文档搜索
### 向量化测试
- ✅ 向量化命中测试
- ✅ 测试结果展示
- ✅ 结果详情查看
### 文件上传
- ✅ 拖拽上传
- ✅ 文件类型验证
- ✅ 文件大小限制
- ✅ 上传进度显示
## 🎨 样式特性
- 响应式设计,支持多种屏幕尺寸
- 现代化的卡片式布局
- 优雅的动画效果
- 一致的视觉风格
- 深色/浅色主题支持Naive UI内置
## 🔌 API接口
### 知识库相关
- `GET /airag/knowledge/list` - 获取知识库列表
- `POST /airag/knowledge/add` - 创建知识库
- `PUT /airag/knowledge/edit` - 编辑知识库
- `DELETE /airag/knowledge/delete` - 删除知识库
- `PUT /airag/knowledge/rebuild` - 知识库向量化
### 文档相关
- `GET /airag/knowledge/doc/list` - 获取文档列表
- `POST /airag/knowledge/doc/edit` - 创建/编辑文档
- `DELETE /airag/knowledge/doc/deleteBatch` - 批量删除文档
- `DELETE /airag/knowledge/doc/deleteAll` - 清空所有文档
- `PUT /airag/knowledge/doc/rebuild` - 文档向量化
- `POST /airag/knowledge/embedding/hitTest` - 向量化测试
## 🐛 注意事项
1. **图标引用**: 使用`@vicons/antd`包中的图标,需要按需引入
2. **样式覆盖**: 使用`:deep()`选择器来覆盖组件内部样式
3. **类型导入**: 确保正确导入Naive UI的类型定义
4. **API地址**: 根据实际情况修改API基础地址
5. **权限控制**: 根据需要添加路由守卫和权限验证
6. **文件上传**: 需要配置正确的上传接口地址和认证信息
## 📝 待完善功能
- [ ] 国际化支持
- [ ] 主题切换
- [ ] 更多文件类型支持
- [ ] 导入导出功能
- [ ] 知识库模板功能
- [ ] 实时向量化进度显示
- [ ] 文档预览功能
## 🤝 贡献
欢迎提交Issue和Pull Request来改进这个项目
## 📄 许可证
MIT License

View File

@ -0,0 +1,193 @@
import { defHttp } from '../utils/http'
import type {
KnowledgeBase,
KnowledgeDoc,
KnowledgeSearchForm,
KnowledgeDocSearchForm,
EmbeddingHitResult,
RebuildVectorParams
} from '../types/knowledge'
// API 端点枚举
enum Api {
// 知识库管理
list = '/airag/knowledge/list',
save = '/airag/knowledge/add',
delete = '/airag/knowledge/delete',
queryById = '/airag/knowledge/queryById',
edit = '/airag/knowledge/edit',
rebuild = '/airag/knowledge/rebuild',
// 知识库文档
knowledgeDocList = '/airag/knowledge/doc/list',
knowledgeEditDoc = '/airag/knowledge/doc/edit',
knowledgeDeleteBatchDoc = '/airag/knowledge/doc/deleteBatch',
knowledgeDeleteAllDoc = '/airag/knowledge/doc/deleteAll',
knowledgeRebuildDoc = '/airag/knowledge/doc/rebuild',
knowledgeEmbeddingHitTest = '/airag/knowledge/embedding/hitTest',
}
/**
*
* @param params
*/
export const getKnowledgeList = (params: KnowledgeSearchForm & { pageNo?: number; pageSize?: number }) => {
return defHttp.get<{
records: KnowledgeBase[]
total: number
size: number
current: number
}>({
url: Api.list,
params
}, { isTransformResponse: false })
}
/**
* ID查询知识库详情
* @param params
*/
export const getKnowledgeById = (params: { id: string }) => {
return defHttp.get<KnowledgeBase>({
url: Api.queryById,
params
}, { isTransformResponse: false })
}
/**
*
* @param params
*/
export const createKnowledge = (params: Omit<KnowledgeBase, 'id'>) => {
return defHttp.post<KnowledgeBase>({
url: Api.save,
params
})
}
/**
*
* @param params
*/
export const updateKnowledge = (params: KnowledgeBase) => {
return defHttp.put<KnowledgeBase>({
url: Api.edit,
params
})
}
/**
*
* @param params
*/
export const deleteKnowledge = (params: { id: string }) => {
return defHttp.delete({
url: Api.delete,
params
}, { joinParamsToUrl: true })
}
/**
*
* @param params
*/
export const rebuildKnowledgeVector = (params: RebuildVectorParams) => {
return defHttp.put({
url: Api.rebuild,
params,
timeout: 2 * 60 * 1000
}, {
joinParamsToUrl: true,
isTransformResponse: false
})
}
/**
*
* @param params
*/
export const getKnowledgeDocList = (params: KnowledgeDocSearchForm & {
knowledgeId: string
pageNo?: number
pageSize?: number
}) => {
return defHttp.get<{
records: KnowledgeDoc[]
total: number
size: number
current: number
}>({
url: Api.knowledgeDocList,
params
}, { isTransformResponse: false })
}
/**
* /
* @param params
*/
export const saveKnowledgeDoc = (params: Partial<KnowledgeDoc>) => {
return defHttp.post<KnowledgeDoc>({
url: Api.knowledgeEditDoc,
params
})
}
/**
*
* @param params
*/
export const batchDeleteKnowledgeDocs = (params: { ids: string }) => {
return defHttp.delete({
url: Api.knowledgeDeleteBatchDoc,
params
}, { joinParamsToUrl: true })
}
/**
*
* @param knowledgeId ID
*/
export const deleteAllKnowledgeDocs = (knowledgeId: string) => {
return defHttp.delete({
url: Api.knowledgeDeleteAllDoc,
params: { knowledgeId }
}, { joinParamsToUrl: true })
}
/**
*
* @param params
*/
export const rebuildDocVector = (params: { docIds: string }) => {
return defHttp.put({
url: Api.knowledgeRebuildDoc,
params
}, { joinParamsToUrl: true })
}
/**
*
* @param params
*/
export const embeddingHitTest = (params: {
knowledgeId: string
query: string
topK?: number
}) => {
return defHttp.post<EmbeddingHitResult[]>({
url: Api.knowledgeEmbeddingHitTest,
params
})
}
/**
*
* @param params
*/
export const batchQueryKnowledgeById = (params: { ids: string }) => {
return defHttp.get<KnowledgeBase[]>({
url: '/airag/knowledge/query/batch/byId',
params
}, { isTransformResponse: false })
}

View File

@ -0,0 +1,244 @@
<template>
<n-modal
v-model:show="showModal"
preset="card"
:title="modalTitle"
style="width: 600px"
:mask-closable="false"
:closable="true"
@close="handleCancel"
>
<template #header-extra>
<n-tooltip trigger="hover">
<template #trigger>
<n-button
quaternary
circle
size="small"
@click="openHelpDoc"
>
<template #icon>
<n-icon><QuestionCircleOutlined /></n-icon>
</template>
</n-button>
</template>
AI知识库文档
</n-tooltip>
</template>
<n-form
ref="formRef"
:model="formData"
:rules="formRules"
label-placement="left"
:label-width="100"
require-mark-placement="right-hanging"
>
<n-form-item label="知识库名称" path="name">
<n-input
v-model:value="formData.name"
placeholder="请输入知识库名称"
:maxlength="64"
show-count
clearable
/>
</n-form-item>
<n-form-item label="知识库描述" path="descr">
<n-input
v-model:value="formData.descr"
type="textarea"
placeholder="描述知识库的内容详尽的描述将帮助AI能深入理解该知识库的内容能更准确的检索到内容提高该知识库的命中率。"
:maxlength="256"
show-count
:autosize="{ minRows: 3, maxRows: 6 }"
/>
</n-form-item>
<n-form-item label="向量模型" path="embedId">
<n-select
v-model:value="formData.embedId"
placeholder="请选择向量模型"
:options="embedModelOptions"
:loading="embedModelLoading"
clearable
filterable
/>
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="handleCancel">取消</n-button>
<n-button
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
{{ isEdit ? '更新' : '创建' }}
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useMessage } from 'naive-ui'
import { QuestionCircleOutlined } from '@vicons/antd'
import type { FormInst, FormRules, SelectOption } from 'naive-ui'
import {
createKnowledge,
updateKnowledge,
getKnowledgeById
} from '../api/knowledge'
import type { KnowledgeBase, KnowledgeFormData } from '../types/knowledge'
interface Props {
show: boolean
knowledgeData?: Partial<KnowledgeBase>
isEdit?: boolean
}
interface Emits {
(e: 'update:show', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
show: false,
isEdit: false,
knowledgeData: () => ({})
})
const emit = defineEmits<Emits>()
//
const formRef = ref<FormInst>()
//
const message = useMessage()
//
const formData = reactive<KnowledgeFormData>({
name: '',
descr: '',
embedId: ''
})
const submitLoading = ref(false)
const embedModelLoading = ref(false)
const embedModelOptions = ref<SelectOption[]>([])
//
const showModal = computed({
get: () => props.show,
set: (value) => emit('update:show', value)
})
const modalTitle = computed(() => props.isEdit ? '编辑知识库' : '创建知识库')
//
const formRules: FormRules = {
name: [
{ required: true, message: '请输入知识库名称', trigger: 'blur' },
{ min: 1, max: 64, message: '知识库名称长度应在1-64个字符之间', trigger: 'blur' }
],
descr: [
{ max: 256, message: '描述长度不能超过256个字符', trigger: 'blur' }
],
embedId: [
{ required: true, message: '请选择向量模型', trigger: 'change' }
]
}
//
watch(() => props.show, (newVal) => {
if (newVal) {
initForm()
loadEmbedModels()
}
})
//
const initForm = async () => {
if (props.isEdit && props.knowledgeData?.id) {
try {
const response = await getKnowledgeById({ id: props.knowledgeData.id })
if (response.success) {
Object.assign(formData, response.result)
}
} catch (error) {
message.error('获取知识库详情失败')
}
} else {
//
Object.assign(formData, {
name: '',
descr: '',
embedId: ''
})
}
}
//
const loadEmbedModels = async () => {
try {
embedModelLoading.value = true
// API
// 使
embedModelOptions.value = [
{ label: 'text-embedding-ada-002', value: '1' },
{ label: 'text-embedding-3-small', value: '2' },
{ label: 'text-embedding-3-large', value: '3' }
]
} catch (error) {
message.error('获取向量模型失败')
} finally {
embedModelLoading.value = false
}
}
//
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
if (props.isEdit) {
await updateKnowledge({ ...formData, id: props.knowledgeData?.id! })
message.success('更新成功')
} else {
await createKnowledge(formData)
message.success('创建成功')
}
showModal.value = false
emit('success')
} catch (error) {
if (error instanceof Error) {
message.error(error.message)
}
} finally {
submitLoading.value = false
}
}
//
const handleCancel = () => {
showModal.value = false
}
//
const openHelpDoc = () => {
window.open('https://help.jeecg.com/aigc/guide/knowledge', '_blank')
}
</script>
<style scoped lang="less">
.n-form {
.n-form-item {
margin-bottom: 20px;
}
}
</style>

View File

@ -0,0 +1,775 @@
<template>
<n-modal
v-model:show="showModal"
preset="card"
title="知识库文档管理"
style="width: 90vw; height: 80vh"
:mask-closable="false"
:closable="true"
@close="handleClose"
>
<div class="doc-list-container">
<n-layout has-sider style="height: 100%">
<!-- 左侧菜单 -->
<n-layout-sider
bordered
:width="200"
:collapsed-width="64"
show-trigger
collapse-mode="width"
>
<n-menu
v-model:value="selectedMenuKey"
:options="menuOptions"
@update:value="handleMenuSelect"
/>
</n-layout-sider>
<!-- 右侧内容区域 -->
<n-layout-content style="padding: 16px; overflow-y: auto; max-height: calc(80vh - 100px)">
<!-- 文档管理 -->
<div v-if="selectedMenuKey === 'document'">
<!-- 搜索和操作区域 -->
<div class="doc-header">
<n-space align="center" justify="space-between">
<n-space align="center">
<n-input
v-model:value="searchText"
placeholder="请输入文档名称,回车搜索"
style="width: 300px"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<n-icon><SearchOutlined /></n-icon>
</template>
</n-input>
<n-button type="primary" @click="handleSearch">
搜索
</n-button>
</n-space>
<n-space>
<n-button
v-if="selectedRows.length > 0"
type="error"
@click="handleBatchDelete"
>
<template #icon>
<n-icon><DeleteOutlined /></n-icon>
</template>
批量删除 ({{ selectedRows.length }})
</n-button>
<n-button
v-if="selectedRows.length > 0"
type="info"
@click="handleBatchRebuild"
>
<template #icon>
<n-icon><SyncOutlined /></n-icon>
</template>
批量向量化
</n-button>
<n-dropdown
trigger="click"
:options="addDocOptions"
@select="handleAddDoc"
>
<n-button type="primary">
<template #icon>
<n-icon><PlusOutlined /></n-icon>
</template>
添加文档
</n-button>
</n-dropdown>
<n-button
type="warning"
@click="handleClearAllDocs"
>
<template #icon>
<n-icon><ClearOutlined /></n-icon>
</template>
清空文档
</n-button>
</n-space>
</n-space>
</div>
<!-- 文档列表 - 卡片式布局 -->
<n-spin :show="docLoading">
<n-empty v-if="docList.length === 0" description="暂无文档" style="margin-top: 60px" />
<n-grid v-else :cols="5" :x-gap="16" :y-gap="16" style="margin-top: 16px">
<n-grid-item v-for="doc in docList" :key="doc.id">
<n-card
class="doc-card"
hoverable
:content-style="{ padding: '16px' }"
>
<!-- 文档图标和名称 -->
<div class="doc-card-header">
<n-icon size="40" color="#ff6b6b">
<FileTextOutlined />
</n-icon>
<div class="doc-name" :title="doc.title">{{ doc.title }}</div>
</div>
<!-- 文档状态 -->
<div class="doc-status">
<span>状态</span>
<n-tag
:type="getStatusType(doc.status)"
size="small"
>
{{ getStatusText(doc.status) }}
</n-tag>
</div>
<!-- 操作按钮 -->
<div class="doc-actions">
<n-space size="small">
<n-button
size="small"
type="primary"
quaternary
@click="handleEditDoc(doc)"
>
编辑
</n-button>
<n-button
size="small"
type="info"
quaternary
@click="handleRebuildDoc(doc)"
>
向量化
</n-button>
<n-button
size="small"
type="error"
quaternary
@click="handleDeleteDoc(doc)"
>
删除
</n-button>
</n-space>
</div>
</n-card>
</n-grid-item>
</n-grid>
<!-- 分页 -->
<div v-if="docList.length > 0" class="doc-pagination">
<n-pagination
v-model:page="docPagination.page"
v-model:page-size="docPagination.pageSize"
:total="docPagination.total"
:page-sizes="[10, 20, 30, 50]"
show-size-picker
show-quick-jumper
@update:page="handleDocPageChange"
@update:page-size="handleDocPageSizeChange"
/>
</div>
</n-spin>
</div>
<!-- 向量化测试 -->
<div v-else-if="selectedMenuKey === 'test'">
<div class="test-container">
<n-card title="向量化命中测试" :bordered="false">
<n-form
ref="testFormRef"
:model="testForm"
label-placement="top"
>
<n-form-item label="测试查询" path="query">
<n-input
v-model:value="testForm.query"
type="textarea"
placeholder="请输入要测试的查询内容"
:autosize="{ minRows: 3, maxRows: 6 }"
/>
</n-form-item>
<n-form-item label="返回数量" path="topK">
<n-input-number
v-model:value="testForm.topK"
:min="1"
:max="20"
placeholder="返回结果数量"
/>
</n-form-item>
<n-form-item>
<n-button
type="primary"
:loading="testLoading"
@click="handleEmbeddingTest"
>
开始测试
</n-button>
</n-form-item>
</n-form>
<!-- 测试结果 -->
<div v-if="testResults.length > 0" class="test-results">
<n-divider title-placement="left">测试结果</n-divider>
<n-list>
<n-list-item
v-for="(result, index) in testResults"
:key="index"
>
<n-card size="small" hoverable @click="handleViewResult(result)">
<template #header>
<n-space align="center" justify="space-between">
<n-tag type="info" size="small">
相似度: {{ result.score.toFixed(4) }}
</n-tag>
<n-tag size="small">
{{ result.source }}
</n-tag>
</n-space>
</template>
<n-ellipsis :line-clamp="3">
{{ result.content }}
</n-ellipsis>
</n-card>
</n-list-item>
</n-list>
</div>
</n-card>
</div>
</div>
</n-layout-content>
</n-layout>
</div>
<!-- 文档编辑弹窗 -->
<KnowledgeDocTextModal
v-model:show="showDocModal"
:knowledge-id="knowledgeId"
:doc-data="currentDoc"
:is-edit="isEditDoc"
@success="handleDocModalSuccess"
/>
<!-- 文本详情弹窗 -->
<TextDescModal
v-model:show="showTextModal"
:text-data="currentTextData"
/>
</n-modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, h } from 'vue'
import { useMessage, useDialog, NTag, NIcon, NButton, NSpin } from 'naive-ui'
import type { MenuOption } from 'naive-ui'
import {
SearchOutlined,
PlusOutlined,
DeleteOutlined,
SyncOutlined,
ClearOutlined,
FileTextOutlined,
UploadOutlined,
LinkOutlined,
ExperimentOutlined
} from '@vicons/antd'
import {
getKnowledgeDocList,
batchDeleteKnowledgeDocs,
deleteAllKnowledgeDocs,
rebuildDocVector,
embeddingHitTest
} from '../api/knowledge'
import type {
KnowledgeDoc,
EmbeddingHitResult
} from '../types/knowledge'
import KnowledgeDocTextModal from './KnowledgeDocTextModal.vue'
import TextDescModal from './TextDescModal.vue'
interface Props {
show: boolean
knowledgeId: string
}
interface Emits {
(e: 'update:show', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
//
const testFormRef = ref()
//
const message = useMessage()
const dialog = useDialog()
//
const docList = ref<KnowledgeDoc[]>([])
const searchText = ref('')
const docLoading = ref(false)
const selectedMenuKey = ref('document')
const checkedRowKeys = ref<string[]>([])
//
const testForm = reactive({
query: '',
topK: 5
})
const testLoading = ref(false)
const testResults = ref<EmbeddingHitResult[]>([])
//
const showDocModal = ref(false)
const showTextModal = ref(false)
const isEditDoc = ref(false)
const currentDoc = ref<Partial<KnowledgeDoc>>({})
const currentTextData = ref<any>({})
//
const docPagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
const tablePagination = reactive({
page: 1,
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 20, 50],
onChange: (page: number) => {
tablePagination.page = page
loadDocList()
},
onUpdatePageSize: (pageSize: number) => {
tablePagination.pageSize = pageSize
tablePagination.page = 1
loadDocList()
}
})
//
const showModal = computed({
get: () => props.show,
set: (value) => emit('update:show', value)
})
const selectedRows = computed(() =>
docList.value.filter(item => checkedRowKeys.value.includes(item.id))
)
//
const menuOptions: MenuOption[] = [
{
label: '文档管理',
key: 'document',
icon: () => h(NIcon, null, { default: () => h(FileTextOutlined) })
},
{
label: '向量化测试',
key: 'test',
icon: () => h(NIcon, null, { default: () => h(ExperimentOutlined) })
}
]
//
const addDocOptions = [
{
label: '文本文档',
key: 'text',
icon: () => h(NIcon, null, { default: () => h(FileTextOutlined) })
},
{
label: '上传文件',
key: 'file',
icon: () => h(NIcon, null, { default: () => h(UploadOutlined) })
},
{
label: '网页链接',
key: 'url',
icon: () => h(NIcon, null, { default: () => h(LinkOutlined) })
}
]
//
watch(() => props.show, (newVal) => {
if (newVal) {
selectedMenuKey.value = 'document'
loadDocList()
}
})
//
const loadDocList = async () => {
if (!props.knowledgeId) return
try {
docLoading.value = true
const params: any = {
pageNo: docPagination.page,
pageSize: docPagination.pageSize,
knowledgeId: props.knowledgeId,
column: 'createTime',
order: 'desc'
}
// title
if (searchText.value) {
params.title = searchText.value
}
const response = await getKnowledgeDocList(params)
console.log('📋 请求参数:', params)
console.log('📋 API 响应:', response)
console.log('📋 响应数据:', response.result)
console.log('📋 文档记录:', response.result?.records)
console.log('📋 记录数量:', response.result?.records?.length)
if (response.success) {
docList.value = response.result.records || []
docPagination.total = response.result.total || 0
console.log('✅ docList.value:', docList.value)
console.log('✅ docList 长度:', docList.value.length)
console.log('✅ 分页总数:', docPagination.total)
} else {
message.error(response.message || '获取文档列表失败')
}
} catch (error) {
message.error('获取文档列表失败')
console.error('Load doc list error:', error)
} finally {
docLoading.value = false
}
}
//
const handleSearch = () => {
docPagination.page = 1
tablePagination.page = 1
loadDocList()
}
//
const handleDocPageChange = (page: number) => {
docPagination.page = page
loadDocList()
}
const handleDocPageSizeChange = (pageSize: number) => {
docPagination.pageSize = pageSize
docPagination.page = 1
loadDocList()
}
//
const handleMenuSelect = (key: string) => {
selectedMenuKey.value = key
if (key === 'document') {
loadDocList()
}
}
//
const handleAddDoc = (key: string) => {
isEditDoc.value = false
currentDoc.value = { type: key as any }
showDocModal.value = true
}
//
const handleEditDoc = (doc: KnowledgeDoc) => {
isEditDoc.value = true
currentDoc.value = { ...doc }
showDocModal.value = true
}
//
const handleDeleteDoc = (doc: KnowledgeDoc) => {
dialog.warning({
title: '确认删除',
content: `是否删除文档"${doc.title}"`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
try {
await batchDeleteKnowledgeDocs({ ids: doc.id })
message.success('删除成功')
loadDocList()
} catch (error) {
message.error('删除失败')
}
}
})
}
//
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) return
dialog.warning({
title: '确认删除',
content: `是否删除选中的 ${selectedRows.value.length} 个文档?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
try {
const ids = selectedRows.value.map(item => item.id).join(',')
await batchDeleteKnowledgeDocs({ ids })
message.success('删除成功')
checkedRowKeys.value = []
loadDocList()
} catch (error) {
message.error('删除失败')
}
}
})
}
//
const handleRebuildDoc = (doc: KnowledgeDoc) => {
dialog.info({
title: '确认向量化',
content: `是否对文档"${doc.title}"进行向量化?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
try {
await rebuildDocVector({ docIds: doc.id })
message.success('向量化成功')
loadDocList()
} catch (error) {
message.error('向量化失败')
}
}
})
}
//
const handleBatchRebuild = () => {
if (selectedRows.value.length === 0) return
dialog.info({
title: '确认向量化',
content: `是否对选中的 ${selectedRows.value.length} 个文档进行向量化?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
try {
const ids = selectedRows.value.map(item => item.id).join(',')
await rebuildDocVector({ docIds: ids })
message.success('向量化成功')
checkedRowKeys.value = []
loadDocList()
} catch (error) {
message.error('向量化失败')
}
}
})
}
//
const handleClearAllDocs = () => {
dialog.error({
title: '清空文档',
content: '确定要清空所有文档吗?此操作会删除所有已录入的文档,并且不能恢复,请谨慎操作',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
try {
await deleteAllKnowledgeDocs(props.knowledgeId)
message.success('清空成功')
loadDocList()
} catch (error) {
message.error('清空失败')
}
}
})
}
//
const handleEmbeddingTest = async () => {
try {
await testFormRef.value?.validate()
testLoading.value = true
const response = await embeddingHitTest({
knowledgeId: props.knowledgeId,
query: testForm.query,
topK: testForm.topK
})
if (response.success) {
testResults.value = response.result || []
if (testResults.value.length === 0) {
message.info('未找到匹配的内容')
}
} else {
message.error(response.message || '测试失败')
}
} catch (error) {
message.error('测试失败')
} finally {
testLoading.value = false
}
}
//
const handleViewResult = (result: EmbeddingHitResult) => {
currentTextData.value = result
showTextModal.value = true
}
//
const handleDocModalSuccess = () => {
loadDocList()
}
//
const getStatusType = (status?: string): 'warning' | 'success' | 'error' => {
const statusMap: Record<string, 'warning' | 'success' | 'error'> = {
processing: 'warning',
building: 'warning', //
completed: 'success',
complete: 'success', // 'complete'
failed: 'error',
error: 'error'
}
return statusMap[status || 'complete'] || 'success'
}
//
const getStatusText = (status?: string): string => {
const statusMap: Record<string, string> = {
processing: '处理中',
building: '构建中', //
completed: '已完成',
complete: '已完成', // 'complete'
failed: '失败',
error: '失败'
}
return statusMap[status || 'complete'] || '已完成'
}
//
const handleClose = () => {
showModal.value = false
}
</script>
<style scoped lang="less">
.doc-list-container {
height: 100%;
.doc-header {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.doc-card {
height: 180px;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.doc-card-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 12px;
.doc-name {
margin-top: 8px;
font-size: 14px;
font-weight: 500;
color: #333;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
}
.doc-status {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
font-size: 12px;
color: #666;
span {
margin-right: 4px;
}
}
.doc-actions {
margin-top: auto;
display: flex;
justify-content: center;
}
}
.doc-pagination {
display: flex;
justify-content: center;
margin-top: 24px;
padding: 16px 0;
}
.test-container {
.test-results {
margin-top: 20px;
.n-list-item {
margin-bottom: 12px;
}
}
}
}
//
@media (max-width: 1600px) {
.doc-list-container .n-grid {
grid-template-columns: repeat(4, 1fr) !important;
}
}
@media (max-width: 1200px) {
.doc-list-container .n-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
}
@media (max-width: 992px) {
.doc-list-container .n-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
}
@media (max-width: 576px) {
.doc-list-container .n-grid {
grid-template-columns: 1fr !important;
}
}
</style>

View File

@ -0,0 +1,347 @@
<template>
<n-modal
v-model:show="showModal"
preset="card"
:title="modalTitle"
style="width: 800px"
:mask-closable="false"
:closable="true"
@close="handleCancel"
>
<n-form
ref="formRef"
:model="formData"
:rules="formRules"
label-placement="left"
:label-width="100"
require-mark-placement="right-hanging"
>
<n-form-item label="文档标题" path="title">
<n-input
v-model:value="formData.title"
placeholder="请输入文档标题"
clearable
/>
</n-form-item>
<!-- 文本类型 -->
<template v-if="formData.type === 'text'">
<n-form-item label="文档内容" path="content">
<n-input
v-model:value="formData.content"
type="textarea"
placeholder="请输入文档内容"
:autosize="{ minRows: 10, maxRows: 20 }"
show-count
/>
</n-form-item>
</template>
<!-- 文件类型 -->
<template v-if="formData.type === 'file'">
<n-form-item label="文件上传" path="filePath">
<n-upload
ref="uploadRef"
:action="uploadAction"
:headers="uploadHeaders"
:data="uploadData"
:file-list="fileList"
:max="1"
@before-upload="handleBeforeUpload"
@finish="handleUploadFinish"
@error="handleUploadError"
@remove="handleRemoveFile"
>
<n-upload-dragger>
<div style="margin-bottom: 12px">
<n-icon size="48" :depth="3">
<UploadOutlined />
</n-icon>
</div>
<n-text style="font-size: 16px">
点击或者拖动文件到该区域来上传
</n-text>
<n-p depth="3" style="margin: 8px 0 0 0">
支持 PDFWordExcelPowerPointTXT 等格式
</n-p>
</n-upload-dragger>
</n-upload>
</n-form-item>
</template>
<!-- URL类型 -->
<template v-if="formData.type === 'url'">
<n-form-item label="网页链接" path="content">
<n-input
v-model:value="formData.content"
placeholder="请输入网页链接地址"
clearable
/>
</n-form-item>
</template>
</n-form>
<template #action>
<n-space>
<n-button @click="handleCancel">取消</n-button>
<n-button
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
{{ isEdit ? '更新' : '创建' }}
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useMessage } from 'naive-ui'
import { UploadOutlined } from '@vicons/antd'
import type { FormInst, FormRules, UploadFileInfo } from 'naive-ui'
import { saveKnowledgeDoc } from '../api/knowledge'
import type { KnowledgeDoc, KnowledgeDocFormData } from '../types/knowledge'
import { getHeaders } from '../utils/file'
interface Props {
show: boolean
knowledgeId: string
docData?: Partial<KnowledgeDoc>
isEdit?: boolean
}
interface Emits {
(e: 'update:show', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
show: false,
isEdit: false,
docData: () => ({})
})
const emit = defineEmits<Emits>()
//
const formRef = ref<FormInst>()
const uploadRef = ref()
//
const message = useMessage()
//
const formData = reactive<KnowledgeDocFormData>({
title: '',
content: '',
type: 'text',
filePath: ''
})
const submitLoading = ref(false)
const fileList = ref<UploadFileInfo[]>([])
//
const uploadAction = '/api/sys/common/upload'
const uploadHeaders = computed(() => getHeaders())
const uploadData = { biz: 'knowledge_doc' }
//
const showModal = computed({
get: () => props.show,
set: (value) => emit('update:show', value)
})
const modalTitle = computed(() => {
const typeMap = {
text: '文本文档',
file: '文件文档',
url: '网页文档'
}
const typeText = typeMap[formData.type] || '文档'
return props.isEdit ? `编辑${typeText}` : `创建${typeText}`
})
//
const formRules: FormRules = {
title: [
{ required: true, message: '请输入文档标题', trigger: 'blur' }
],
content: [
{
required: true,
message: () => {
if (formData.type === 'text') return '请输入文档内容'
if (formData.type === 'url') return '请输入网页链接'
return '请输入内容'
},
trigger: 'blur'
}
],
filePath: [
{
required: true,
message: '请上传文件',
trigger: 'change',
validator: () => {
if (formData.type === 'file') {
return !!formData.filePath || fileList.value.length > 0
}
return true
}
}
]
}
//
watch(() => props.show, (newVal) => {
if (newVal) {
initForm()
}
})
//
const initForm = () => {
if (props.isEdit && props.docData) {
Object.assign(formData, {
title: props.docData.title || '',
content: props.docData.content || '',
type: props.docData.type || 'text',
filePath: props.docData.filePath || ''
})
//
if (formData.type === 'file' && formData.filePath) {
fileList.value = [{
id: '1',
name: formData.filePath.split('/').pop() || 'file',
status: 'finished',
url: formData.filePath
}]
}
} else {
//
Object.assign(formData, {
title: '',
content: '',
type: props.docData?.type || 'text',
filePath: ''
})
fileList.value = []
}
}
//
const handleBeforeUpload = (data: { file: File; fileList: UploadFileInfo[] }) => {
const { file } = data
// 50MB
if (file.size && file.size > 50 * 1024 * 1024) {
message.error('文件大小不能超过50MB')
return false
}
//
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain'
]
if (!allowedTypes.includes(file.type)) {
message.error('不支持的文件类型')
return false
}
return true
}
//
const handleUploadFinish = ({ event }: { file: UploadFileInfo; event?: ProgressEvent }) => {
try {
const response = JSON.parse((event?.target as XMLHttpRequest)?.response || '{}')
if (response.success) {
formData.filePath = response.result.url
message.success('文件上传成功')
} else {
message.error(response.message || '文件上传失败')
fileList.value = []
}
} catch (error) {
message.error('文件上传失败')
fileList.value = []
}
}
//
const handleUploadError = () => {
message.error('文件上传失败')
fileList.value = []
}
//
const handleRemoveFile = () => {
formData.filePath = ''
return true
}
//
const handleSubmit = async () => {
try {
await formRef.value?.validate()
submitLoading.value = true
const submitData: any = {
...formData,
knowledgeId: props.knowledgeId
}
if (props.isEdit && props.docData?.id) {
submitData.id = props.docData.id
}
// metadata
if (formData.type === 'file' && formData.filePath) {
submitData.metadata = JSON.stringify({ filePath: formData.filePath })
delete submitData.filePath
}
await saveKnowledgeDoc(submitData)
message.success(props.isEdit ? '更新成功' : '创建成功')
showModal.value = false
emit('success')
} catch (error) {
if (error instanceof Error) {
message.error(error.message)
}
} finally {
submitLoading.value = false
}
}
//
const handleCancel = () => {
showModal.value = false
}
</script>
<style scoped lang="less">
.n-form {
.n-form-item {
margin-bottom: 20px;
}
}
.n-upload-dragger {
text-align: center;
padding: 40px 20px;
}
</style>

View File

@ -0,0 +1,222 @@
<template>
<n-modal
v-model:show="showModal"
preset="card"
title="段落详情"
style="width: 800px; max-height: 80vh"
:mask-closable="true"
:closable="true"
@close="handleClose"
>
<div class="text-desc-container">
<!-- 头部信息 -->
<div class="header">
<n-tag type="info" size="medium">
{{ textData.source }}
</n-tag>
<n-tag v-if="textData.score" type="success" size="medium">
相似度: {{ textData.score.toFixed(4) }}
</n-tag>
</div>
<!-- 内容区域 -->
<div class="content">
<n-scrollbar style="max-height: 500px">
<div class="markdown-content" v-html="renderedContent"></div>
</n-scrollbar>
</div>
<!-- 操作区域 -->
<div class="actions">
<n-space>
<n-button @click="handleCopy">
<template #icon>
<n-icon><CopyOutlined /></n-icon>
</template>
复制内容
</n-button>
<n-button @click="handleClose">关闭</n-button>
</n-space>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useMessage } from 'naive-ui'
import { CopyOutlined } from '@vicons/antd'
import { copyToClipboard } from '../utils/clipboard'
import { replaceImageWith, replaceDomainUrl } from '../utils/common'
import { marked } from 'marked'
interface Props {
show: boolean
textData?: {
content: string
source?: string
score?: number
}
}
interface Emits {
(e: 'update:show', value: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
show: false,
textData: () => ({
content: '',
source: '',
score: 0
})
})
const emit = defineEmits<Emits>()
//
const message = useMessage()
//
const showModal = computed({
get: () => props.show,
set: (value) => emit('update:show', value)
})
const renderedContent = computed(() => {
if (!props.textData?.content) return ''
let content = props.textData.content
//
content = replaceImageWith(content)
// URL
content = replaceDomainUrl(content)
// Markdown
if (content.includes('**') || content.includes('##') || content.includes('[') || content.includes('```')) {
try {
return marked(content)
} catch (error) {
console.warn('Markdown渲染失败使用原始内容:', error)
return content.replace(/\n/g, '<br>')
}
}
//
return content.replace(/\n/g, '<br>')
})
//
const handleCopy = async () => {
try {
const success = await copyToClipboard(props.textData?.content || '')
if (success) {
message.success('复制成功')
} else {
message.error('复制失败')
}
} catch (error) {
message.error('复制失败')
}
}
//
const handleClose = () => {
showModal.value = false
}
</script>
<style scoped lang="less">
.text-desc-container {
.header {
display: flex;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.content {
margin-bottom: 20px;
.markdown-content {
line-height: 1.6;
color: #333;
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
margin: 16px 0 8px 0;
font-weight: 600;
}
:deep(p) {
margin: 8px 0;
}
:deep(ul), :deep(ol) {
margin: 8px 0;
padding-left: 20px;
}
:deep(blockquote) {
margin: 16px 0;
padding: 12px 16px;
background-color: #f8f9fa;
border-left: 4px solid #dee2e6;
color: #6c757d;
}
:deep(code) {
background-color: #f8f9fa;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
:deep(pre) {
background-color: #f8f9fa;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
code {
background: none;
padding: 0;
}
}
:deep(img) {
max-width: 100%;
height: auto;
border-radius: 6px;
margin: 8px 0;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
th, td {
border: 1px solid #dee2e6;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #f8f9fa;
font-weight: 600;
}
}
}
}
.actions {
display: flex;
justify-content: flex-end;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
}
</style>

View File

@ -0,0 +1,88 @@
<template>
<n-config-provider :theme="theme">
<n-global-style />
<n-message-provider>
<n-dialog-provider>
<div class="app-container">
<!-- 头部导航 -->
<n-layout-header bordered style="height: 64px; padding: 0 24px">
<div class="header-content">
<div class="logo">
<n-icon size="32" color="#1890ff">
<DatabaseOutlined />
</n-icon>
<span class="title">AI知识库管理系统</span>
</div>
<div class="header-actions">
<n-button
quaternary
circle
@click="toggleTheme"
>
<template #icon>
<n-icon>
<BulbOutlined v-if="isDark" />
<BulbFilled v-else />
</n-icon>
</template>
</n-button>
</div>
</div>
</n-layout-header>
<!-- 主要内容区域 -->
<n-layout-content style="height: calc(100vh - 64px)">
<AiKnowledgeBaseList />
</n-layout-content>
</div>
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { darkTheme } from 'naive-ui'
import { DatabaseOutlined, BulbOutlined, BulbFilled } from '@vicons/antd'
import AiKnowledgeBaseList from '../AiKnowledgeBaseList.vue'
//
const isDark = ref(false)
const theme = computed(() => isDark.value ? darkTheme : null)
const toggleTheme = () => {
isDark.value = !isDark.value
}
</script>
<style scoped lang="less">
.app-container {
height: 100vh;
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
.logo {
display: flex;
align-items: center;
gap: 12px;
.title {
font-size: 18px;
font-weight: 600;
color: #333;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
}
}
</style>

View File

@ -0,0 +1,23 @@
/**
* AI知识库管理系统 - Naive UI版本
*
*/
// 主要组件
export { default as AiKnowledgeBaseList } from './AiKnowledgeBaseList.vue'
export { default as KnowledgeBaseModal } from './components/KnowledgeBaseModal.vue'
export { default as KnowledgeDocListModal } from './components/KnowledgeDocListModal.vue'
export { default as KnowledgeDocTextModal } from './components/KnowledgeDocTextModal.vue'
export { default as TextDescModal } from './components/TextDescModal.vue'
// API接口
export * from './api/knowledge'
// 类型定义
export * from './types/knowledge'
// 工具函数
export * from './utils/http'
export * from './utils/clipboard'
export * from './utils/file'
export * from './utils/common'

View File

@ -0,0 +1,58 @@
{
"name": "ai-knowledge-naive-ui",
"version": "1.0.0",
"description": "AI知识库管理系统 - Naive UI版本",
"main": "index.ts",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"keywords": [
"vue3",
"typescript",
"naive-ui",
"ai",
"knowledge-base",
"vector-database"
],
"author": "Your Name",
"license": "MIT",
"dependencies": {
"vue": "^3.3.0",
"naive-ui": "^2.35.0",
"@vicons/antd": "^0.12.0",
"axios": "^1.5.0",
"marked": "^9.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@vitejs/plugin-vue": "^4.4.0",
"typescript": "^5.2.0",
"vite": "^4.4.0",
"vue-tsc": "^1.8.0",
"less": "^4.2.0"
},
"peerDependencies": {
"vue": ">=3.3.0"
},
"files": [
"*.vue",
"api/",
"components/",
"types/",
"utils/",
"index.ts",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/your-username/ai-knowledge-naive-ui.git"
},
"bugs": {
"url": "https://github.com/your-username/ai-knowledge-naive-ui/issues"
},
"homepage": "https://github.com/your-username/ai-knowledge-naive-ui#readme"
}

View File

@ -0,0 +1,46 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
/* Vue specific */
"types": ["vite/client", "node"]
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.vue"
],
"exclude": [
"node_modules",
"dist"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,174 @@
/**
* AI知识库相关类型定义
*/
// 知识库基础信息
export interface KnowledgeBase {
id: string
name: string
descr?: string
embedId: string
embedName?: string
status?: 'active' | 'inactive'
createBy?: string
createBy_dictText?: string
createTime?: string
updateBy?: string
updateTime?: string
docCount?: number
vectorCount?: number
}
// 知识库搜索表单
export interface KnowledgeSearchForm {
name?: string
status?: string
createBy?: string
}
// 知识库文档
export interface KnowledgeDoc {
id: string
knowledgeId: string
title: string
content?: string
type: 'text' | 'file' | 'url'
source?: string
metadata?: string
filePath?: string
status?: 'processing' | 'completed' | 'failed'
createBy?: string
createBy_dictText?: string
createTime?: string
updateBy?: string
updateTime?: string
vectorStatus?: 'pending' | 'processing' | 'completed' | 'failed'
__checked?: boolean // 用于表格选择
}
// 知识库文档搜索表单
export interface KnowledgeDocSearchForm {
title?: string
type?: string
status?: string
}
// 向量化命中测试结果
export interface EmbeddingHitResult {
id: string
content: string
source: string
score: number
metadata?: any
}
// 分页参数
export interface Pagination {
page: number
pageSize: number
total: number
}
// 表单规则
export interface FormRules {
[key: string]: Array<{
required?: boolean
message: string
trigger?: string | string[]
min?: number
max?: number
pattern?: RegExp
validator?: (rule: any, value: any) => boolean | Promise<boolean>
}>
}
// 知识库表单数据
export interface KnowledgeFormData {
id?: string
name: string
descr?: string
embedId: string
}
// 知识库文档表单数据
export interface KnowledgeDocFormData {
id?: string
knowledgeId?: string
title: string
content?: string
type: 'text' | 'file' | 'url'
filePath?: string
metadata?: string
}
// 文件上传响应
export interface UploadResponse {
success: boolean
message: string
result: {
filename: string
url: string
size: number
}
}
// 向量模型选项
export interface EmbedModelOption {
label: string
value: string
disabled?: boolean
}
// 菜单项
export interface MenuItem {
key: string
label: string
icon?: string
disabled?: boolean
}
// 表格列配置
export interface TableColumn {
key: string
title: string
width?: number
align?: 'left' | 'center' | 'right'
ellipsis?: boolean
render?: (row: any) => any
}
// 操作按钮配置
export interface ActionButton {
label: string
type?: 'primary' | 'info' | 'success' | 'warning' | 'error'
icon?: string
disabled?: boolean
loading?: boolean
onClick: () => void
}
// 知识库统计信息
export interface KnowledgeStats {
totalKnowledge: number
totalDocs: number
totalVectors: number
processingDocs: number
}
// 批量操作参数
export interface BatchOperationParams {
ids: string[]
operation: 'delete' | 'rebuild' | 'enable' | 'disable'
}
// 重建向量参数
export interface RebuildVectorParams {
knowIds?: string
docIds?: string[]
}
// 清空文档参数
export interface DeleteAllDocsParams {
knowledgeId: string
confirm: boolean
}

View File

@ -0,0 +1,48 @@
/**
*
* @param text
* @returns Promise<boolean>
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
// 优先使用现代的 Clipboard API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
}
// 降级方案:使用 document.execCommand
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const success = document.execCommand('copy')
document.body.removeChild(textArea)
return success
} catch (error) {
console.error('复制到剪贴板失败:', error)
return false
}
}
/**
*
* @returns Promise<string>
*/
export async function readFromClipboard(): Promise<string> {
try {
if (navigator.clipboard && window.isSecureContext) {
return await navigator.clipboard.readText()
}
throw new Error('Clipboard API not supported')
} catch (error) {
console.error('从剪贴板读取失败:', error)
return ''
}
}

View File

@ -0,0 +1,173 @@
/**
*
*/
/**
*
* @param func
* @param wait
* @returns
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
return function (this: any, ...args: Parameters<T>) {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => func.apply(this, args), wait)
}
}
/**
*
* @param func
* @param wait
* @returns
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
let previous = 0
return function (this: any, ...args: Parameters<T>) {
const now = Date.now()
const remaining = wait - (now - previous)
const context = this
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
previous = now
func.apply(context, args)
} else if (!timeout) {
timeout = setTimeout(() => {
previous = Date.now()
timeout = null
func.apply(context, args)
}, remaining)
}
}
}
/**
*
* @param obj
* @returns
*/
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as T
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as T
}
if (typeof obj === 'object') {
const clonedObj = {} as T
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key])
}
}
return clonedObj
}
return obj
}
/**
* ID
* @returns ID字符串
*/
export function generateId(): string {
return Math.random().toString(36).substr(2, 9) + Date.now().toString(36)
}
/**
*
* @param date
* @param format 'YYYY-MM-DD HH:mm:ss'
* @returns
*/
export function formatDate(date: Date | number | string, format = 'YYYY-MM-DD HH:mm:ss'): string {
const d = new Date(date)
if (isNaN(d.getTime())) {
return ''
}
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', year.toString())
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
/**
*
* @param value
* @returns
*/
export function isEmpty(value: any): boolean {
if (value === null || value === undefined) {
return true
}
if (typeof value === 'string') {
return value.trim() === ''
}
if (Array.isArray(value)) {
return value.length === 0
}
if (typeof value === 'object') {
return Object.keys(value).length === 0
}
return false
}
/**
*
* @param content
* @returns
*/
export function replaceImageWith(content: string): string {
return content.replace(/<img([^>]*?)style="([^"]*?)"([^>]*?)>/g, (_match, before, style, after) => {
const newStyle = style.replace(/width:\s*\d+px/g, 'width: 100%')
return `<img${before}style="${newStyle}"${after}>`
})
}
/**
* URL
* @param content
* @returns
*/
export function replaceDomainUrl(content: string): string {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
return content.replace(/src="\/sys\/common\/static\//g, `src="${baseUrl}/sys/common/static/`)
}

View File

@ -0,0 +1,128 @@
/**
*
*/
/**
* 访URL
* @param filePath
* @returns 访URL
*/
export function getFileAccessHttpUrl(filePath: string): string {
if (!filePath) return ''
// 如果已经是完整URL直接返回
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return filePath
}
// 拼接API基础路径
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
return `${baseUrl}/sys/common/static/${filePath}`
}
/**
*
* @returns
*/
export function getHeaders(): Record<string, string> {
const token = localStorage.getItem('token')
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` })
}
}
/**
*
* @param bytes
* @returns
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
*
* @param filename
* @returns
*/
export function getFileExtension(filename: string): string {
return filename.slice((filename.lastIndexOf('.') - 1 >>> 0) + 2)
}
/**
*
* @param filename
* @returns
*/
export function isImageFile(filename: string): boolean {
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
const extension = getFileExtension(filename).toLowerCase()
return imageExtensions.includes(extension)
}
/**
*
* @param filename
* @returns
*/
export function isDocumentFile(filename: string): boolean {
const docExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md']
const extension = getFileExtension(filename).toLowerCase()
return docExtensions.includes(extension)
}
/**
*
* @param url
* @param filename
*/
export function downloadFile(url: string, filename?: string): void {
const link = document.createElement('a')
link.href = url
if (filename) {
link.download = filename
}
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
/**
* Base64
* @param file
* @returns Promise<string> Base64字符串
*/
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result as string)
reader.onerror = error => reject(error)
})
}
/**
* Base64转换为Blob
* @param base64 Base64字符串
* @param mimeType MIME类型
* @returns Blob对象
*/
export function base64ToBlob(base64: string, mimeType: string): Blob {
const byteCharacters = atob(base64.split(',')[1])
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
return new Blob([byteArray], { type: mimeType })
}

View File

@ -0,0 +1,203 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
export interface HttpResponse<T = any> {
success: boolean
result: T
message: string
code: number
timestamp: number
}
export interface RequestConfig extends AxiosRequestConfig {
isTransformResponse?: boolean
joinParamsToUrl?: boolean
}
export class HttpClient {
private instance: AxiosInstance
constructor(config?: AxiosRequestConfig) {
this.instance = axios.create({
baseURL: '/jeecgboot',
timeout: 60000,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
},
...config
})
this.setupInterceptors()
}
private setupInterceptors() {
// 请求拦截器
this.instance.interceptors.request.use(
(config) => {
// 添加token等认证信息使用项目标准的 X-Access-Token
const token = localStorage.getItem('X-Access-Token') || sessionStorage.getItem('X-Access-Token')
if (token) {
config.headers = config.headers || {}
config.headers['X-Access-Token'] = token
}
// 添加租户ID
const tenantId = localStorage.getItem('TENANT_ID') || '0'
if (tenantId) {
config.headers = config.headers || {}
config.headers['X-Tenant-Id'] = tenantId
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
this.instance.interceptors.response.use(
(response: AxiosResponse<HttpResponse>) => {
const { data } = response
// 如果是文件下载等特殊情况,直接返回
if (response.config.responseType === 'blob') {
return response
}
// 检查业务状态码
if (data.success === false) {
throw new Error(data.message || '请求失败')
}
return response
},
(error) => {
// 处理HTTP错误状态码
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
// 未授权清除token并跳转到登录页
localStorage.removeItem('X-Access-Token')
sessionStorage.removeItem('X-Access-Token')
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
break
case 403:
throw new Error('没有权限访问该资源')
case 404:
throw new Error('请求的资源不存在')
case 500:
throw new Error('服务器内部错误')
default:
throw new Error(data?.message || `请求失败 (${status})`)
}
} else if (error.request) {
throw new Error('网络连接失败,请检查网络设置')
} else {
throw new Error(error.message || '请求配置错误')
}
return Promise.reject(error)
}
)
}
// GET请求
get<T = any>(config: RequestConfig, options?: { isTransformResponse?: boolean; joinParamsToUrl?: boolean }): Promise<HttpResponse<T>> {
const mergedConfig = { ...config, ...options }
const { joinParamsToUrl, isTransformResponse = true, ...axiosConfig } = mergedConfig
if (joinParamsToUrl && mergedConfig.params) {
const params = new URLSearchParams(mergedConfig.params).toString()
axiosConfig.url = `${axiosConfig.url}?${params}`
delete axiosConfig.params
}
return this.instance.get(axiosConfig.url!, axiosConfig).then(response => {
return isTransformResponse ? response.data : response
})
}
// POST请求
post<T = any>(config: RequestConfig, options?: { isTransformResponse?: boolean }): Promise<HttpResponse<T>> {
const mergedConfig = { ...config, ...options }
const { isTransformResponse = true, ...axiosConfig } = mergedConfig
return this.instance.post(axiosConfig.url!, axiosConfig.params || axiosConfig.data, axiosConfig).then(response => {
return isTransformResponse ? response.data : response
})
}
// PUT请求
put<T = any>(config: RequestConfig, options?: { isTransformResponse?: boolean; joinParamsToUrl?: boolean }): Promise<HttpResponse<T>> {
const mergedConfig = { ...config, ...options }
const { joinParamsToUrl, isTransformResponse = true, ...axiosConfig } = mergedConfig
if (joinParamsToUrl && mergedConfig.params) {
const params = new URLSearchParams(mergedConfig.params).toString()
axiosConfig.url = `${axiosConfig.url}?${params}`
delete axiosConfig.params
}
return this.instance.put(axiosConfig.url!, axiosConfig.params || axiosConfig.data, axiosConfig).then(response => {
return isTransformResponse ? response.data : response
})
}
// DELETE请求
delete<T = any>(config: RequestConfig, options?: { isTransformResponse?: boolean; joinParamsToUrl?: boolean }): Promise<HttpResponse<T>> {
const mergedConfig = { ...config, ...options }
const { joinParamsToUrl, isTransformResponse = true, ...axiosConfig } = mergedConfig
if (joinParamsToUrl && mergedConfig.params) {
const params = new URLSearchParams(mergedConfig.params).toString()
axiosConfig.url = `${axiosConfig.url}?${params}`
delete axiosConfig.params
}
return this.instance.delete(axiosConfig.url!, axiosConfig).then(response => {
return isTransformResponse ? response.data : response
})
}
// 文件上传
upload<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<HttpResponse<T>> {
return this.instance.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
...config
}).then(response => response.data)
}
// 文件下载
download(url: string, params?: any, filename?: string): Promise<void> {
return this.instance.get(url, {
params,
responseType: 'blob'
}).then(response => {
const blob = new Blob([response.data])
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = filename || 'download'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(downloadUrl)
})
}
}
// 创建默认实例
export const http = new HttpClient()
// 导出默认实例的方法
export const defHttp = {
get: http.get.bind(http),
post: http.post.bind(http),
put: http.put.bind(http),
delete: http.delete.bind(http),
upload: http.upload.bind(http),
download: http.download.bind(http)
}

View File

@ -0,0 +1,50 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, '.'),
},
},
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true,
},
},
},
server: {
port: 3000,
open: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
},
},
build: {
lib: {
entry: resolve(__dirname, 'index.ts'),
name: 'AiKnowledgeNaiveUI',
fileName: (format) => `ai-knowledge-naive-ui.${format}.js`,
},
rollupOptions: {
external: ['vue', 'naive-ui', '@vicons/antd', 'axios', 'marked'],
output: {
globals: {
vue: 'Vue',
'naive-ui': 'naive',
'@vicons/antd': 'ViconsAntd',
axios: 'axios',
marked: 'marked',
},
},
},
},
})