feat:bug修改
This commit is contained in:
parent
a55bf916c2
commit
b52a954e86
944
package-lock.json
generated
944
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
|
"@vicons/antd": "^0.13.0",
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
@ -37,6 +38,7 @@
|
|||||||
"@types/dplayer": "^1.25.5",
|
"@types/dplayer": "^1.25.5",
|
||||||
"@types/node": "^24.0.15",
|
"@types/node": "^24.0.15",
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"sass-embedded": "^1.93.2",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vite-plugin-vue-devtools": "^7.7.7",
|
"vite-plugin-vue-devtools": "^7.7.7",
|
||||||
|
244
src/components/common/Loading.md
Normal file
244
src/components/common/Loading.md
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
# Loading 组件使用文档
|
||||||
|
|
||||||
|
基于 Naive UI 的通用 Loading 组件,支持全屏遮罩和内联两种模式。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ **两种模式**:全屏遮罩模式和内联模式
|
||||||
|
- ✅ **多种尺寸**:small、medium、large
|
||||||
|
- ✅ **自定义样式**:颜色、文本、背景等
|
||||||
|
- ✅ **TypeScript 支持**:完整的类型定义
|
||||||
|
- ✅ **响应式设计**:适配移动端
|
||||||
|
- ✅ **异步包装器**:简化异步操作的 Loading 处理
|
||||||
|
- ✅ **全局实例**:支持全局调用
|
||||||
|
- ✅ **组件 Hook**:支持组件内状态管理
|
||||||
|
|
||||||
|
## 安装使用
|
||||||
|
|
||||||
|
### 1. 组件方式使用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- 内联模式 -->
|
||||||
|
<Loading
|
||||||
|
:loading="loading"
|
||||||
|
text="加载中..."
|
||||||
|
size="medium"
|
||||||
|
color="#1890ff"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 遮罩模式 -->
|
||||||
|
<Loading
|
||||||
|
:loading="loading"
|
||||||
|
text="正在处理..."
|
||||||
|
overlay
|
||||||
|
background-color="rgba(0, 0, 0, 0.5)"
|
||||||
|
opacity="0.8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Loading from '@/components/common/Loading.vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Composable Hook 使用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<n-button @click="handleLoad">开始加载</n-button>
|
||||||
|
|
||||||
|
<Loading
|
||||||
|
:loading="loading"
|
||||||
|
:text="loadingText"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useLoading } from '@/composables/useLoading'
|
||||||
|
import Loading from '@/components/common/Loading.vue'
|
||||||
|
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
loadingText,
|
||||||
|
showLoading,
|
||||||
|
hideLoading,
|
||||||
|
updateLoadingText
|
||||||
|
} = useLoading()
|
||||||
|
|
||||||
|
const handleLoad = async () => {
|
||||||
|
showLoading('正在加载数据...')
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
updateLoadingText('即将完成...')
|
||||||
|
}, 1500)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
hideLoading()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 全局方式使用
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import Loading from '@/composables/useLoading'
|
||||||
|
|
||||||
|
// 显示 Loading
|
||||||
|
Loading.show({
|
||||||
|
text: '正在加载...',
|
||||||
|
size: 'large',
|
||||||
|
color: '#52c41a'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新文本
|
||||||
|
Loading.updateText('加载中... 50%')
|
||||||
|
|
||||||
|
// 隐藏 Loading
|
||||||
|
Loading.hide()
|
||||||
|
|
||||||
|
// 异步操作包装
|
||||||
|
const result = await Loading.wrap(
|
||||||
|
() => fetch('/api/data').then(res => res.json()),
|
||||||
|
{
|
||||||
|
text: '正在获取数据...',
|
||||||
|
onError: (error) => console.error('请求失败:', error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 参数
|
||||||
|
|
||||||
|
### Loading 组件 Props
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| loading | boolean | true | 是否显示加载状态 |
|
||||||
|
| text | string | '加载中...' | 加载文本 |
|
||||||
|
| overlay | boolean | false | 是否显示遮罩层 |
|
||||||
|
| size | 'small' \| 'medium' \| 'large' | 'medium' | 尺寸 |
|
||||||
|
| color | string | '#1890ff' | 自定义颜色 |
|
||||||
|
| textColor | string | '#666666' | 文本颜色 |
|
||||||
|
| backgroundColor | string | 'rgba(255, 255, 255, 0.8)' | 背景颜色(遮罩模式) |
|
||||||
|
| opacity | number | 0.8 | 透明度(遮罩模式) |
|
||||||
|
| customClass | string | '' | 自定义类名 |
|
||||||
|
| zIndex | number | 1000 | z-index |
|
||||||
|
|
||||||
|
### useLoading Hook 返回值
|
||||||
|
|
||||||
|
| 属性 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| loading | Ref\<boolean\> | 加载状态 |
|
||||||
|
| loadingText | Ref\<string\> | 加载文本 |
|
||||||
|
| showLoading | (text?: string) => void | 显示加载 |
|
||||||
|
| hideLoading | () => void | 隐藏加载 |
|
||||||
|
| updateLoadingText | (text: string) => void | 更新加载文本 |
|
||||||
|
|
||||||
|
### 全局 Loading 方法
|
||||||
|
|
||||||
|
| 方法 | 参数 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Loading.show | (options?: LoadingOptions) => void | 显示全局 Loading |
|
||||||
|
| Loading.hide | () => void | 隐藏全局 Loading |
|
||||||
|
| Loading.updateText | (text: string) => void | 更新 Loading 文本 |
|
||||||
|
| Loading.destroy | () => void | 销毁全局 Loading |
|
||||||
|
| Loading.wrap | \<T\>(asyncFn, options?) => Promise\<T\> | 异步操作包装器 |
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 1. 页面级加载
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 页面进入时显示
|
||||||
|
Loading.show({ text: '正在加载页面...' })
|
||||||
|
|
||||||
|
// 数据加载完成后隐藏
|
||||||
|
Loading.hide()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. API 请求加载
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const fetchData = async () => {
|
||||||
|
return await Loading.wrap(
|
||||||
|
() => api.getData(),
|
||||||
|
{
|
||||||
|
text: '正在获取数据...',
|
||||||
|
onError: (error) => message.error('获取数据失败')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 表单提交加载
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
Loading.show({ text: '正在提交...' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.submitForm(formData)
|
||||||
|
message.success('提交成功')
|
||||||
|
} catch (error) {
|
||||||
|
message.error('提交失败')
|
||||||
|
} finally {
|
||||||
|
Loading.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 进度加载
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const uploadFile = () => {
|
||||||
|
Loading.show({ text: '准备上传... 0%' })
|
||||||
|
|
||||||
|
let progress = 0
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
progress += 10
|
||||||
|
Loading.updateText(`上传中... ${progress}%`)
|
||||||
|
|
||||||
|
if (progress >= 100) {
|
||||||
|
clearInterval(interval)
|
||||||
|
Loading.hide()
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 样式自定义
|
||||||
|
|
||||||
|
组件支持通过 CSS 变量进行样式自定义:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.loading-container {
|
||||||
|
--loading-bg-color: rgba(255, 255, 255, 0.9);
|
||||||
|
--loading-text-color: #333;
|
||||||
|
--loading-spin-color: #1890ff;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **全局 Loading** 会自动创建和销毁 DOM 元素
|
||||||
|
2. **遮罩模式** 会阻止用户交互,请合理使用
|
||||||
|
3. **异步包装器** 会自动处理 Loading 的显示和隐藏
|
||||||
|
4. **组件卸载时** 建议调用 `Loading.destroy()` 清理资源
|
||||||
|
5. **移动端适配** 已内置响应式样式
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **统一管理**:建议在 API 拦截器中统一处理 Loading
|
||||||
|
2. **错误处理**:使用 `Loading.wrap` 时提供错误处理回调
|
||||||
|
3. **用户体验**:避免过短或过长的 Loading 时间
|
||||||
|
4. **文本提示**:提供有意义的加载文本提示
|
||||||
|
5. **性能优化**:避免频繁的显示/隐藏操作
|
@ -760,6 +760,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/Ai.vue'),
|
component: () => import('@/views/Ai.vue'),
|
||||||
meta: { title: 'AI' }
|
meta: { title: 'AI' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/ai/app',
|
||||||
|
name: 'AiAppList',
|
||||||
|
component: () => import('@/views/Ai/AiAppList-NaiveUI.vue'),
|
||||||
|
meta: { title: 'AI应用管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ai/app/chat/:id',
|
||||||
|
name: 'AiAppChat',
|
||||||
|
component: () => import('@/views/Ai/AiAppChat.vue'),
|
||||||
|
meta: { title: 'AI应用对话' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/ai-demo',
|
path: '/ai-demo',
|
||||||
name: 'AIDemo',
|
name: 'AIDemo',
|
||||||
|
@ -440,10 +440,10 @@
|
|||||||
|
|
||||||
<!-- 左侧边栏 -->
|
<!-- 左侧边栏 -->
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="sidebar-title">
|
<!-- <div class="sidebar-title">
|
||||||
<h2>学习进度</h2>
|
<h2>学习进度</h2>
|
||||||
<img src="/images/aiCompanion/fold.png" alt="">
|
<img src="/images/aiCompanion/fold.png" alt="">
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<!-- 学习进度 -->
|
<!-- 学习进度 -->
|
||||||
<div class="progress-section">
|
<div class="progress-section">
|
||||||
@ -1828,6 +1828,7 @@ onMounted(() => {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
border: 1px solid white;
|
border: 1px solid white;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 进度头部样式 */
|
/* 进度头部样式 */
|
||||||
|
212
src/views/Ai/AI-App-NaiveUI-README.md
Normal file
212
src/views/Ai/AI-App-NaiveUI-README.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# AI应用列表 - Naive UI版本
|
||||||
|
|
||||||
|
这是将原有的Ant Design Vue + JeecgBoot项目中的AI应用列表页面转换为Naive UI + TypeScript + Vue3版本的实现。
|
||||||
|
|
||||||
|
## 📁 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── AiAppList-NaiveUI.vue # 主页面组件
|
||||||
|
├── api/
|
||||||
|
│ ├── aiApp.ts # AI应用相关API
|
||||||
|
│ └── upload.ts # 文件上传API
|
||||||
|
├── components/
|
||||||
|
│ ├── AiAppForm.vue # 应用表单组件
|
||||||
|
│ ├── AiAppSetting.vue # 应用设置组件
|
||||||
|
│ └── AiAppPublish.vue # 应用发布组件
|
||||||
|
├── types/
|
||||||
|
│ └── aiApp.ts # 类型定义
|
||||||
|
├── utils/
|
||||||
|
│ ├── http.ts # HTTP请求工具
|
||||||
|
│ └── clipboard.ts # 剪贴板工具
|
||||||
|
└── AI-App-NaiveUI-README.md # 说明文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 接口转换对照
|
||||||
|
|
||||||
|
### 原始接口 → 转换后接口
|
||||||
|
|
||||||
|
| 功能 | 原始接口 | 转换后接口 | 说明 |
|
||||||
|
|------|----------|------------|------|
|
||||||
|
| 获取应用列表 | `GET /airag/app/list` | `aiAppApi.getAppList()` | 分页查询AI应用列表 |
|
||||||
|
| 删除应用 | `DELETE /airag/app/delete` | `aiAppApi.deleteApp()` | 删除指定应用 |
|
||||||
|
| 发布应用 | `POST /airag/app/release` | `aiAppApi.releaseApp()` | 发布或取消发布应用 |
|
||||||
|
| 查询应用详情 | `GET /airag/app/queryById` | `aiAppApi.getAppById()` | 获取单个应用详情 |
|
||||||
|
| 保存应用 | `PUT /airag/app/edit` | `aiAppApi.saveApp()` | 新增或编辑应用 |
|
||||||
|
| 查询知识库 | `GET /airag/knowledge/query/batch/byId` | `aiAppApi.getKnowledgeBatch()` | 批量查询知识库 |
|
||||||
|
| 查询流程 | `GET /airag/flow/queryById` | `aiAppApi.getFlowById()` | 根据ID查询流程 |
|
||||||
|
| 生成提示词 | `POST /airag/app/prompt/generate` | `aiAppApi.generatePrompt()` | AI应用编排生成提示词 |
|
||||||
|
|
||||||
|
## 🎨 UI组件转换
|
||||||
|
|
||||||
|
### Ant Design Vue → Naive UI
|
||||||
|
|
||||||
|
| 原组件 | 新组件 | 说明 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| `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-avatar` | `n-avatar` | 头像组件 |
|
||||||
|
|
||||||
|
## 🚀 使用方法
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install naive-ui @vicons/antd
|
||||||
|
# 或
|
||||||
|
yarn add naive-ui @vicons/antd
|
||||||
|
# 或
|
||||||
|
pnpm add naive-ui @vicons/antd
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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>
|
||||||
|
<AiAppList />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AiAppList from './AiAppList-NaiveUI.vue'
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 配置说明
|
||||||
|
|
||||||
|
### HTTP请求配置
|
||||||
|
|
||||||
|
在 `utils/http.ts` 中配置你的API基础地址:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const http = new HttpClient({
|
||||||
|
baseURL: 'http://your-api-domain.com/api', // 修改为你的API地址
|
||||||
|
timeout: 60000
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 认证配置
|
||||||
|
|
||||||
|
系统会自动从localStorage或sessionStorage中获取token:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 设置token
|
||||||
|
localStorage.setItem('ACCESS_TOKEN', 'your-token')
|
||||||
|
// 或
|
||||||
|
sessionStorage.setItem('ACCESS_TOKEN', 'your-token')
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 功能特性
|
||||||
|
|
||||||
|
### ✅ 已实现功能
|
||||||
|
|
||||||
|
- [x] 应用列表展示(卡片式布局)
|
||||||
|
- [x] 搜索和筛选功能
|
||||||
|
- [x] 分页功能
|
||||||
|
- [x] 创建新应用
|
||||||
|
- [x] 编辑应用信息
|
||||||
|
- [x] 删除应用
|
||||||
|
- [x] 发布/取消发布应用
|
||||||
|
- [x] 应用预览
|
||||||
|
- [x] 嵌入网站代码生成
|
||||||
|
- [x] 菜单配置SQL生成
|
||||||
|
- [x] 文件上传功能
|
||||||
|
- [x] 响应式布局
|
||||||
|
|
||||||
|
### 🎯 主要改进
|
||||||
|
|
||||||
|
1. **类型安全**: 使用TypeScript提供完整的类型定义
|
||||||
|
2. **组件化**: 将复杂功能拆分为独立的组件
|
||||||
|
3. **响应式设计**: 适配不同屏幕尺寸
|
||||||
|
4. **错误处理**: 完善的错误处理和用户提示
|
||||||
|
5. **代码复用**: 提取公共工具函数和API封装
|
||||||
|
|
||||||
|
## 🔍 代码示例
|
||||||
|
|
||||||
|
### 调用API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 获取应用列表
|
||||||
|
const loadAppList = async () => {
|
||||||
|
try {
|
||||||
|
const response = await aiAppApi.getAppList({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
name: '搜索关键词'
|
||||||
|
})
|
||||||
|
console.log(response.result.records)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除应用
|
||||||
|
const deleteApp = async (appId: string) => {
|
||||||
|
try {
|
||||||
|
await aiAppApi.deleteApp({ id: appId, name: '应用名称' })
|
||||||
|
console.log('删除成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 复制到剪贴板
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
const success = await copyToClipboard('要复制的内容')
|
||||||
|
if (success) {
|
||||||
|
console.log('复制成功')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 注意事项
|
||||||
|
|
||||||
|
1. **图标引用**: 使用`@vicons/antd`包中的图标,需要按需引入
|
||||||
|
2. **样式覆盖**: 使用`:deep()`选择器来覆盖组件内部样式
|
||||||
|
3. **类型导入**: 确保正确导入Naive UI的类型定义
|
||||||
|
4. **API地址**: 根据实际情况修改API基础地址
|
||||||
|
5. **权限控制**: 根据需要添加路由守卫和权限验证
|
||||||
|
|
||||||
|
## 📝 待完善功能
|
||||||
|
|
||||||
|
- [ ] 国际化支持
|
||||||
|
- [ ] 主题切换
|
||||||
|
- [ ] 更多文件类型支持
|
||||||
|
- [ ] 批量操作功能
|
||||||
|
- [ ] 导入导出功能
|
||||||
|
- [ ] 应用模板功能
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
欢迎提交Issue和Pull Request来改进这个项目!
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License
|
377
src/views/Ai/AiAppChat.vue
Normal file
377
src/views/Ai/AiAppChat.vue
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-app-chat">
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="chat-header">
|
||||||
|
<div class="app-info">
|
||||||
|
<n-avatar
|
||||||
|
:size="32"
|
||||||
|
:src="appInfo?.icon ? getAppIcon(appInfo.icon) : '/default-app-icon.png'"
|
||||||
|
round
|
||||||
|
/>
|
||||||
|
<div class="app-details">
|
||||||
|
<h3 class="app-name">{{ appInfo?.name || 'AI应用' }}</h3>
|
||||||
|
<p class="app-desc">{{ appInfo?.descr || '智能对话助手' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n-button @click="handleBack" quaternary>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><ArrowLeftOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
返回
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-content" ref="chatContentRef">
|
||||||
|
<div class="message-list">
|
||||||
|
<!-- 欢迎消息 -->
|
||||||
|
<div v-if="messages.length === 0 && appInfo?.prologue" class="message bot-message">
|
||||||
|
<n-avatar size="small" src="/ai-avatar.png" round />
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-text">{{ appInfo.prologue }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<div
|
||||||
|
v-for="(message, index) in messages"
|
||||||
|
:key="index"
|
||||||
|
class="message"
|
||||||
|
:class="message.type === 'user' ? 'user-message' : 'bot-message'"
|
||||||
|
>
|
||||||
|
<n-avatar
|
||||||
|
size="small"
|
||||||
|
:src="message.type === 'user' ? '/user-avatar.png' : '/ai-avatar.png'"
|
||||||
|
round
|
||||||
|
/>
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-text" v-html="formatMessage(message.content)"></div>
|
||||||
|
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="isLoading" class="message bot-message">
|
||||||
|
<n-avatar size="small" src="/ai-avatar.png" round />
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-text">
|
||||||
|
<n-spin size="small" />
|
||||||
|
<span style="margin-left: 8px">正在思考中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-input">
|
||||||
|
<!-- 预设问题 -->
|
||||||
|
<div v-if="presetQuestions.length > 0 && messages.length === 0" class="preset-questions">
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
v-for="question in presetQuestions"
|
||||||
|
:key="question"
|
||||||
|
size="small"
|
||||||
|
@click="handlePresetQuestion(question)"
|
||||||
|
>
|
||||||
|
{{ question }}
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<n-input
|
||||||
|
v-model:value="inputMessage"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="请输入您的问题..."
|
||||||
|
:rows="3"
|
||||||
|
:maxlength="1000"
|
||||||
|
show-count
|
||||||
|
@keydown.enter.prevent="handleSend"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!inputMessage.trim() || isLoading"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { ArrowLeftOutlined } from '@vicons/antd'
|
||||||
|
import { aiAppApi } from './aiApp'
|
||||||
|
import type { AiApp } from './type/aiApp'
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
type: 'user' | 'bot'
|
||||||
|
content: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const appId = route.params.id as string
|
||||||
|
const appInfo = ref<AiApp | null>(null)
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const inputMessage = ref('')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const chatContentRef = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const presetQuestions = ref<string[]>([])
|
||||||
|
|
||||||
|
// 获取应用信息
|
||||||
|
const loadAppInfo = async () => {
|
||||||
|
try {
|
||||||
|
const response = await aiAppApi.getAppById({ id: appId })
|
||||||
|
if (response.success) {
|
||||||
|
appInfo.value = response.result
|
||||||
|
// 解析预设问题
|
||||||
|
if (appInfo.value.presetQuestion) {
|
||||||
|
presetQuestions.value = appInfo.value.presetQuestion.split('\n').filter(q => q.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('加载应用信息失败')
|
||||||
|
console.error('Load app info error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取应用图标
|
||||||
|
const getAppIcon = (icon: string) => {
|
||||||
|
return icon ? `/api/sys/common/static/${icon}` : '/default-app-icon.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!inputMessage.value.trim() || isLoading.value) return
|
||||||
|
|
||||||
|
const userMessage: ChatMessage = {
|
||||||
|
type: 'user',
|
||||||
|
content: inputMessage.value.trim(),
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.value.push(userMessage)
|
||||||
|
const currentInput = inputMessage.value.trim()
|
||||||
|
inputMessage.value = ''
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 这里应该调用实际的AI对话接口
|
||||||
|
// 暂时使用模拟响应
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
const botMessage: ChatMessage = {
|
||||||
|
type: 'bot',
|
||||||
|
content: `您好!我收到了您的问题:"${currentInput}"。这是一个模拟回复,实际的AI对话功能需要连接到具体的AI服务。`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.value.push(botMessage)
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
} catch (error) {
|
||||||
|
message.error('发送消息失败')
|
||||||
|
console.error('Send message error:', error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理预设问题
|
||||||
|
const handlePresetQuestion = (question: string) => {
|
||||||
|
inputMessage.value = question
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回
|
||||||
|
const handleBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化消息
|
||||||
|
const formatMessage = (content: string) => {
|
||||||
|
return content.replace(/\n/g, '<br>')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (chatContentRef.value) {
|
||||||
|
chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAppInfo()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ai-app-chat {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f5f5f5;
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
.app-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.app-details {
|
||||||
|
.app-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-desc {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 24px;
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
&.user-message {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
background: #1890ff;
|
||||||
|
color: white;
|
||||||
|
border-radius: 18px 18px 4px 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bot-message {
|
||||||
|
.message-content {
|
||||||
|
background: #f6f6f6;
|
||||||
|
color: #333;
|
||||||
|
border-radius: 18px 18px 18px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
.preset-questions {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
|
.n-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ai-app-chat {
|
||||||
|
.chat-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
.chat-header,
|
||||||
|
.chat-content,
|
||||||
|
.chat-input {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-content {
|
||||||
|
.message-list {
|
||||||
|
.message {
|
||||||
|
.message-content {
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
796
src/views/Ai/AiAppList-NaiveUI.vue
Normal file
796
src/views/Ai/AiAppList-NaiveUI.vue
Normal file
@ -0,0 +1,796 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-app-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" responsive="screen">
|
||||||
|
<n-form-item-gi :span="searchFormSpan.name" label="应用名称" path="name">
|
||||||
|
<n-input
|
||||||
|
v-model:value="searchForm.name"
|
||||||
|
placeholder="请输入应用名称"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi :span="searchFormSpan.type" label="应用类型" path="type">
|
||||||
|
<n-select
|
||||||
|
v-model:value="searchForm.type"
|
||||||
|
placeholder="请选择应用类型"
|
||||||
|
clearable
|
||||||
|
:options="appTypeOptions"
|
||||||
|
/>
|
||||||
|
</n-form-item-gi>
|
||||||
|
<n-form-item-gi :span="searchFormSpan.actions">
|
||||||
|
<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="app-cards-container" :bordered="false">
|
||||||
|
<n-grid
|
||||||
|
:cols="24"
|
||||||
|
:x-gap="20"
|
||||||
|
:y-gap="20"
|
||||||
|
responsive="screen"
|
||||||
|
>
|
||||||
|
<!-- 创建应用卡片 -->
|
||||||
|
<n-grid-item :span="gridSpan">
|
||||||
|
<n-card
|
||||||
|
class="add-app-card"
|
||||||
|
hoverable
|
||||||
|
@click="handleCreateApp"
|
||||||
|
>
|
||||||
|
<div class="add-app-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 appList" :key="item.id" :span="gridSpan">
|
||||||
|
<n-card
|
||||||
|
class="app-card"
|
||||||
|
hoverable
|
||||||
|
@click="handleEditApp(item)"
|
||||||
|
>
|
||||||
|
<div class="app-header">
|
||||||
|
<n-avatar
|
||||||
|
:size="40"
|
||||||
|
:src="getAppIcon(item.icon)"
|
||||||
|
fallback-src="/default-app-icon.png"
|
||||||
|
round
|
||||||
|
/>
|
||||||
|
<div class="app-info">
|
||||||
|
<div class="app-name">{{ item.name }}</div>
|
||||||
|
<div class="app-meta">
|
||||||
|
<n-tag
|
||||||
|
v-if="item.status === 'release'"
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
已发布
|
||||||
|
</n-tag>
|
||||||
|
<n-tag
|
||||||
|
v-else-if="item.status === 'disable'"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
已禁用
|
||||||
|
</n-tag>
|
||||||
|
<span class="creator">创建者:{{ item.createBy_dictText || item.createBy }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-type-tag">
|
||||||
|
<n-tag
|
||||||
|
v-if="item.type === 'chatSimple'"
|
||||||
|
type="info"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
简单配置
|
||||||
|
</n-tag>
|
||||||
|
<n-tag
|
||||||
|
v-else-if="item.type === 'chatFlow'"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
高级编排
|
||||||
|
</n-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-description">
|
||||||
|
{{ item.descr || '暂无描述' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-actions" @click.stop>
|
||||||
|
<n-space>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
text
|
||||||
|
@click="handlePreview(item.id)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><PlayCircleOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
演示
|
||||||
|
</n-tooltip>
|
||||||
|
|
||||||
|
<n-tooltip v-if="item.status !== 'release'" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
text
|
||||||
|
@click="handleDelete(item)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><DeleteOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
删除
|
||||||
|
</n-tooltip>
|
||||||
|
|
||||||
|
<n-dropdown
|
||||||
|
:options="getActionOptions(item)"
|
||||||
|
@select="(key) => handleActionSelect(key, item)"
|
||||||
|
>
|
||||||
|
<n-button text>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><SendOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-dropdown>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</n-grid-item>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-container" v-if="appList.length > 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
|
||||||
|
@update:page="handlePageChange"
|
||||||
|
@update:page-size="handlePageSizeChange"
|
||||||
|
>
|
||||||
|
<template #prefix="{ total }">
|
||||||
|
共 {{ total }} 条
|
||||||
|
</template>
|
||||||
|
</n-pagination>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<!-- 创建/编辑应用弹窗 -->
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showAppModal"
|
||||||
|
preset="card"
|
||||||
|
:title="appModalTitle"
|
||||||
|
:style="modalStyle"
|
||||||
|
:mask-closable="false"
|
||||||
|
>
|
||||||
|
<AiAppForm
|
||||||
|
ref="appFormRef"
|
||||||
|
:form-data="currentApp"
|
||||||
|
:is-edit="isEditMode"
|
||||||
|
@submit="handleAppSubmit"
|
||||||
|
/>
|
||||||
|
<template #action>
|
||||||
|
<n-space>
|
||||||
|
<n-button @click="showAppModal = false">取消</n-button>
|
||||||
|
<n-button type="primary" @click="handleAppFormSubmit">确定</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- 应用设置弹窗 -->
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showSettingModal"
|
||||||
|
preset="card"
|
||||||
|
title="应用设置"
|
||||||
|
:style="settingModalStyle"
|
||||||
|
:mask-closable="false"
|
||||||
|
>
|
||||||
|
<AiAppSetting
|
||||||
|
ref="appSettingRef"
|
||||||
|
:app-data="currentApp"
|
||||||
|
@success="handleSettingSuccess"
|
||||||
|
/>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- 发布配置弹窗 -->
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showPublishModal"
|
||||||
|
preset="card"
|
||||||
|
:title="publishModalTitle"
|
||||||
|
:style="publishModalStyle"
|
||||||
|
:mask-closable="false"
|
||||||
|
>
|
||||||
|
<AiAppPublish
|
||||||
|
ref="appPublishRef"
|
||||||
|
:app-data="currentApp"
|
||||||
|
:publish-type="publishType"
|
||||||
|
/>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed, h } from 'vue'
|
||||||
|
import { useMessage, useDialog } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
MenuOutlined
|
||||||
|
} from '@vicons/antd'
|
||||||
|
import { aiAppApi } from './aiApp'
|
||||||
|
import type { AiApp, SearchForm, Pagination } from './type/aiApp'
|
||||||
|
import AiAppForm from './component/AiAppForm.vue'
|
||||||
|
import AiAppSetting from './component/AiAppSetting.vue'
|
||||||
|
import AiAppPublish from './component/AiAppPublish.vue'
|
||||||
|
|
||||||
|
// 组件引用
|
||||||
|
const searchFormRef = ref()
|
||||||
|
const appFormRef = ref()
|
||||||
|
const appSettingRef = ref()
|
||||||
|
const appPublishRef = ref()
|
||||||
|
|
||||||
|
// 消息和对话框
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const appList = ref<AiApp[]>([])
|
||||||
|
const searchForm = reactive<SearchForm>({
|
||||||
|
name: '',
|
||||||
|
type: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagination = reactive<Pagination>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹窗状态
|
||||||
|
const showAppModal = ref(false)
|
||||||
|
const showSettingModal = ref(false)
|
||||||
|
const showPublishModal = ref(false)
|
||||||
|
const isEditMode = ref(false)
|
||||||
|
const currentApp = ref<Partial<AiApp>>({})
|
||||||
|
const publishType = ref<'web' | 'menu'>('web')
|
||||||
|
|
||||||
|
// 应用类型选项
|
||||||
|
const appTypeOptions = [
|
||||||
|
{ label: '简单配置', value: 'chatSimple' },
|
||||||
|
{ label: '高级编排', value: 'chatFlow' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const appModalTitle = computed(() => isEditMode.value ? '编辑应用' : '创建应用')
|
||||||
|
const publishModalTitle = computed(() => publishType.value === 'web' ? '嵌入网站' : '配置菜单')
|
||||||
|
|
||||||
|
// 响应式网格布局
|
||||||
|
const gridSpan = computed(() => {
|
||||||
|
// 根据屏幕尺寸动态调整网格列数
|
||||||
|
// 24列系统:超大屏6列(4span),大屏4列(6span),中屏3列(8span),小屏2列(12span),超小屏1列(24span)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const width = window.innerWidth
|
||||||
|
if (width >= 1600) return 4 // 超大屏:6列
|
||||||
|
if (width >= 1200) return 6 // 大屏:4列
|
||||||
|
if (width >= 992) return 8 // 中屏:3列
|
||||||
|
if (width >= 768) return 12 // 小屏:2列
|
||||||
|
return 24 // 超小屏:1列
|
||||||
|
}
|
||||||
|
return 6 // 默认值
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索表单响应式布局
|
||||||
|
const searchFormSpan = computed(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const width = window.innerWidth
|
||||||
|
if (width >= 1200) {
|
||||||
|
return { name: 6, type: 6, actions: 6 } // 大屏:三列
|
||||||
|
}
|
||||||
|
if (width >= 768) {
|
||||||
|
return { name: 8, type: 8, actions: 8 } // 中屏:三列紧凑
|
||||||
|
}
|
||||||
|
return { name: 24, type: 24, actions: 24 } // 小屏:单列
|
||||||
|
}
|
||||||
|
return { name: 6, type: 6, actions: 6 } // 默认值
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹窗样式响应式
|
||||||
|
const modalStyle = computed(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const width = window.innerWidth
|
||||||
|
if (width <= 768) {
|
||||||
|
return 'width: 95vw; max-width: 500px'
|
||||||
|
}
|
||||||
|
return 'width: 800px'
|
||||||
|
}
|
||||||
|
return 'width: 800px'
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingModalStyle = computed(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const width = window.innerWidth
|
||||||
|
if (width <= 768) {
|
||||||
|
return 'width: 95vw; max-width: 600px'
|
||||||
|
}
|
||||||
|
if (width <= 1024) {
|
||||||
|
return 'width: 90vw; max-width: 900px'
|
||||||
|
}
|
||||||
|
return 'width: 1200px'
|
||||||
|
}
|
||||||
|
return 'width: 1200px'
|
||||||
|
})
|
||||||
|
|
||||||
|
const publishModalStyle = computed(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const width = window.innerWidth
|
||||||
|
if (width <= 768) {
|
||||||
|
return 'width: 95vw; max-width: 400px'
|
||||||
|
}
|
||||||
|
return 'width: 600px'
|
||||||
|
}
|
||||||
|
return 'width: 600px'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取应用图标
|
||||||
|
const getAppIcon = (icon?: string) => {
|
||||||
|
return icon ? `/api/sys/common/static/${icon}` : '/default-app-icon.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取操作选项
|
||||||
|
const getActionOptions = (item: AiApp) => {
|
||||||
|
const options = []
|
||||||
|
|
||||||
|
if (item.status === 'enable') {
|
||||||
|
options.push({
|
||||||
|
label: '发布',
|
||||||
|
key: 'release',
|
||||||
|
icon: () => h(RocketOutlined)
|
||||||
|
})
|
||||||
|
} else if (item.status === 'release') {
|
||||||
|
options.push({
|
||||||
|
label: '取消发布',
|
||||||
|
key: 'un-release',
|
||||||
|
icon: () => h(RocketOutlined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
label: '嵌入网站',
|
||||||
|
key: 'web',
|
||||||
|
icon: () => h(GlobalOutlined)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据路由判断是否显示菜单配置
|
||||||
|
const currentPath = window.location.pathname
|
||||||
|
if (currentPath !== '/myapps/ai/app') {
|
||||||
|
options.push({
|
||||||
|
label: '配置菜单',
|
||||||
|
key: 'menu',
|
||||||
|
icon: () => h(MenuOutlined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载应用列表
|
||||||
|
const loadAppList = async () => {
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
pageNo: pagination.page,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
column: 'createTime',
|
||||||
|
order: 'desc',
|
||||||
|
...searchForm
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await aiAppApi.getAppList(params)
|
||||||
|
if (response.success) {
|
||||||
|
appList.value = response.result.records
|
||||||
|
pagination.total = response.result.total
|
||||||
|
} else {
|
||||||
|
appList.value = []
|
||||||
|
pagination.total = 0
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('加载应用列表失败')
|
||||||
|
console.error('Load app list error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.page = 1
|
||||||
|
loadAppList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
searchFormRef.value?.restoreValidation()
|
||||||
|
Object.assign(searchForm, { name: '', type: '' })
|
||||||
|
pagination.page = 1
|
||||||
|
loadAppList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页变化
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
pagination.page = page
|
||||||
|
loadAppList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = (pageSize: number) => {
|
||||||
|
pagination.pageSize = pageSize
|
||||||
|
pagination.page = 1
|
||||||
|
loadAppList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建应用
|
||||||
|
const handleCreateApp = () => {
|
||||||
|
isEditMode.value = false
|
||||||
|
currentApp.value = {}
|
||||||
|
showAppModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑应用
|
||||||
|
const handleEditApp = (app: AiApp) => {
|
||||||
|
isEditMode.value = true
|
||||||
|
currentApp.value = { ...app }
|
||||||
|
showSettingModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览应用
|
||||||
|
const handlePreview = (appId: string) => {
|
||||||
|
const url = `/ai/app/chat/${appId}`
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除应用
|
||||||
|
const handleDelete = (app: AiApp) => {
|
||||||
|
dialog.warning({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `是否删除名称为"${app.name}"的应用?`,
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await aiAppApi.deleteApp({ id: app.id, name: app.name })
|
||||||
|
message.success('删除成功')
|
||||||
|
|
||||||
|
// 如果当前页只有一条数据且不是第一页,则回到上一页
|
||||||
|
if (appList.value.length === 1 && pagination.page > 1) {
|
||||||
|
pagination.page -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAppList()
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败')
|
||||||
|
console.error('Delete app error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作选择
|
||||||
|
const handleActionSelect = (key: string, app: AiApp) => {
|
||||||
|
currentApp.value = app
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'release':
|
||||||
|
case 'un-release':
|
||||||
|
handleRelease(app, key === 'release')
|
||||||
|
break
|
||||||
|
case 'web':
|
||||||
|
publishType.value = 'web'
|
||||||
|
showPublishModal.value = true
|
||||||
|
break
|
||||||
|
case 'menu':
|
||||||
|
publishType.value = 'menu'
|
||||||
|
showPublishModal.value = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布/取消发布
|
||||||
|
const handleRelease = (app: AiApp, toRelease: boolean) => {
|
||||||
|
const title = toRelease ? '发布应用' : '取消发布应用'
|
||||||
|
const content = toRelease
|
||||||
|
? '确定要发布应用吗?发布后将不允许修改应用。'
|
||||||
|
: '确定要取消发布应用吗?'
|
||||||
|
|
||||||
|
dialog.warning({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
const success = await aiAppApi.releaseApp(app.id, toRelease)
|
||||||
|
if (success) {
|
||||||
|
app.status = toRelease ? 'release' : 'enable'
|
||||||
|
message.success(toRelease ? '发布成功' : '取消发布成功')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(toRelease ? '发布失败' : '取消发布失败')
|
||||||
|
console.error('Release app error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用表单提交
|
||||||
|
const handleAppFormSubmit = () => {
|
||||||
|
appFormRef.value?.submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAppSubmit = async (formData: AiApp) => {
|
||||||
|
try {
|
||||||
|
await aiAppApi.saveApp(formData)
|
||||||
|
message.success(isEditMode.value ? '编辑成功' : '创建成功')
|
||||||
|
showAppModal.value = false
|
||||||
|
|
||||||
|
if (!isEditMode.value) {
|
||||||
|
// 创建成功后打开设置弹窗
|
||||||
|
currentApp.value = formData
|
||||||
|
showSettingModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAppList()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(isEditMode.value ? '编辑失败' : '创建失败')
|
||||||
|
console.error('Save app error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置成功
|
||||||
|
const handleSettingSuccess = () => {
|
||||||
|
showSettingModal.value = false
|
||||||
|
loadAppList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadAppList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ai-app-container {
|
||||||
|
padding: 24px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
|
||||||
|
// 响应式padding
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
.n-form-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-cards-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
.add-app-card {
|
||||||
|
height: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
// 响应式高度
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-app-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.add-icon {
|
||||||
|
color: #1890ff;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f0f8ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card {
|
||||||
|
height: 180px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
// 响应式高度
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.app-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.creator {
|
||||||
|
color: #666;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-type-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-description {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-card) {
|
||||||
|
.n-card__content {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-grid-item) {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.n-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-button--text-type) {
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
245
src/views/Ai/aiApp.ts
Normal file
245
src/views/Ai/aiApp.ts
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import { http } from './utils/http'
|
||||||
|
import type { AiApp, AppListParams, AppListResponse, ApiResponse } from './type/aiApp'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI应用相关API接口
|
||||||
|
*/
|
||||||
|
export const aiAppApi = {
|
||||||
|
/**
|
||||||
|
* 获取应用列表
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
getAppList(params: AppListParams): Promise<AppListResponse> {
|
||||||
|
return http.get('/airag/app/list', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询应用详情
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
getAppById(params: { id: string }): Promise<ApiResponse<AiApp>> {
|
||||||
|
return http.get('/airag/app/queryById', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存应用(新增或编辑)
|
||||||
|
* @param data 应用数据
|
||||||
|
*/
|
||||||
|
saveApp(data: Partial<AiApp>): Promise<ApiResponse<any>> {
|
||||||
|
return http.put('/airag/app/edit', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除应用
|
||||||
|
* @param params 删除参数
|
||||||
|
*/
|
||||||
|
deleteApp(params: { id: string; name: string }): Promise<ApiResponse<any>> {
|
||||||
|
return http.delete('/airag/app/delete', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布/取消发布应用
|
||||||
|
* @param appId 应用ID
|
||||||
|
* @param release 是否发布
|
||||||
|
*/
|
||||||
|
releaseApp(appId: string, release: boolean): Promise<boolean> {
|
||||||
|
return http.post('/airag/app/release', null, {
|
||||||
|
params: {
|
||||||
|
id: appId,
|
||||||
|
release: release
|
||||||
|
}
|
||||||
|
}).then(response => {
|
||||||
|
return response.success || false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查询知识库
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
getKnowledgeBatch(params: { ids: string }): Promise<ApiResponse<any[]>> {
|
||||||
|
return http.get('/airag/knowledge/query/batch/byId', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询流程
|
||||||
|
* @param params 查询参数
|
||||||
|
*/
|
||||||
|
getFlowById(params: { id: string }): Promise<ApiResponse<any>> {
|
||||||
|
return http.get('/airag/flow/queryById', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成提示词
|
||||||
|
* @param params 生成参数
|
||||||
|
*/
|
||||||
|
generatePrompt(params: { prompt: string }): Promise<ReadableStream> {
|
||||||
|
return http.post(`/airag/app/prompt/generate?prompt=${encodeURIComponent(params.prompt)}`, null, {
|
||||||
|
responseType: 'stream',
|
||||||
|
timeout: 5 * 60 * 1000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用类型字典
|
||||||
|
*/
|
||||||
|
export const APP_TYPE_DICT = {
|
||||||
|
chatSimple: '简单配置',
|
||||||
|
chatFlow: '高级编排'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用状态字典
|
||||||
|
*/
|
||||||
|
export const APP_STATUS_DICT = {
|
||||||
|
enable: '启用',
|
||||||
|
disable: '禁用',
|
||||||
|
release: '已发布'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取应用类型标签
|
||||||
|
*/
|
||||||
|
export const getAppTypeLabel = (type: string): string => {
|
||||||
|
return APP_TYPE_DICT[type as keyof typeof APP_TYPE_DICT] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取应用状态标签
|
||||||
|
*/
|
||||||
|
export const getAppStatusLabel = (status: string): string => {
|
||||||
|
return APP_STATUS_DICT[status as keyof typeof APP_STATUS_DICT] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取应用图标URL
|
||||||
|
*/
|
||||||
|
export const getAppIconUrl = (icon?: string): string => {
|
||||||
|
if (!icon) {
|
||||||
|
return '/images/default-app-icon.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是完整URL,直接返回
|
||||||
|
if (icon.startsWith('http')) {
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼接文件访问URL
|
||||||
|
return `/api/sys/common/static/${icon}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用操作权限检查
|
||||||
|
*/
|
||||||
|
export const checkAppPermission = (app: AiApp, action: string): boolean => {
|
||||||
|
switch (action) {
|
||||||
|
case 'edit':
|
||||||
|
// 已发布的应用不允许编辑
|
||||||
|
return app.status !== 'release'
|
||||||
|
case 'delete':
|
||||||
|
// 已发布的应用不允许删除
|
||||||
|
return app.status !== 'release'
|
||||||
|
case 'release':
|
||||||
|
// 只有启用状态的应用可以发布
|
||||||
|
return app.status === 'enable'
|
||||||
|
case 'un-release':
|
||||||
|
// 只有已发布的应用可以取消发布
|
||||||
|
return app.status === 'release'
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化应用数据用于显示
|
||||||
|
*/
|
||||||
|
export const formatAppForDisplay = (app: AiApp) => {
|
||||||
|
return {
|
||||||
|
...app,
|
||||||
|
typeLabel: getAppTypeLabel(app.type),
|
||||||
|
statusLabel: getAppStatusLabel(app.status),
|
||||||
|
iconUrl: getAppIconUrl(app.icon),
|
||||||
|
canEdit: checkAppPermission(app, 'edit'),
|
||||||
|
canDelete: checkAppPermission(app, 'delete'),
|
||||||
|
canRelease: checkAppPermission(app, 'release'),
|
||||||
|
canUnRelease: checkAppPermission(app, 'un-release')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建应用聊天URL
|
||||||
|
*/
|
||||||
|
export const buildChatUrl = (appId: string, mode?: string): string => {
|
||||||
|
const baseUrl = '/ai/app/chat'
|
||||||
|
if (mode) {
|
||||||
|
return `${baseUrl}/${appId}?mode=${mode}`
|
||||||
|
}
|
||||||
|
return `${baseUrl}/${appId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建应用嵌入代码
|
||||||
|
*/
|
||||||
|
export const buildEmbedCode = (appId: string, type: 'iframe' | 'script' = 'iframe'): string => {
|
||||||
|
const baseUrl = window.location.origin
|
||||||
|
const chatUrl = `${baseUrl}/ai/app/chat/${appId}`
|
||||||
|
|
||||||
|
if (type === 'iframe') {
|
||||||
|
return `<iframe src="${chatUrl}" width="100%" height="600" frameborder="0"></iframe>`
|
||||||
|
} else {
|
||||||
|
return `<script>
|
||||||
|
(function() {
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.src = '${baseUrl}/js/ai-chat-widget.js';
|
||||||
|
script.setAttribute('data-app-id', '${appId}');
|
||||||
|
document.head.appendChild(script);
|
||||||
|
})();
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成菜单配置SQL
|
||||||
|
*/
|
||||||
|
export const generateMenuSql = (app: AiApp): string => {
|
||||||
|
const menuId = `ai_app_${app.id}`
|
||||||
|
const menuUrl = `/ai/chat/${app.id}`
|
||||||
|
|
||||||
|
return `-- AI应用菜单配置SQL
|
||||||
|
INSERT INTO sys_permission(
|
||||||
|
id, parent_id, name, url, component, component_name,
|
||||||
|
redirect, menu_type, perms, perms_type, sort_no,
|
||||||
|
always_show, icon, is_route, is_leaf, keep_alive,
|
||||||
|
hidden, hide_tab, description, status, del_flag,
|
||||||
|
rule_flag, create_by, create_time, internal_or_external
|
||||||
|
) VALUES (
|
||||||
|
'${menuId}',
|
||||||
|
NULL,
|
||||||
|
'${app.name}',
|
||||||
|
'${menuUrl}',
|
||||||
|
'super/airag/aiapp/chat/AiChat',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
1,
|
||||||
|
NULL,
|
||||||
|
'0',
|
||||||
|
1.00,
|
||||||
|
0,
|
||||||
|
'ant-design:robot-outlined',
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'${app.descr || app.name}',
|
||||||
|
'1',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'admin',
|
||||||
|
NOW(),
|
||||||
|
0
|
||||||
|
);`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default aiAppApi
|
382
src/views/Ai/component/AiAppForm.vue
Normal file
382
src/views/Ai/component/AiAppForm.vue
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-app-form">
|
||||||
|
<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="描述该应用的应用场景及用途"
|
||||||
|
:rows="4"
|
||||||
|
:maxlength="256"
|
||||||
|
show-count
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="应用图标" path="icon">
|
||||||
|
<div class="icon-upload-container">
|
||||||
|
<n-upload
|
||||||
|
v-model:file-list="iconFileList"
|
||||||
|
:max="1"
|
||||||
|
list-type="image-card"
|
||||||
|
:on-before-upload="handleBeforeUpload"
|
||||||
|
:on-finish="handleUploadFinish"
|
||||||
|
:on-remove="handleRemoveIcon"
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<n-upload-dragger v-if="!formData.icon">
|
||||||
|
<div class="upload-content">
|
||||||
|
<n-icon size="48" :depth="3">
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
</n-icon>
|
||||||
|
<n-text style="font-size: 16px">
|
||||||
|
点击或者拖动文件到该区域来上传
|
||||||
|
</n-text>
|
||||||
|
<n-p depth="3" style="margin: 8px 0 0 0">
|
||||||
|
支持 JPG、PNG、GIF 格式,建议尺寸 200x200px
|
||||||
|
</n-p>
|
||||||
|
</div>
|
||||||
|
</n-upload-dragger>
|
||||||
|
</n-upload>
|
||||||
|
|
||||||
|
<div v-if="formData.icon && !iconFileList.length" class="current-icon">
|
||||||
|
<n-image
|
||||||
|
:src="getIconUrl(formData.icon)"
|
||||||
|
width="100"
|
||||||
|
height="100"
|
||||||
|
object-fit="cover"
|
||||||
|
preview-disabled
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
type="error"
|
||||||
|
ghost
|
||||||
|
@click="handleRemoveCurrentIcon"
|
||||||
|
class="remove-btn"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item v-if="!isEdit" label="应用类型" path="type">
|
||||||
|
<div class="app-type-selector">
|
||||||
|
<n-radio-group v-model:value="formData.type">
|
||||||
|
<n-space vertical>
|
||||||
|
<n-card
|
||||||
|
v-for="option in appTypeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="type-option-card"
|
||||||
|
:class="{ active: formData.type === option.value }"
|
||||||
|
hoverable
|
||||||
|
@click="formData.type = option.value"
|
||||||
|
>
|
||||||
|
<div class="type-option-content">
|
||||||
|
<n-radio :value="option.value" />
|
||||||
|
<div class="type-info">
|
||||||
|
<div class="type-title">{{ option.label }}</div>
|
||||||
|
<div class="type-description">{{ option.description }}</div>
|
||||||
|
</div>
|
||||||
|
<n-icon v-if="option.icon" size="24" class="type-icon">
|
||||||
|
<component :is="option.icon" />
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</n-space>
|
||||||
|
</n-radio-group>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, watch, computed } from 'vue'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { CloudUploadOutlined, RobotOutlined, SettingOutlined } from '@vicons/antd'
|
||||||
|
import type { FormInst, FormRules, UploadFileInfo } from 'naive-ui'
|
||||||
|
import type { AiApp, AppFormData } from '../type/aiApp'
|
||||||
|
import { uploadApi } from '../upload'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
formData: Partial<AiApp>
|
||||||
|
isEdit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', data: AppFormData): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isEdit: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const formRef = ref<FormInst>()
|
||||||
|
const iconFileList = ref<UploadFileInfo[]>([])
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = reactive<AppFormData>({
|
||||||
|
name: '',
|
||||||
|
descr: '',
|
||||||
|
icon: '',
|
||||||
|
type: 'chatSimple',
|
||||||
|
...props.formData
|
||||||
|
})
|
||||||
|
|
||||||
|
// 应用类型选项
|
||||||
|
const appTypeOptions = [
|
||||||
|
{
|
||||||
|
label: '简单配置',
|
||||||
|
value: 'chatSimple',
|
||||||
|
description: '通过简单的配置快速创建AI应用,适合大多数场景',
|
||||||
|
icon: RobotOutlined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '高级编排',
|
||||||
|
value: 'chatFlow',
|
||||||
|
description: '通过流程编排创建复杂的AI应用,支持多步骤处理',
|
||||||
|
icon: SettingOutlined
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
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' }
|
||||||
|
],
|
||||||
|
type: [
|
||||||
|
{ required: true, message: '请选择应用类型', trigger: 'change' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取图标URL
|
||||||
|
const getIconUrl = (icon: string) => {
|
||||||
|
if (!icon) return ''
|
||||||
|
if (icon.startsWith('http')) return icon
|
||||||
|
return `/api/sys/common/static/${icon}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传前检查
|
||||||
|
const handleBeforeUpload = (data: { file: UploadFileInfo }) => {
|
||||||
|
const file = data.file.file
|
||||||
|
if (!file) return false
|
||||||
|
|
||||||
|
// 检查文件类型
|
||||||
|
const isImage = file.type.startsWith('image/')
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小(2MB)
|
||||||
|
const isLt2M = file.size / 1024 / 1024 < 2
|
||||||
|
if (!isLt2M) {
|
||||||
|
message.error('图片大小不能超过2MB')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传完成
|
||||||
|
const handleUploadFinish = async ({ file, event }: { file: UploadFileInfo, event?: ProgressEvent }) => {
|
||||||
|
try {
|
||||||
|
if (file.file) {
|
||||||
|
const response = await uploadApi.uploadImage(file.file)
|
||||||
|
if (response.success) {
|
||||||
|
formData.icon = response.result.url
|
||||||
|
message.success('图标上传成功')
|
||||||
|
} else {
|
||||||
|
message.error('图标上传失败')
|
||||||
|
iconFileList.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('图标上传失败')
|
||||||
|
iconFileList.value = []
|
||||||
|
console.error('Upload error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除图标
|
||||||
|
const handleRemoveIcon = () => {
|
||||||
|
formData.icon = ''
|
||||||
|
iconFileList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除当前图标
|
||||||
|
const handleRemoveCurrentIcon = () => {
|
||||||
|
formData.icon = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听props变化
|
||||||
|
watch(() => props.formData, (newData) => {
|
||||||
|
Object.assign(formData, newData)
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
emit('submit', { ...formData })
|
||||||
|
} catch (error) {
|
||||||
|
message.error('请检查表单填写')
|
||||||
|
console.error('Form validation error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const reset = () => {
|
||||||
|
formRef.value?.restoreValidation()
|
||||||
|
Object.assign(formData, {
|
||||||
|
name: '',
|
||||||
|
descr: '',
|
||||||
|
icon: '',
|
||||||
|
type: 'chatSimple'
|
||||||
|
})
|
||||||
|
iconFileList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法
|
||||||
|
defineExpose({
|
||||||
|
submit,
|
||||||
|
reset,
|
||||||
|
formData
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ai-app-form {
|
||||||
|
.icon-upload-container {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.upload-content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-icon {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-type-selector {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.type-option-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--n-color-target);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--n-color-target);
|
||||||
|
background-color: var(--n-color-target-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
// 响应式布局
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.type-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
// 响应式字体
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-description {
|
||||||
|
color: var(--n-text-color-2);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
// 响应式字体
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-icon {
|
||||||
|
color: var(--n-color-target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式表单布局
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(.n-form-item) {
|
||||||
|
.n-form-item-label {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-upload-container {
|
||||||
|
.upload-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-upload-file-list) {
|
||||||
|
.n-upload-file {
|
||||||
|
.n-upload-file-info {
|
||||||
|
.n-upload-file-info__thumbnail {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
322
src/views/Ai/component/AiAppPublish-Simple.vue
Normal file
322
src/views/Ai/component/AiAppPublish-Simple.vue
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-app-publish">
|
||||||
|
<!-- 嵌入网站 -->
|
||||||
|
<div v-if="publishType === 'web'" class="web-embed">
|
||||||
|
<div class="embed-type-selector">
|
||||||
|
<n-radio-group v-model:value="embedType">
|
||||||
|
<n-space>
|
||||||
|
<n-card
|
||||||
|
class="embed-option"
|
||||||
|
:class="{ active: embedType === 'iframe' }"
|
||||||
|
hoverable
|
||||||
|
@click="embedType = 'iframe'"
|
||||||
|
>
|
||||||
|
<div class="embed-option-content">
|
||||||
|
<n-radio value="iframe" />
|
||||||
|
<div class="embed-info">
|
||||||
|
<div class="embed-title">iframe 嵌入</div>
|
||||||
|
<div class="embed-desc">完整页面嵌入</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<n-card
|
||||||
|
class="embed-option"
|
||||||
|
:class="{ active: embedType === 'script' }"
|
||||||
|
hoverable
|
||||||
|
@click="embedType = 'script'"
|
||||||
|
>
|
||||||
|
<div class="embed-option-content">
|
||||||
|
<n-radio value="script" />
|
||||||
|
<div class="embed-info">
|
||||||
|
<div class="embed-title">脚本嵌入</div>
|
||||||
|
<div class="embed-desc">悬浮窗口形式</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</n-space>
|
||||||
|
</n-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="embed-config">
|
||||||
|
<h4>嵌入配置</h4>
|
||||||
|
<n-form label-placement="left" :label-width="100">
|
||||||
|
<n-form-item v-if="embedType === 'iframe'" label="宽度">
|
||||||
|
<n-input v-model:value="embedConfig.width" placeholder="100%" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="embedType === 'iframe'" label="高度">
|
||||||
|
<n-input v-model:value="embedConfig.height" placeholder="600px" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="embed-code">
|
||||||
|
<div class="code-header">
|
||||||
|
<h4>{{ embedType === 'iframe' ? 'HTML' : 'JavaScript' }} 代码</h4>
|
||||||
|
<n-button type="primary" @click="copyEmbedCode">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><CopyOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
复制代码
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
:value="embedCode"
|
||||||
|
readonly
|
||||||
|
:rows="8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 配置菜单 -->
|
||||||
|
<div v-else-if="publishType === 'menu'" class="menu-config">
|
||||||
|
<n-form
|
||||||
|
:model="menuConfig"
|
||||||
|
label-placement="left"
|
||||||
|
:label-width="100"
|
||||||
|
>
|
||||||
|
<n-form-item label="菜单名称">
|
||||||
|
<n-input
|
||||||
|
v-model:value="menuConfig.menuName"
|
||||||
|
placeholder="请输入菜单名称"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="菜单地址">
|
||||||
|
<n-input
|
||||||
|
v-model:value="menuConfig.menuUrl"
|
||||||
|
placeholder="菜单访问地址"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="菜单图标">
|
||||||
|
<n-input
|
||||||
|
v-model:value="menuConfig.icon"
|
||||||
|
placeholder="菜单图标"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="排序号">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="menuConfig.sortNo"
|
||||||
|
:min="0"
|
||||||
|
placeholder="排序号"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<div class="menu-actions">
|
||||||
|
<n-space>
|
||||||
|
<n-button type="primary" @click="copyMenuConfig">
|
||||||
|
复制菜单配置
|
||||||
|
</n-button>
|
||||||
|
<n-button type="primary" @click="copySqlScript">
|
||||||
|
复制SQL脚本
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sql-script">
|
||||||
|
<div class="code-header">
|
||||||
|
<h4>SQL 脚本</h4>
|
||||||
|
<n-button text @click="copySqlScript">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><CopyOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
复制
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
:value="sqlScript"
|
||||||
|
readonly
|
||||||
|
:rows="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, reactive, watch } from 'vue'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { CopyOutlined } from '@vicons/antd'
|
||||||
|
import type { AiApp } from '../type/aiApp'
|
||||||
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appData: AiApp
|
||||||
|
publishType: 'web' | 'menu'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
// 嵌入类型
|
||||||
|
const embedType = ref<'iframe' | 'script'>('iframe')
|
||||||
|
|
||||||
|
// 嵌入配置
|
||||||
|
const embedConfig = reactive({
|
||||||
|
width: '100%',
|
||||||
|
height: '600px'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 菜单配置
|
||||||
|
const menuConfig = reactive({
|
||||||
|
menuName: props.appData.name,
|
||||||
|
menuUrl: `/ai/chat/${props.appData.id}`,
|
||||||
|
icon: 'ant-design:robot-outlined',
|
||||||
|
sortNo: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// 嵌入代码
|
||||||
|
const embedCode = computed(() => {
|
||||||
|
const baseUrl = window.location.origin
|
||||||
|
const chatUrl = `${baseUrl}/ai/app/chat/${props.appData.id}`
|
||||||
|
|
||||||
|
if (embedType.value === 'iframe') {
|
||||||
|
return `<iframe src="${chatUrl}" width="${embedConfig.width}" height="${embedConfig.height}" frameborder="0"></iframe>`
|
||||||
|
} else {
|
||||||
|
return `<script>
|
||||||
|
(function() {
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.src = '${baseUrl}/js/ai-chat-widget.js';
|
||||||
|
script.setAttribute('data-app-id', '${props.appData.id}');
|
||||||
|
document.head.appendChild(script);
|
||||||
|
})();
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// SQL脚本
|
||||||
|
const sqlScript = computed(() => {
|
||||||
|
const menuId = `ai_app_${props.appData.id}`
|
||||||
|
return `INSERT INTO sys_permission(id, name, url, icon, sort_no) VALUES ('${menuId}', '${menuConfig.menuName}', '${menuConfig.menuUrl}', '${menuConfig.icon}', ${menuConfig.sortNo});`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 复制嵌入代码
|
||||||
|
const copyEmbedCode = async () => {
|
||||||
|
const success = await copyToClipboard(embedCode.value)
|
||||||
|
if (success) {
|
||||||
|
message.success('代码已复制到剪贴板')
|
||||||
|
} else {
|
||||||
|
message.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制菜单配置
|
||||||
|
const copyMenuConfig = async () => {
|
||||||
|
const config = JSON.stringify(menuConfig, null, 2)
|
||||||
|
const success = await copyToClipboard(config)
|
||||||
|
if (success) {
|
||||||
|
message.success('菜单配置已复制到剪贴板')
|
||||||
|
} else {
|
||||||
|
message.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制SQL脚本
|
||||||
|
const copySqlScript = async () => {
|
||||||
|
const success = await copyToClipboard(sqlScript.value)
|
||||||
|
if (success) {
|
||||||
|
message.success('SQL脚本已复制到剪贴板')
|
||||||
|
} else {
|
||||||
|
message.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听应用数据变化
|
||||||
|
watch(() => props.appData, (newData) => {
|
||||||
|
menuConfig.menuName = newData.name
|
||||||
|
menuConfig.menuUrl = `/ai/chat/${newData.id}`
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ai-app-publish {
|
||||||
|
.web-embed {
|
||||||
|
.embed-type-selector {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.embed-option {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-option-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.embed-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.embed-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-desc {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-config {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-code {
|
||||||
|
.code-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-config {
|
||||||
|
.menu-actions {
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sql-script {
|
||||||
|
.code-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
643
src/views/Ai/component/AiAppPublish.vue
Normal file
643
src/views/Ai/component/AiAppPublish.vue
Normal file
@ -0,0 +1,643 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-app-publish">
|
||||||
|
<!-- 嵌入网站 -->
|
||||||
|
<div v-if="publishType === 'web'" class="web-embed">
|
||||||
|
<div class="embed-type-selector">
|
||||||
|
<n-radio-group v-model:value="embedType">
|
||||||
|
<n-space>
|
||||||
|
<n-card
|
||||||
|
class="embed-option"
|
||||||
|
:class="{ active: embedType === 'iframe' }"
|
||||||
|
hoverable
|
||||||
|
@click="embedType = 'iframe'"
|
||||||
|
>
|
||||||
|
<div class="embed-option-content">
|
||||||
|
<n-radio value="iframe" />
|
||||||
|
<div class="embed-info">
|
||||||
|
<div class="embed-title">iframe 嵌入</div>
|
||||||
|
<div class="embed-desc">完整页面嵌入</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<n-card
|
||||||
|
class="embed-option"
|
||||||
|
:class="{ active: embedType === 'script' }"
|
||||||
|
hoverable
|
||||||
|
@click="embedType = 'script'"
|
||||||
|
>
|
||||||
|
<div class="embed-option-content">
|
||||||
|
<n-radio value="script" />
|
||||||
|
<div class="embed-info">
|
||||||
|
<div class="embed-title">脚本嵌入</div>
|
||||||
|
<div class="embed-desc">悬浮窗口形式</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</n-space>
|
||||||
|
</n-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="embed-config">
|
||||||
|
<h4>嵌入配置</h4>
|
||||||
|
<n-form label-placement="left" :label-width="100">
|
||||||
|
<n-form-item v-if="embedType === 'iframe'" label="宽度">
|
||||||
|
<n-input v-model:value="embedConfig.width" placeholder="100%" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="embedType === 'iframe'" label="高度">
|
||||||
|
<n-input v-model:value="embedConfig.height" placeholder="600px" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="显示标题">
|
||||||
|
<n-switch v-model:value="embedConfig.showHeader" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="embed-code">
|
||||||
|
<div class="code-header">
|
||||||
|
<h4>{{ embedType === 'iframe' ? 'HTML' : 'JavaScript' }} 代码</h4>
|
||||||
|
<n-button type="primary" @click="copyEmbedCode">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><CopyOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
复制代码
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
:value="embedCode"
|
||||||
|
readonly
|
||||||
|
:rows="8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="embed-preview">
|
||||||
|
<h4>预览效果</h4>
|
||||||
|
<n-card>
|
||||||
|
<div class="preview-container">
|
||||||
|
<div v-if="embedType === 'iframe'" class="iframe-preview">
|
||||||
|
<p>iframe 预览:{{ previewUrl }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="script-preview">
|
||||||
|
<div class="chat-widget">
|
||||||
|
<div class="widget-header">
|
||||||
|
<span>{{ appData.name }}</span>
|
||||||
|
<n-button text size="small">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><CloseOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<div class="widget-body">
|
||||||
|
<div class="message">{{ appData.prologue || '您好,我是AI助手,有什么可以帮助您的吗?' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="widget-input">
|
||||||
|
<n-input placeholder="输入消息..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 配置菜单 -->
|
||||||
|
<div v-else-if="publishType === 'menu'" class="menu-config">
|
||||||
|
<n-form
|
||||||
|
:model="menuConfig"
|
||||||
|
label-placement="left"
|
||||||
|
:label-width="100"
|
||||||
|
>
|
||||||
|
<n-form-item label="菜单名称">
|
||||||
|
<n-input
|
||||||
|
v-model:value="menuConfig.menuName"
|
||||||
|
placeholder="请输入菜单名称"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="菜单地址">
|
||||||
|
<n-input
|
||||||
|
v-model:value="menuConfig.menuUrl"
|
||||||
|
placeholder="菜单访问地址"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="菜单图标">
|
||||||
|
<n-input
|
||||||
|
v-model:value="menuConfig.icon"
|
||||||
|
placeholder="菜单图标"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="排序号">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="menuConfig.sortNo"
|
||||||
|
:min="0"
|
||||||
|
placeholder="排序号"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<div class="menu-actions">
|
||||||
|
<n-space>
|
||||||
|
<n-button type="primary" @click="copyMenuConfig">
|
||||||
|
复制菜单配置
|
||||||
|
</n-button>
|
||||||
|
<n-button type="primary" @click="copySqlScript">
|
||||||
|
复制SQL脚本
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sql-script">
|
||||||
|
<div class="code-header">
|
||||||
|
<h4>SQL 脚本</h4>
|
||||||
|
<n-button text @click="copySqlScript">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><CopyOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
复制
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
:value="sqlScript"
|
||||||
|
readonly
|
||||||
|
:rows="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, reactive, watch } from 'vue'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { CopyOutlined, CloseOutlined } from '@vicons/antd'
|
||||||
|
import type { AiApp } from '../type/aiApp'
|
||||||
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appData: AiApp
|
||||||
|
publishType: 'web' | 'menu'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
// 嵌入类型
|
||||||
|
const embedType = ref<'iframe' | 'script'>('iframe')
|
||||||
|
|
||||||
|
// 嵌入配置
|
||||||
|
const embedConfig = reactive({
|
||||||
|
width: '100%',
|
||||||
|
height: '600px',
|
||||||
|
theme: 'light',
|
||||||
|
showHeader: true,
|
||||||
|
showFooter: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 菜单配置
|
||||||
|
const menuConfig = reactive({
|
||||||
|
menuName: props.appData.name,
|
||||||
|
menuUrl: `/ai/chat/${props.appData.id}`,
|
||||||
|
icon: 'ant-design:robot-outlined',
|
||||||
|
sortNo: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// 主题选项
|
||||||
|
const themeOptions = [
|
||||||
|
{ label: '浅色主题', value: 'light' },
|
||||||
|
{ label: '深色主题', value: 'dark' },
|
||||||
|
{ label: '自动主题', value: 'auto' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 预览URL
|
||||||
|
const previewUrl = computed(() => {
|
||||||
|
const baseUrl = window.location.origin
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
theme: embedConfig.theme,
|
||||||
|
showHeader: embedConfig.showHeader.toString(),
|
||||||
|
showFooter: embedConfig.showFooter.toString()
|
||||||
|
})
|
||||||
|
return `${baseUrl}/ai/app/chat/${props.appData.id}?${params.toString()}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 嵌入代码
|
||||||
|
const embedCode = computed(() => {
|
||||||
|
if (embedType.value === 'iframe') {
|
||||||
|
return generateIframeCode()
|
||||||
|
} else {
|
||||||
|
return generateScriptCode()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// SQL脚本
|
||||||
|
const sqlScript = computed(() => {
|
||||||
|
return generateMenuSql()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成iframe代码
|
||||||
|
const generateIframeCode = () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
theme: embedConfig.theme,
|
||||||
|
showHeader: embedConfig.showHeader.toString(),
|
||||||
|
showFooter: embedConfig.showFooter.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/ai/app/chat/${props.appData.id}?${params.toString()}`
|
||||||
|
|
||||||
|
return `<iframe
|
||||||
|
src="${url}"
|
||||||
|
width="${embedConfig.width}"
|
||||||
|
height="${embedConfig.height}"
|
||||||
|
frameborder="0"
|
||||||
|
style="border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
</iframe>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成脚本代码
|
||||||
|
const generateScriptCode = () => {
|
||||||
|
return `<!-- AI聊天组件 -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// 创建聊天组件配置
|
||||||
|
window.AIChatConfig = {
|
||||||
|
appId: '${props.appData.id}',
|
||||||
|
theme: '${embedConfig.theme}',
|
||||||
|
showHeader: ${embedConfig.showHeader},
|
||||||
|
showFooter: ${embedConfig.showFooter},
|
||||||
|
position: 'bottom-right', // 位置:bottom-right, bottom-left, top-right, top-left
|
||||||
|
trigger: 'button' // 触发方式:button, auto
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载聊天组件脚本
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.src = '${window.location.origin}/js/ai-chat-widget.js';
|
||||||
|
script.async = true;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
// 加载样式文件
|
||||||
|
var link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = '${window.location.origin}/css/ai-chat-widget.css';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
})();
|
||||||
|
<\/script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成菜单SQL
|
||||||
|
const generateMenuSql = () => {
|
||||||
|
const menuId = `ai_app_${props.appData.id}`
|
||||||
|
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||||
|
|
||||||
|
return `-- AI应用菜单配置SQL
|
||||||
|
-- 应用名称: ${props.appData.name}
|
||||||
|
-- 应用ID: ${props.appData.id}
|
||||||
|
-- 生成时间: ${now}
|
||||||
|
|
||||||
|
INSERT INTO sys_permission (
|
||||||
|
id,
|
||||||
|
parent_id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
component,
|
||||||
|
component_name,
|
||||||
|
redirect,
|
||||||
|
menu_type,
|
||||||
|
perms,
|
||||||
|
perms_type,
|
||||||
|
sort_no,
|
||||||
|
always_show,
|
||||||
|
icon,
|
||||||
|
is_route,
|
||||||
|
is_leaf,
|
||||||
|
keep_alive,
|
||||||
|
hidden,
|
||||||
|
hide_tab,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
del_flag,
|
||||||
|
rule_flag,
|
||||||
|
create_by,
|
||||||
|
create_time,
|
||||||
|
internal_or_external
|
||||||
|
) VALUES (
|
||||||
|
'${menuId}',
|
||||||
|
NULL,
|
||||||
|
'${menuConfig.menuName}',
|
||||||
|
'${menuConfig.menuUrl}',
|
||||||
|
'super/airag/aiapp/chat/AiChat',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
1,
|
||||||
|
NULL,
|
||||||
|
'0',
|
||||||
|
${menuConfig.sortNo}.00,
|
||||||
|
0,
|
||||||
|
'${menuConfig.icon}',
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'${props.appData.descr || props.appData.name}',
|
||||||
|
'1',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'admin',
|
||||||
|
NOW(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 如果需要添加到特定父菜单下,请修改上面的 parent_id 字段
|
||||||
|
-- 常用父菜单ID参考:
|
||||||
|
-- AI应用管理: '1893865471550578689'
|
||||||
|
-- 系统管理: '1'`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制嵌入代码
|
||||||
|
const copyEmbedCode = async () => {
|
||||||
|
try {
|
||||||
|
await copyToClipboard(embedCode.value)
|
||||||
|
message.success('代码已复制到剪贴板')
|
||||||
|
} catch (error) {
|
||||||
|
message.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制菜单配置
|
||||||
|
const copyMenuConfig = async () => {
|
||||||
|
const config = JSON.stringify(menuConfig, null, 2)
|
||||||
|
try {
|
||||||
|
await copyToClipboard(config)
|
||||||
|
message.success('菜单配置已复制到剪贴板')
|
||||||
|
} catch (error) {
|
||||||
|
message.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制SQL脚本
|
||||||
|
const copySqlScript = async () => {
|
||||||
|
try {
|
||||||
|
await copyToClipboard(sqlScript.value)
|
||||||
|
message.success('SQL脚本已复制到剪贴板')
|
||||||
|
} catch (error) {
|
||||||
|
message.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听应用数据变化
|
||||||
|
watch(() => props.appData, (newData) => {
|
||||||
|
menuConfig.menuName = newData.name
|
||||||
|
menuConfig.menuUrl = `/ai/chat/${newData.id}`
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ai-app-publish {
|
||||||
|
.web-embed {
|
||||||
|
.embed-type-selector {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.embed-option {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--n-color-target);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--n-color-target);
|
||||||
|
background-color: var(--n-color-target-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-option-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.embed-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.embed-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-desc {
|
||||||
|
color: var(--n-text-color-2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-preview {
|
||||||
|
width: 60px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-config {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-code {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.code-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-preview {
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
min-height: 400px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.script-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 300px;
|
||||||
|
height: 400px;
|
||||||
|
|
||||||
|
.chat-widget {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 280px;
|
||||||
|
height: 360px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.widget-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--n-border-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.message {
|
||||||
|
background: var(--n-color-target-light);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-input {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--n-border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-config {
|
||||||
|
.menu-actions {
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sql-script {
|
||||||
|
.code-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.web-embed {
|
||||||
|
.embed-type-selector {
|
||||||
|
:deep(.n-space) {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.n-space-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-option {
|
||||||
|
.embed-option-content {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.embed-info {
|
||||||
|
.embed-title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-preview {
|
||||||
|
width: 60px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-config {
|
||||||
|
:deep(.n-form-item) {
|
||||||
|
.n-form-item-label {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-display {
|
||||||
|
.code-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-config {
|
||||||
|
:deep(.n-form-item) {
|
||||||
|
.n-form-item-label {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sql-display {
|
||||||
|
.sql-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.web-embed {
|
||||||
|
.embed-option {
|
||||||
|
.embed-option-content {
|
||||||
|
.embed-preview {
|
||||||
|
width: 50px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
749
src/views/Ai/component/AiAppSetting.vue
Normal file
749
src/views/Ai/component/AiAppSetting.vue
Normal file
@ -0,0 +1,749 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ai-app-setting">
|
||||||
|
<!-- 应用头部信息 -->
|
||||||
|
<div class="app-header">
|
||||||
|
<n-avatar
|
||||||
|
:size="48"
|
||||||
|
:src="getAppIcon(appData.icon)"
|
||||||
|
fallback-src="/default-app-icon.png"
|
||||||
|
round
|
||||||
|
/>
|
||||||
|
<div class="app-info">
|
||||||
|
<h3 class="app-name">{{ appData.name }}</h3>
|
||||||
|
<n-space>
|
||||||
|
<n-tag
|
||||||
|
v-if="appData.status === 'release'"
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
已发布
|
||||||
|
</n-tag>
|
||||||
|
<n-tag
|
||||||
|
v-else-if="appData.status === 'disable'"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
已禁用
|
||||||
|
</n-tag>
|
||||||
|
<n-button
|
||||||
|
v-if="!isReleased"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
@click="handleEditBasic"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><EditOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
编辑
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
<div class="app-actions">
|
||||||
|
<n-button type="primary" @click="handleSave" :loading="saving">
|
||||||
|
保存配置
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-divider />
|
||||||
|
|
||||||
|
<!-- 配置选项卡 -->
|
||||||
|
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||||
|
<!-- 基础配置 -->
|
||||||
|
<n-tab-pane name="basic" tab="基础配置">
|
||||||
|
<div class="config-section">
|
||||||
|
<n-form
|
||||||
|
ref="basicFormRef"
|
||||||
|
:model="configData.basic"
|
||||||
|
:rules="basicRules"
|
||||||
|
label-placement="left"
|
||||||
|
:label-width="120"
|
||||||
|
>
|
||||||
|
<n-form-item label="应用名称" path="name">
|
||||||
|
<n-input
|
||||||
|
v-model:value="configData.basic.name"
|
||||||
|
placeholder="请输入应用名称"
|
||||||
|
:maxlength="64"
|
||||||
|
show-count
|
||||||
|
:disabled="isReleased"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="应用描述" path="descr">
|
||||||
|
<n-input
|
||||||
|
v-model:value="configData.basic.descr"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="描述该应用的应用场景及用途"
|
||||||
|
:rows="3"
|
||||||
|
:maxlength="256"
|
||||||
|
show-count
|
||||||
|
:disabled="isReleased"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<!-- 模型配置 -->
|
||||||
|
<n-tab-pane name="model" tab="模型配置">
|
||||||
|
<div class="config-section">
|
||||||
|
<n-form
|
||||||
|
ref="modelFormRef"
|
||||||
|
:model="configData.model"
|
||||||
|
:rules="modelRules"
|
||||||
|
label-placement="left"
|
||||||
|
:label-width="120"
|
||||||
|
>
|
||||||
|
<n-form-item label="AI模型" path="modelId">
|
||||||
|
<n-select
|
||||||
|
v-model:value="configData.model.modelId"
|
||||||
|
placeholder="请选择AI模型"
|
||||||
|
:options="modelOptions"
|
||||||
|
:loading="loadingModels"
|
||||||
|
:disabled="isReleased"
|
||||||
|
@update:value="handleModelChange"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="温度" path="temperature">
|
||||||
|
<n-slider
|
||||||
|
v-model:value="configData.model.temperature"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="0.1"
|
||||||
|
:disabled="isReleased"
|
||||||
|
/>
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="configData.model.temperature"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="0.1"
|
||||||
|
size="small"
|
||||||
|
style="width: 80px; margin-left: 12px"
|
||||||
|
:disabled="isReleased"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="最大令牌数" path="maxTokens">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="configData.model.maxTokens"
|
||||||
|
:min="1"
|
||||||
|
:max="32000"
|
||||||
|
placeholder="请输入最大令牌数"
|
||||||
|
:disabled="isReleased"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<!-- 知识库配置 -->
|
||||||
|
<n-tab-pane name="knowledge" tab="知识库">
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="knowledge-header">
|
||||||
|
<h4>已选择知识库</h4>
|
||||||
|
<n-button
|
||||||
|
v-if="!isReleased"
|
||||||
|
type="primary"
|
||||||
|
@click="showKnowledgeModal = true"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><PlusOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
添加知识库
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="knowledge-list">
|
||||||
|
<n-empty v-if="selectedKnowledges.length === 0" description="暂未选择知识库">
|
||||||
|
<template #extra>
|
||||||
|
<n-button
|
||||||
|
v-if="!isReleased"
|
||||||
|
size="small"
|
||||||
|
@click="showKnowledgeModal = true"
|
||||||
|
>
|
||||||
|
添加知识库
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
|
||||||
|
<n-card
|
||||||
|
v-for="knowledge in selectedKnowledges"
|
||||||
|
:key="knowledge.id"
|
||||||
|
class="knowledge-item"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<div class="knowledge-content">
|
||||||
|
<div class="knowledge-info">
|
||||||
|
<div class="knowledge-name">{{ knowledge.name }}</div>
|
||||||
|
<div class="knowledge-desc">{{ knowledge.description || '暂无描述' }}</div>
|
||||||
|
</div>
|
||||||
|
<n-button
|
||||||
|
v-if="!isReleased"
|
||||||
|
text
|
||||||
|
type="error"
|
||||||
|
@click="handleRemoveKnowledge(knowledge.id)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><DeleteOutlined /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<!-- 对话配置 -->
|
||||||
|
<n-tab-pane name="chat" tab="对话配置">
|
||||||
|
<div class="config-section">
|
||||||
|
<n-form
|
||||||
|
ref="chatFormRef"
|
||||||
|
:model="configData.chat"
|
||||||
|
label-placement="left"
|
||||||
|
:label-width="120"
|
||||||
|
>
|
||||||
|
<n-form-item label="系统提示词" path="prompt">
|
||||||
|
<n-input
|
||||||
|
v-model:value="configData.chat.prompt"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="请输入系统提示词,用于指导AI的回答风格和行为"
|
||||||
|
:rows="6"
|
||||||
|
:maxlength="2000"
|
||||||
|
show-count
|
||||||
|
:disabled="isReleased"
|
||||||
|
/>
|
||||||
|
<template #feedback>
|
||||||
|
<n-space style="margin-top: 8px">
|
||||||
|
<n-button
|
||||||
|
v-if="!isReleased"
|
||||||
|
size="small"
|
||||||
|
@click="handleGeneratePrompt"
|
||||||
|
:loading="generatingPrompt"
|
||||||
|
>
|
||||||
|
AI生成提示词
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="开场白" path="prologue">
|
||||||
|
<n-input
|
||||||
|
v-model:value="configData.chat.prologue"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="请输入开场白,用户进入对话时显示"
|
||||||
|
:rows="3"
|
||||||
|
:maxlength="500"
|
||||||
|
show-count
|
||||||
|
:disabled="isReleased"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="预设问题" path="presetQuestions">
|
||||||
|
<div class="preset-questions">
|
||||||
|
<n-dynamic-input
|
||||||
|
v-model:value="configData.chat.presetQuestions"
|
||||||
|
placeholder="请输入预设问题"
|
||||||
|
:disabled="isReleased"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item label="消息轮次限制" path="msgNum">
|
||||||
|
<n-input-number
|
||||||
|
v-model:value="configData.chat.msgNum"
|
||||||
|
:min="1"
|
||||||
|
:max="100"
|
||||||
|
placeholder="请输入消息轮次限制"
|
||||||
|
:disabled="isReleased"
|
||||||
|
/>
|
||||||
|
<template #feedback>
|
||||||
|
<n-text depth="3" style="font-size: 12px">
|
||||||
|
限制用户在一次对话中的消息轮次,0表示不限制
|
||||||
|
</n-text>
|
||||||
|
</template>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<!-- 流程配置(高级编排) -->
|
||||||
|
<n-tab-pane v-if="appData.type === 'chatFlow'" name="flow" tab="流程编排">
|
||||||
|
<div class="config-section">
|
||||||
|
<n-form
|
||||||
|
ref="flowFormRef"
|
||||||
|
:model="configData.flow"
|
||||||
|
:rules="flowRules"
|
||||||
|
label-placement="left"
|
||||||
|
:label-width="120"
|
||||||
|
>
|
||||||
|
<n-form-item label="选择流程" path="flowId">
|
||||||
|
<n-select
|
||||||
|
v-model:value="configData.flow.flowId"
|
||||||
|
placeholder="请选择流程"
|
||||||
|
:options="flowOptions"
|
||||||
|
:loading="loadingFlows"
|
||||||
|
:disabled="isReleased"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<div v-if="configData.flow.flowId" class="flow-preview">
|
||||||
|
<h4>流程预览</h4>
|
||||||
|
<n-card>
|
||||||
|
<n-empty description="流程预览功能开发中" />
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
|
||||||
|
<!-- 知识库选择弹窗 -->
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showKnowledgeModal"
|
||||||
|
preset="card"
|
||||||
|
title="选择知识库"
|
||||||
|
style="width: 800px"
|
||||||
|
>
|
||||||
|
<n-empty description="知识库选择功能开发中" />
|
||||||
|
<template #action>
|
||||||
|
<n-space>
|
||||||
|
<n-button @click="showKnowledgeModal = false">取消</n-button>
|
||||||
|
<n-button type="primary" @click="handleKnowledgeConfirm">确定</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined
|
||||||
|
} from '@vicons/antd'
|
||||||
|
import type { FormInst } from 'naive-ui'
|
||||||
|
import type { AiApp, AppConfig, Knowledge } from '../type/aiApp'
|
||||||
|
import { aiAppApi } from '../aiApp'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appData: AiApp
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'success'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
// 表单引用
|
||||||
|
const basicFormRef = ref<FormInst>()
|
||||||
|
const modelFormRef = ref<FormInst>()
|
||||||
|
const chatFormRef = ref<FormInst>()
|
||||||
|
const flowFormRef = ref<FormInst>()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const activeTab = ref('basic')
|
||||||
|
const saving = ref(false)
|
||||||
|
const loadingModels = ref(false)
|
||||||
|
const loadingFlows = ref(false)
|
||||||
|
const generatingPrompt = ref(false)
|
||||||
|
const showKnowledgeModal = ref(false)
|
||||||
|
|
||||||
|
// 选项数据
|
||||||
|
const modelOptions = ref([])
|
||||||
|
const flowOptions = ref([])
|
||||||
|
const selectedKnowledges = ref<Knowledge[]>([])
|
||||||
|
|
||||||
|
// 配置数据
|
||||||
|
const configData = reactive<AppConfig>({
|
||||||
|
basic: {
|
||||||
|
name: props.appData.name || '',
|
||||||
|
descr: props.appData.descr || '',
|
||||||
|
icon: props.appData.icon || '',
|
||||||
|
type: props.appData.type || 'chatSimple'
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
modelId: props.appData.modelId || '',
|
||||||
|
temperature: 0.7,
|
||||||
|
maxTokens: 2000
|
||||||
|
},
|
||||||
|
knowledge: {
|
||||||
|
knowledgeIds: props.appData.knowledgeIds ? props.appData.knowledgeIds.split(',') : [],
|
||||||
|
searchType: 'similarity',
|
||||||
|
topK: 5,
|
||||||
|
scoreThreshold: 0.7
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
prompt: props.appData.prompt || '',
|
||||||
|
prologue: props.appData.prologue || '',
|
||||||
|
presetQuestions: props.appData.presetQuestion ? props.appData.presetQuestion.split('\n') : [],
|
||||||
|
msgNum: props.appData.msgNum || 0
|
||||||
|
},
|
||||||
|
flow: {
|
||||||
|
flowId: props.appData.flowId || '',
|
||||||
|
config: {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isReleased = computed(() => props.appData.status === 'release')
|
||||||
|
|
||||||
|
// 获取应用图标
|
||||||
|
const getAppIcon = (icon?: string) => {
|
||||||
|
return icon ? `/api/sys/common/static/${icon}` : '/default-app-icon.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const basicRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入应用名称', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelRules = {
|
||||||
|
modelId: [
|
||||||
|
{ required: true, message: '请选择AI模型', trigger: 'change' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowRules = {
|
||||||
|
flowId: [
|
||||||
|
{ required: true, message: '请选择流程', trigger: 'change' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载模型选项
|
||||||
|
const loadModelOptions = async () => {
|
||||||
|
loadingModels.value = true
|
||||||
|
try {
|
||||||
|
// 这里调用获取模型列表的API
|
||||||
|
// const response = await aiModelApi.getModelList()
|
||||||
|
// modelOptions.value = response.result.map(item => ({
|
||||||
|
// label: item.name,
|
||||||
|
// value: item.id
|
||||||
|
// }))
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
modelOptions.value = [
|
||||||
|
{ label: 'GPT-3.5 Turbo', value: 'gpt-3.5-turbo' },
|
||||||
|
{ label: 'GPT-4', value: 'gpt-4' },
|
||||||
|
{ label: 'Claude-3', value: 'claude-3' }
|
||||||
|
]
|
||||||
|
} catch (error) {
|
||||||
|
message.error('加载模型列表失败')
|
||||||
|
} finally {
|
||||||
|
loadingModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载流程选项
|
||||||
|
const loadFlowOptions = async () => {
|
||||||
|
if (props.appData.type !== 'chatFlow') return
|
||||||
|
|
||||||
|
loadingFlows.value = true
|
||||||
|
try {
|
||||||
|
// 这里调用获取流程列表的API
|
||||||
|
// const response = await flowApi.getFlowList()
|
||||||
|
// flowOptions.value = response.result.map(item => ({
|
||||||
|
// label: item.name,
|
||||||
|
// value: item.id
|
||||||
|
// }))
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
flowOptions.value = [
|
||||||
|
{ label: '客服流程', value: 'flow-1' },
|
||||||
|
{ label: '销售流程', value: 'flow-2' },
|
||||||
|
{ label: '技术支持流程', value: 'flow-3' }
|
||||||
|
]
|
||||||
|
} catch (error) {
|
||||||
|
message.error('加载流程列表失败')
|
||||||
|
} finally {
|
||||||
|
loadingFlows.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载已选择的知识库
|
||||||
|
const loadSelectedKnowledges = async () => {
|
||||||
|
if (!configData.knowledge.knowledgeIds.length) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await aiAppApi.getKnowledgeBatch({
|
||||||
|
ids: configData.knowledge.knowledgeIds.join(',')
|
||||||
|
})
|
||||||
|
if (response.success) {
|
||||||
|
selectedKnowledges.value = response.result
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('加载知识库信息失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理模型变化
|
||||||
|
const handleModelChange = (modelId: string) => {
|
||||||
|
// 根据模型设置默认参数
|
||||||
|
const modelDefaults = {
|
||||||
|
'gpt-3.5-turbo': { temperature: 0.7, maxTokens: 4000 },
|
||||||
|
'gpt-4': { temperature: 0.7, maxTokens: 8000 },
|
||||||
|
'claude-3': { temperature: 0.7, maxTokens: 4000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = modelDefaults[modelId]
|
||||||
|
if (defaults) {
|
||||||
|
Object.assign(configData.model, defaults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑基础信息
|
||||||
|
const handleEditBasic = () => {
|
||||||
|
activeTab.value = 'basic'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成提示词
|
||||||
|
const handleGeneratePrompt = async () => {
|
||||||
|
generatingPrompt.value = true
|
||||||
|
try {
|
||||||
|
const currentPrompt = configData.chat.prompt || '请为我生成一个AI助手的系统提示词'
|
||||||
|
const response = await aiAppApi.generatePrompt({ prompt: currentPrompt })
|
||||||
|
|
||||||
|
// 处理流式响应
|
||||||
|
const reader = response.getReader()
|
||||||
|
let result = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
const chunk = new TextDecoder().decode(value)
|
||||||
|
result += chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
configData.chat.prompt = result
|
||||||
|
message.success('提示词生成成功')
|
||||||
|
} catch (error) {
|
||||||
|
message.error('生成提示词失败')
|
||||||
|
} finally {
|
||||||
|
generatingPrompt.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除知识库
|
||||||
|
const handleRemoveKnowledge = (knowledgeId: string) => {
|
||||||
|
const index = configData.knowledge.knowledgeIds.indexOf(knowledgeId)
|
||||||
|
if (index > -1) {
|
||||||
|
configData.knowledge.knowledgeIds.splice(index, 1)
|
||||||
|
selectedKnowledges.value = selectedKnowledges.value.filter(k => k.id !== knowledgeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 知识库选择确认
|
||||||
|
const handleKnowledgeConfirm = () => {
|
||||||
|
showKnowledgeModal.value = false
|
||||||
|
loadSelectedKnowledges()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
const handleSave = async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
// 验证所有表单
|
||||||
|
await Promise.all([
|
||||||
|
basicFormRef.value?.validate(),
|
||||||
|
modelFormRef.value?.validate(),
|
||||||
|
props.appData.type === 'chatFlow' ? flowFormRef.value?.validate() : Promise.resolve()
|
||||||
|
])
|
||||||
|
|
||||||
|
// 构建保存数据
|
||||||
|
const saveData = {
|
||||||
|
id: props.appData.id,
|
||||||
|
...configData.basic,
|
||||||
|
...configData.model,
|
||||||
|
knowledgeIds: configData.knowledge.knowledgeIds.join(','),
|
||||||
|
prompt: configData.chat.prompt,
|
||||||
|
prologue: configData.chat.prologue,
|
||||||
|
presetQuestion: configData.chat.presetQuestions?.join('\n') || '',
|
||||||
|
msgNum: configData.chat.msgNum,
|
||||||
|
flowId: configData.flow?.flowId || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
await aiAppApi.saveApp(saveData)
|
||||||
|
message.success('保存成功')
|
||||||
|
emit('success')
|
||||||
|
} catch (error) {
|
||||||
|
message.error('保存失败')
|
||||||
|
console.error('Save error:', error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
loadModelOptions()
|
||||||
|
loadFlowOptions()
|
||||||
|
loadSelectedKnowledges()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听知识库ID变化
|
||||||
|
watch(() => configData.knowledge.knowledgeIds, () => {
|
||||||
|
loadSelectedKnowledges()
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ai-app-setting {
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.app-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-list {
|
||||||
|
.knowledge-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.knowledge-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.knowledge-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.knowledge-name {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-desc {
|
||||||
|
color: var(--n-text-color-2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-questions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-preview {
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.app-info {
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-tabs) {
|
||||||
|
.n-tabs-nav {
|
||||||
|
.n-tabs-nav-scroll-content {
|
||||||
|
.n-tabs-tab {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-form-item) {
|
||||||
|
.n-form-item-label {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-list {
|
||||||
|
.knowledge-item {
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
.knowledge-info {
|
||||||
|
.knowledge-name {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.app-header {
|
||||||
|
.app-info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-tabs) {
|
||||||
|
.n-tabs-nav {
|
||||||
|
.n-tabs-nav-scroll-content {
|
||||||
|
.n-tabs-tab {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
338
src/views/Ai/type/aiApp.ts
Normal file
338
src/views/Ai/type/aiApp.ts
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* AI应用相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础API响应类型
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean
|
||||||
|
result: T
|
||||||
|
message?: string
|
||||||
|
code?: number
|
||||||
|
timestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页响应类型
|
||||||
|
*/
|
||||||
|
export interface PageResponse<T = any> {
|
||||||
|
records: T[]
|
||||||
|
total: number
|
||||||
|
size: number
|
||||||
|
current: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI应用数据类型
|
||||||
|
*/
|
||||||
|
export interface AiApp {
|
||||||
|
/** 应用ID */
|
||||||
|
id: string
|
||||||
|
/** 应用名称 */
|
||||||
|
name: string
|
||||||
|
/** 应用描述 */
|
||||||
|
descr?: string
|
||||||
|
/** 应用图标 */
|
||||||
|
icon?: string
|
||||||
|
/** 应用类型 */
|
||||||
|
type: 'chatSimple' | 'chatFlow'
|
||||||
|
/** 应用状态 */
|
||||||
|
status: 'enable' | 'disable' | 'release'
|
||||||
|
/** 模型ID */
|
||||||
|
modelId?: string
|
||||||
|
/** 流程ID */
|
||||||
|
flowId?: string
|
||||||
|
/** 知识库IDs */
|
||||||
|
knowledgeIds?: string
|
||||||
|
/** 提示词 */
|
||||||
|
prompt?: string
|
||||||
|
/** 开场白 */
|
||||||
|
prologue?: string
|
||||||
|
/** 预设问题 */
|
||||||
|
presetQuestion?: string
|
||||||
|
/** 消息数量限制 */
|
||||||
|
msgNum?: number
|
||||||
|
/** 创建者 */
|
||||||
|
createBy?: string
|
||||||
|
/** 创建者显示名 */
|
||||||
|
createBy_dictText?: string
|
||||||
|
/** 创建时间 */
|
||||||
|
createTime?: string
|
||||||
|
/** 更新时间 */
|
||||||
|
updateTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用列表查询参数
|
||||||
|
*/
|
||||||
|
export interface AppListParams {
|
||||||
|
/** 页码 */
|
||||||
|
pageNo: number
|
||||||
|
/** 每页大小 */
|
||||||
|
pageSize: number
|
||||||
|
/** 排序字段 */
|
||||||
|
column?: string
|
||||||
|
/** 排序方式 */
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
|
/** 应用名称 */
|
||||||
|
name?: string
|
||||||
|
/** 应用类型 */
|
||||||
|
type?: string
|
||||||
|
/** 应用状态 */
|
||||||
|
status?: string
|
||||||
|
/** 创建者 */
|
||||||
|
createBy?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用列表响应类型
|
||||||
|
*/
|
||||||
|
export interface AppListResponse extends ApiResponse<PageResponse<AiApp>> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索表单类型
|
||||||
|
*/
|
||||||
|
export interface SearchForm {
|
||||||
|
/** 应用名称 */
|
||||||
|
name: string
|
||||||
|
/** 应用类型 */
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页配置类型
|
||||||
|
*/
|
||||||
|
export interface Pagination {
|
||||||
|
/** 当前页 */
|
||||||
|
page: number
|
||||||
|
/** 每页大小 */
|
||||||
|
pageSize: number
|
||||||
|
/** 总数 */
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用表单数据类型
|
||||||
|
*/
|
||||||
|
export interface AppFormData extends Omit<AiApp, 'id' | 'createTime' | 'updateTime'> {
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用类型选项
|
||||||
|
*/
|
||||||
|
export interface AppTypeOption {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
description?: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用状态选项
|
||||||
|
*/
|
||||||
|
export interface AppStatusOption {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
color?: string
|
||||||
|
type?: 'success' | 'warning' | 'error' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作选项类型
|
||||||
|
*/
|
||||||
|
export interface ActionOption {
|
||||||
|
label: string
|
||||||
|
key: string
|
||||||
|
icon?: any
|
||||||
|
disabled?: boolean
|
||||||
|
show?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库数据类型
|
||||||
|
*/
|
||||||
|
export interface Knowledge {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
type?: string
|
||||||
|
status?: string
|
||||||
|
createTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流程数据类型
|
||||||
|
*/
|
||||||
|
export interface Flow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
config?: any
|
||||||
|
status?: string
|
||||||
|
createTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI模型数据类型
|
||||||
|
*/
|
||||||
|
export interface AiModel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
provider?: string
|
||||||
|
modelType?: string
|
||||||
|
maxTokens?: number
|
||||||
|
temperature?: number
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷指令类型
|
||||||
|
*/
|
||||||
|
export interface QuickCommand {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
descr: string
|
||||||
|
order?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用配置类型
|
||||||
|
*/
|
||||||
|
export interface AppConfig {
|
||||||
|
/** 基础配置 */
|
||||||
|
basic: {
|
||||||
|
name: string
|
||||||
|
descr?: string
|
||||||
|
icon?: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
/** 模型配置 */
|
||||||
|
model: {
|
||||||
|
modelId: string
|
||||||
|
temperature?: number
|
||||||
|
maxTokens?: number
|
||||||
|
topP?: number
|
||||||
|
frequencyPenalty?: number
|
||||||
|
presencePenalty?: number
|
||||||
|
}
|
||||||
|
/** 知识库配置 */
|
||||||
|
knowledge: {
|
||||||
|
knowledgeIds: string[]
|
||||||
|
searchType?: string
|
||||||
|
topK?: number
|
||||||
|
scoreThreshold?: number
|
||||||
|
}
|
||||||
|
/** 对话配置 */
|
||||||
|
chat: {
|
||||||
|
prompt?: string
|
||||||
|
prologue?: string
|
||||||
|
presetQuestions?: string[]
|
||||||
|
quickCommands?: QuickCommand[]
|
||||||
|
msgNum?: number
|
||||||
|
}
|
||||||
|
/** 流程配置(高级编排) */
|
||||||
|
flow?: {
|
||||||
|
flowId: string
|
||||||
|
config?: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布配置类型
|
||||||
|
*/
|
||||||
|
export interface PublishConfig {
|
||||||
|
/** 发布类型 */
|
||||||
|
type: 'web' | 'menu' | 'api'
|
||||||
|
/** 应用数据 */
|
||||||
|
appData: AiApp
|
||||||
|
/** 嵌入配置 */
|
||||||
|
embedConfig?: {
|
||||||
|
width?: string
|
||||||
|
height?: string
|
||||||
|
theme?: string
|
||||||
|
showHeader?: boolean
|
||||||
|
showFooter?: boolean
|
||||||
|
}
|
||||||
|
/** 菜单配置 */
|
||||||
|
menuConfig?: {
|
||||||
|
parentId?: string
|
||||||
|
menuName?: string
|
||||||
|
menuUrl?: string
|
||||||
|
icon?: string
|
||||||
|
sortNo?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用统计数据类型
|
||||||
|
*/
|
||||||
|
export interface AppStats {
|
||||||
|
/** 总应用数 */
|
||||||
|
totalApps: number
|
||||||
|
/** 已发布应用数 */
|
||||||
|
publishedApps: number
|
||||||
|
/** 今日新增 */
|
||||||
|
todayAdded: number
|
||||||
|
/** 本月活跃 */
|
||||||
|
monthlyActive: number
|
||||||
|
/** 按类型统计 */
|
||||||
|
byType: Record<string, number>
|
||||||
|
/** 按状态统计 */
|
||||||
|
byStatus: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用使用统计
|
||||||
|
*/
|
||||||
|
export interface AppUsageStats {
|
||||||
|
appId: string
|
||||||
|
appName: string
|
||||||
|
/** 总对话数 */
|
||||||
|
totalChats: number
|
||||||
|
/** 总消息数 */
|
||||||
|
totalMessages: number
|
||||||
|
/** 活跃用户数 */
|
||||||
|
activeUsers: number
|
||||||
|
/** 平均对话轮次 */
|
||||||
|
avgChatRounds: number
|
||||||
|
/** 用户满意度 */
|
||||||
|
satisfaction?: number
|
||||||
|
/** 最后使用时间 */
|
||||||
|
lastUsedTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出配置类型
|
||||||
|
*/
|
||||||
|
export interface ExportConfig {
|
||||||
|
/** 导出格式 */
|
||||||
|
format: 'json' | 'yaml' | 'excel'
|
||||||
|
/** 包含字段 */
|
||||||
|
fields: string[]
|
||||||
|
/** 过滤条件 */
|
||||||
|
filters?: Partial<AppListParams>
|
||||||
|
/** 是否包含配置 */
|
||||||
|
includeConfig?: boolean
|
||||||
|
/** 是否包含统计 */
|
||||||
|
includeStats?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入配置类型
|
||||||
|
*/
|
||||||
|
export interface ImportConfig {
|
||||||
|
/** 导入文件 */
|
||||||
|
file: File
|
||||||
|
/** 导入模式 */
|
||||||
|
mode: 'create' | 'update' | 'merge'
|
||||||
|
/** 冲突处理 */
|
||||||
|
conflictStrategy: 'skip' | 'overwrite' | 'rename'
|
||||||
|
/** 字段映射 */
|
||||||
|
fieldMapping?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AiApp
|
368
src/views/Ai/upload.ts
Normal file
368
src/views/Ai/upload.ts
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
import { http } from './utils/http'
|
||||||
|
import type { HttpResponse } from './utils/http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件响应接口
|
||||||
|
*/
|
||||||
|
export interface UploadResponse {
|
||||||
|
/** 文件URL */
|
||||||
|
url: string
|
||||||
|
/** 文件名 */
|
||||||
|
filename: string
|
||||||
|
/** 文件大小 */
|
||||||
|
size: number
|
||||||
|
/** 文件类型 */
|
||||||
|
type: string
|
||||||
|
/** 文件ID */
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传进度回调
|
||||||
|
*/
|
||||||
|
export type UploadProgressCallback = (progress: number) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传配置
|
||||||
|
*/
|
||||||
|
export interface UploadConfig {
|
||||||
|
/** 上传进度回调 */
|
||||||
|
onProgress?: UploadProgressCallback
|
||||||
|
/** 是否显示成功消息 */
|
||||||
|
showSuccessMessage?: boolean
|
||||||
|
/** 成功消息内容 */
|
||||||
|
successMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传API
|
||||||
|
*/
|
||||||
|
export const uploadApi = {
|
||||||
|
/**
|
||||||
|
* 上传图片
|
||||||
|
* @param file 图片文件
|
||||||
|
* @param config 上传配置
|
||||||
|
*/
|
||||||
|
uploadImage(file: File, config?: UploadConfig): Promise<HttpResponse<UploadResponse>> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
return http.post('/sys/common/upload', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
},
|
||||||
|
showSuccessMessage: config?.showSuccessMessage,
|
||||||
|
successMessage: config?.successMessage || '图片上传成功',
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (config?.onProgress && progressEvent.total) {
|
||||||
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||||
|
config.onProgress(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文档
|
||||||
|
* @param file 文档文件
|
||||||
|
* @param config 上传配置
|
||||||
|
*/
|
||||||
|
uploadDocument(file: File, config?: UploadConfig): Promise<HttpResponse<UploadResponse>> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
return http.post('/sys/common/upload', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
},
|
||||||
|
showSuccessMessage: config?.showSuccessMessage,
|
||||||
|
successMessage: config?.successMessage || '文档上传成功',
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (config?.onProgress && progressEvent.total) {
|
||||||
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||||
|
config.onProgress(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传视频
|
||||||
|
* @param file 视频文件
|
||||||
|
* @param config 上传配置
|
||||||
|
*/
|
||||||
|
uploadVideo(file: File, config?: UploadConfig): Promise<HttpResponse<UploadResponse>> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
return http.post('/sys/common/upload', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
},
|
||||||
|
showSuccessMessage: config?.showSuccessMessage,
|
||||||
|
successMessage: config?.successMessage || '视频上传成功',
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (config?.onProgress && progressEvent.total) {
|
||||||
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||||
|
config.onProgress(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传音频
|
||||||
|
* @param file 音频文件
|
||||||
|
* @param config 上传配置
|
||||||
|
*/
|
||||||
|
uploadAudio(file: File, config?: UploadConfig): Promise<HttpResponse<UploadResponse>> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
return http.post('/sys/common/upload', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
},
|
||||||
|
showSuccessMessage: config?.showSuccessMessage,
|
||||||
|
successMessage: config?.successMessage || '音频上传成功',
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (config?.onProgress && progressEvent.total) {
|
||||||
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||||
|
config.onProgress(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量上传文件
|
||||||
|
* @param files 文件列表
|
||||||
|
* @param config 上传配置
|
||||||
|
*/
|
||||||
|
uploadMultiple(files: File[], config?: UploadConfig): Promise<HttpResponse<UploadResponse[]>> {
|
||||||
|
const formData = new FormData()
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
formData.append(`files[${index}]`, file)
|
||||||
|
})
|
||||||
|
|
||||||
|
return http.post('/sys/common/upload/multiple', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
},
|
||||||
|
showSuccessMessage: config?.showSuccessMessage,
|
||||||
|
successMessage: config?.successMessage || '文件上传成功',
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (config?.onProgress && progressEvent.total) {
|
||||||
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||||
|
config.onProgress(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件
|
||||||
|
* @param fileUrl 文件URL或ID
|
||||||
|
*/
|
||||||
|
deleteFile(fileUrl: string): Promise<HttpResponse<void>> {
|
||||||
|
return http.delete('/sys/common/deleteFile', {
|
||||||
|
params: { fileUrl }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件信息
|
||||||
|
* @param fileUrl 文件URL
|
||||||
|
*/
|
||||||
|
getFileInfo(fileUrl: string): Promise<HttpResponse<UploadResponse>> {
|
||||||
|
return http.get('/sys/common/fileInfo', {
|
||||||
|
params: { fileUrl }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件类型验证
|
||||||
|
*/
|
||||||
|
export const fileValidation = {
|
||||||
|
/**
|
||||||
|
* 验证图片文件
|
||||||
|
* @param file 文件
|
||||||
|
* @param maxSize 最大大小(MB)
|
||||||
|
*/
|
||||||
|
validateImage(file: File, maxSize = 5): { valid: boolean; message?: string } {
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return { valid: false, message: '只支持 JPG、PNG、GIF、WebP 格式的图片' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize * 1024 * 1024) {
|
||||||
|
return { valid: false, message: `图片大小不能超过 ${maxSize}MB` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文档文件
|
||||||
|
* @param file 文件
|
||||||
|
* @param maxSize 最大大小(MB)
|
||||||
|
*/
|
||||||
|
validateDocument(file: File, maxSize = 10): { valid: boolean; message?: string } {
|
||||||
|
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)) {
|
||||||
|
return { valid: false, message: '只支持 PDF、Word、Excel、PowerPoint、TXT 格式的文档' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize * 1024 * 1024) {
|
||||||
|
return { valid: false, message: `文档大小不能超过 ${maxSize}MB` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证视频文件
|
||||||
|
* @param file 文件
|
||||||
|
* @param maxSize 最大大小(MB)
|
||||||
|
*/
|
||||||
|
validateVideo(file: File, maxSize = 100): { valid: boolean; message?: string } {
|
||||||
|
const allowedTypes = ['video/mp4', 'video/avi', 'video/mov', 'video/wmv', 'video/flv']
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return { valid: false, message: '只支持 MP4、AVI、MOV、WMV、FLV 格式的视频' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize * 1024 * 1024) {
|
||||||
|
return { valid: false, message: `视频大小不能超过 ${maxSize}MB` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证音频文件
|
||||||
|
* @param file 文件
|
||||||
|
* @param maxSize 最大大小(MB)
|
||||||
|
*/
|
||||||
|
validateAudio(file: File, maxSize = 20): { valid: boolean; message?: string } {
|
||||||
|
const allowedTypes = ['audio/mp3', 'audio/wav', 'audio/ogg', 'audio/aac', 'audio/flac']
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return { valid: false, message: '只支持 MP3、WAV、OGG、AAC、FLAC 格式的音频' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize * 1024 * 1024) {
|
||||||
|
return { valid: false, message: `音频大小不能超过 ${maxSize}MB` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件工具函数
|
||||||
|
*/
|
||||||
|
export const fileUtils = {
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
* @param bytes 字节数
|
||||||
|
*/
|
||||||
|
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 文件名
|
||||||
|
*/
|
||||||
|
getFileExtension(filename: string): string {
|
||||||
|
return filename.slice((filename.lastIndexOf('.') - 1 >>> 0) + 2)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件类型图标
|
||||||
|
* @param filename 文件名或文件类型
|
||||||
|
*/
|
||||||
|
getFileIcon(filename: string): string {
|
||||||
|
const ext = this.getFileExtension(filename).toLowerCase()
|
||||||
|
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
// 图片
|
||||||
|
jpg: 'image',
|
||||||
|
jpeg: 'image',
|
||||||
|
png: 'image',
|
||||||
|
gif: 'image',
|
||||||
|
webp: 'image',
|
||||||
|
svg: 'image',
|
||||||
|
|
||||||
|
// 文档
|
||||||
|
pdf: 'pdf',
|
||||||
|
doc: 'word',
|
||||||
|
docx: 'word',
|
||||||
|
xls: 'excel',
|
||||||
|
xlsx: 'excel',
|
||||||
|
ppt: 'powerpoint',
|
||||||
|
pptx: 'powerpoint',
|
||||||
|
txt: 'text',
|
||||||
|
|
||||||
|
// 视频
|
||||||
|
mp4: 'video',
|
||||||
|
avi: 'video',
|
||||||
|
mov: 'video',
|
||||||
|
wmv: 'video',
|
||||||
|
flv: 'video',
|
||||||
|
|
||||||
|
// 音频
|
||||||
|
mp3: 'audio',
|
||||||
|
wav: 'audio',
|
||||||
|
ogg: 'audio',
|
||||||
|
aac: 'audio',
|
||||||
|
flac: 'audio',
|
||||||
|
|
||||||
|
// 压缩包
|
||||||
|
zip: 'archive',
|
||||||
|
rar: 'archive',
|
||||||
|
'7z': 'archive',
|
||||||
|
tar: 'archive',
|
||||||
|
gz: 'archive'
|
||||||
|
}
|
||||||
|
|
||||||
|
return iconMap[ext] || 'file'
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文件预览URL
|
||||||
|
* @param file 文件
|
||||||
|
*/
|
||||||
|
createPreviewUrl(file: File): string {
|
||||||
|
return URL.createObjectURL(file)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放文件预览URL
|
||||||
|
* @param url 预览URL
|
||||||
|
*/
|
||||||
|
revokePreviewUrl(url: string): void {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default uploadApi
|
235
src/views/Ai/utils/clipboard.ts
Normal file
235
src/views/Ai/utils/clipboard.ts
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* 剪贴板工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制文本到剪贴板
|
||||||
|
* @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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到传统方法
|
||||||
|
return fallbackCopyToClipboard(text)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制到剪贴板失败:', error)
|
||||||
|
return fallbackCopyToClipboard(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 传统方法复制文本到剪贴板
|
||||||
|
* @param text 要复制的文本
|
||||||
|
* @returns boolean 是否复制成功
|
||||||
|
*/
|
||||||
|
function fallbackCopyToClipboard(text: string): boolean {
|
||||||
|
try {
|
||||||
|
// 创建临时文本区域
|
||||||
|
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 successful = document.execCommand('copy')
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
|
||||||
|
return successful
|
||||||
|
} 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)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否支持剪贴板操作
|
||||||
|
* @returns boolean 是否支持
|
||||||
|
*/
|
||||||
|
export function isClipboardSupported(): boolean {
|
||||||
|
return !!(navigator.clipboard && window.isSecureContext) || document.queryCommandSupported('copy')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制图片到剪贴板
|
||||||
|
* @param imageUrl 图片URL
|
||||||
|
* @returns Promise<boolean> 是否复制成功
|
||||||
|
*/
|
||||||
|
export async function copyImageToClipboard(imageUrl: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!navigator.clipboard || !window.isSecureContext) {
|
||||||
|
throw new Error('Clipboard API not supported')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(imageUrl)
|
||||||
|
const blob = await response.blob()
|
||||||
|
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
[blob.type]: blob
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制图片到剪贴板失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制HTML内容到剪贴板
|
||||||
|
* @param html HTML内容
|
||||||
|
* @param text 纯文本内容(可选)
|
||||||
|
* @returns Promise<boolean> 是否复制成功
|
||||||
|
*/
|
||||||
|
export async function copyHtmlToClipboard(html: string, text?: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!navigator.clipboard || !window.isSecureContext) {
|
||||||
|
throw new Error('Clipboard API not supported')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipboardItem = new ClipboardItem({
|
||||||
|
'text/html': new Blob([html], { type: 'text/html' }),
|
||||||
|
'text/plain': new Blob([text || html.replace(/<[^>]*>/g, '')], { type: 'text/plain' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await navigator.clipboard.write([clipboardItem])
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制HTML到剪贴板失败:', error)
|
||||||
|
// 降级到纯文本复制
|
||||||
|
return copyToClipboard(text || html.replace(/<[^>]*>/g, ''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听剪贴板变化
|
||||||
|
* @param callback 回调函数
|
||||||
|
* @returns 取消监听的函数
|
||||||
|
*/
|
||||||
|
export function watchClipboard(callback: (text: string) => void): () => void {
|
||||||
|
let lastText = ''
|
||||||
|
|
||||||
|
const checkClipboard = async () => {
|
||||||
|
try {
|
||||||
|
const currentText = await readFromClipboard()
|
||||||
|
if (currentText !== lastText) {
|
||||||
|
lastText = currentText
|
||||||
|
callback(currentText)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略读取错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每秒检查一次剪贴板
|
||||||
|
const interval = setInterval(checkClipboard, 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化代码并复制到剪贴板
|
||||||
|
* @param code 代码内容
|
||||||
|
* @param language 编程语言
|
||||||
|
* @returns Promise<boolean> 是否复制成功
|
||||||
|
*/
|
||||||
|
export async function copyCodeToClipboard(code: string, language?: string): Promise<boolean> {
|
||||||
|
const formattedCode = language ? `\`\`\`${language}\n${code}\n\`\`\`` : code
|
||||||
|
return copyToClipboard(formattedCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制JSON数据到剪贴板
|
||||||
|
* @param data 要复制的数据
|
||||||
|
* @param pretty 是否格式化
|
||||||
|
* @returns Promise<boolean> 是否复制成功
|
||||||
|
*/
|
||||||
|
export async function copyJsonToClipboard(data: any, pretty = true): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const jsonString = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data)
|
||||||
|
return copyToClipboard(jsonString)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制JSON失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制表格数据到剪贴板(TSV格式)
|
||||||
|
* @param data 表格数据
|
||||||
|
* @param headers 表头(可选)
|
||||||
|
* @returns Promise<boolean> 是否复制成功
|
||||||
|
*/
|
||||||
|
export async function copyTableToClipboard(data: any[][], headers?: string[]): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let tsvContent = ''
|
||||||
|
|
||||||
|
// 添加表头
|
||||||
|
if (headers) {
|
||||||
|
tsvContent += headers.join('\t') + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加数据行
|
||||||
|
tsvContent += data.map(row => row.join('\t')).join('\n')
|
||||||
|
|
||||||
|
return copyToClipboard(tsvContent)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制表格失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制URL到剪贴板
|
||||||
|
* @param url URL地址
|
||||||
|
* @param title 标题(可选)
|
||||||
|
* @returns Promise<boolean> 是否复制成功
|
||||||
|
*/
|
||||||
|
export async function copyUrlToClipboard(url: string, title?: string): Promise<boolean> {
|
||||||
|
const content = title ? `${title}\n${url}` : url
|
||||||
|
return copyToClipboard(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
copyToClipboard,
|
||||||
|
readFromClipboard,
|
||||||
|
isClipboardSupported,
|
||||||
|
copyImageToClipboard,
|
||||||
|
copyHtmlToClipboard,
|
||||||
|
watchClipboard,
|
||||||
|
copyCodeToClipboard,
|
||||||
|
copyJsonToClipboard,
|
||||||
|
copyTableToClipboard,
|
||||||
|
copyUrlToClipboard
|
||||||
|
}
|
255
src/views/Ai/utils/http.ts
Normal file
255
src/views/Ai/utils/http.ts
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP请求响应接口
|
||||||
|
*/
|
||||||
|
export interface HttpResponse<T = any> {
|
||||||
|
success: boolean
|
||||||
|
result: T
|
||||||
|
message?: string
|
||||||
|
code?: number
|
||||||
|
timestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP请求配置
|
||||||
|
*/
|
||||||
|
export interface HttpRequestConfig extends AxiosRequestConfig {
|
||||||
|
// 是否显示错误消息
|
||||||
|
showErrorMessage?: boolean
|
||||||
|
// 是否显示成功消息
|
||||||
|
showSuccessMessage?: boolean
|
||||||
|
// 成功消息内容
|
||||||
|
successMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建HTTP客户端
|
||||||
|
*/
|
||||||
|
class HttpClient {
|
||||||
|
private instance: AxiosInstance
|
||||||
|
private message = useMessage()
|
||||||
|
|
||||||
|
constructor(config?: AxiosRequestConfig) {
|
||||||
|
this.instance = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 60000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json;charset=UTF-8'
|
||||||
|
},
|
||||||
|
...config
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setupInterceptors()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置拦截器
|
||||||
|
*/
|
||||||
|
private setupInterceptors() {
|
||||||
|
// 请求拦截器
|
||||||
|
this.instance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// 添加认证token
|
||||||
|
const token = this.getToken()
|
||||||
|
if (token) {
|
||||||
|
config.headers = config.headers || {}
|
||||||
|
config.headers['Authorization'] = `Bearer ${token}`
|
||||||
|
config.headers['X-Access-Token'] = token
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加租户ID
|
||||||
|
const tenantId = this.getTenantId()
|
||||||
|
if (tenantId) {
|
||||||
|
config.headers = config.headers || {}
|
||||||
|
config.headers['X-Tenant-Id'] = tenantId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加版本标识
|
||||||
|
config.headers = config.headers || {}
|
||||||
|
config.headers['X-Version'] = 'v3'
|
||||||
|
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
this.instance.interceptors.response.use(
|
||||||
|
(response: AxiosResponse<HttpResponse>) => {
|
||||||
|
const { data, config } = response
|
||||||
|
const requestConfig = config as HttpRequestConfig
|
||||||
|
|
||||||
|
// 处理成功响应
|
||||||
|
if (data.success) {
|
||||||
|
// 显示成功消息
|
||||||
|
if (requestConfig.showSuccessMessage && requestConfig.successMessage) {
|
||||||
|
this.message.success(requestConfig.successMessage)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
} else {
|
||||||
|
// 处理业务错误
|
||||||
|
const errorMessage = data.message || '请求失败'
|
||||||
|
if (requestConfig.showErrorMessage !== false) {
|
||||||
|
this.message.error(errorMessage)
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(errorMessage))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
const { response, config } = error
|
||||||
|
const requestConfig = config as HttpRequestConfig
|
||||||
|
|
||||||
|
let errorMessage = '网络错误'
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const { status, data } = response
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 401:
|
||||||
|
errorMessage = '未授权,请重新登录'
|
||||||
|
this.handleUnauthorized()
|
||||||
|
break
|
||||||
|
case 403:
|
||||||
|
errorMessage = '拒绝访问'
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
errorMessage = '请求地址不存在'
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
errorMessage = '服务器内部错误'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
errorMessage = (data as any)?.message || `请求失败 (${status})`
|
||||||
|
}
|
||||||
|
} else if (error.code === 'ECONNABORTED') {
|
||||||
|
errorMessage = '请求超时'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示错误消息
|
||||||
|
if (requestConfig?.showErrorMessage !== false) {
|
||||||
|
this.message.error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取认证token
|
||||||
|
*/
|
||||||
|
private getToken(): string | null {
|
||||||
|
return localStorage.getItem('ACCESS_TOKEN') || sessionStorage.getItem('ACCESS_TOKEN')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取租户ID
|
||||||
|
*/
|
||||||
|
private getTenantId(): string | null {
|
||||||
|
return localStorage.getItem('TENANT_ID') || '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理未授权
|
||||||
|
*/
|
||||||
|
private handleUnauthorized() {
|
||||||
|
// 清除token
|
||||||
|
localStorage.removeItem('ACCESS_TOKEN')
|
||||||
|
sessionStorage.removeItem('ACCESS_TOKEN')
|
||||||
|
|
||||||
|
// 跳转到登录页
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET请求
|
||||||
|
*/
|
||||||
|
get<T = any>(url: string, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
|
||||||
|
return this.instance.get(url, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST请求
|
||||||
|
*/
|
||||||
|
post<T = any>(url: string, data?: any, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
|
||||||
|
return this.instance.post(url, data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT请求
|
||||||
|
*/
|
||||||
|
put<T = any>(url: string, data?: any, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
|
||||||
|
return this.instance.put(url, data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE请求
|
||||||
|
*/
|
||||||
|
delete<T = any>(url: string, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
|
||||||
|
return this.instance.delete(url, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH请求
|
||||||
|
*/
|
||||||
|
patch<T = any>(url: string, data?: any, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
|
||||||
|
return this.instance.patch(url, data, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
*/
|
||||||
|
upload<T = any>(url: string, file: File, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
return this.instance.post(url, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
},
|
||||||
|
...config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
*/
|
||||||
|
download(url: string, filename?: string, config?: HttpRequestConfig): Promise<void> {
|
||||||
|
return this.instance.get(url, {
|
||||||
|
responseType: 'blob',
|
||||||
|
...config
|
||||||
|
}).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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取原始axios实例
|
||||||
|
*/
|
||||||
|
getInstance(): AxiosInstance {
|
||||||
|
return this.instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建默认HTTP客户端实例
|
||||||
|
export const http = new HttpClient()
|
||||||
|
|
||||||
|
// 导出类型
|
||||||
|
export type { HttpResponse, HttpRequestConfig }
|
||||||
|
export { HttpClient }
|
||||||
|
|
||||||
|
export default http
|
@ -749,7 +749,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- 学期显示 -->
|
<!-- 学期显示 -->
|
||||||
<div class="semester-display">
|
<div class="semester-display" @click="goToAiPage">
|
||||||
<span class="semester-text">2025年上学期</span>
|
<span class="semester-text">2025年上学期</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1010,16 +1010,15 @@
|
|||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
|
|
||||||
<!-- AI主要内容区域 -->
|
<!-- AI助手区域 - 展开状态 -->
|
||||||
<div v-if="showAiAssistant" class="ai-main-content">
|
<div v-if="aiAssistantExpanded" class="ai-main-content">
|
||||||
<!-- AI头部栏 -->
|
<!-- AI头部栏 -->
|
||||||
<div class="ai-header-bar">
|
<div class="ai-header-bar">
|
||||||
<div class="ai-header-left">
|
<div class="ai-header-left">
|
||||||
<img src="/images/aiCompanion/AI小助手@2x.png" alt="AI小助手" class="ai-avatar">
|
<img src="/images/aiCompanion/AI小助手@2x.png" alt="AI小助手" class="ai-avatar">
|
||||||
<h3 class="ai-title">AI小助手</h3>
|
<h3 class="ai-title">AI小助手</h3>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<button class="save-button" @click="hideAiAssistant">
|
<button class="save-button" @click="toggleAiAssistant">
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M1 1L9 9M9 1L1 9" stroke="#999999" stroke-width="1.5" stroke-linecap="round" />
|
<path d="M1 1L9 9M9 1L1 9" stroke="#999999" stroke-width="1.5" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -1190,6 +1189,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI助手折叠状态 - 小区域显示 -->
|
||||||
|
<div v-if="!aiAssistantExpanded" class="ai-collapsed-area" @click="toggleAiAssistant">
|
||||||
|
<div class="ai-collapsed-content">
|
||||||
|
<img src="/images/aiCompanion/AI小助手@2x.png" alt="AI小助手" class="ai-collapsed-avatar">
|
||||||
|
<span class="ai-collapsed-text">AI小助手</span>
|
||||||
|
<div class="expand-icon">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6h6M6 3v6" stroke="#0088D1" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 知识图谱横幅 -->
|
<!-- 知识图谱横幅 -->
|
||||||
<div class="knowledge-graph-banner">
|
<div class="knowledge-graph-banner">
|
||||||
<img src="/images/aiCompanion/知识图谱@2x.png" alt="知识图谱" class="graph-image">
|
<img src="/images/aiCompanion/知识图谱@2x.png" alt="知识图谱" class="graph-image">
|
||||||
@ -1449,9 +1461,10 @@ const hideTipSection = () => {
|
|||||||
console.log('隐藏提示区域')
|
console.log('隐藏提示区域')
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideAiAssistant = () => {
|
// 切换AI助手展开/折叠状态
|
||||||
showAiAssistant.value = false
|
const toggleAiAssistant = () => {
|
||||||
console.log('隐藏AI助手')
|
aiAssistantExpanded.value = !aiAssistantExpanded.value
|
||||||
|
console.log('切换AI助手状态:', aiAssistantExpanded.value ? '展开' : '折叠')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 章节分组数据
|
// 章节分组数据
|
||||||
@ -1602,7 +1615,7 @@ const courseActiveTab = ref('summary')
|
|||||||
|
|
||||||
// 控制区域显示状态
|
// 控制区域显示状态
|
||||||
const showTipSection = ref(true)
|
const showTipSection = ref(true)
|
||||||
const showAiAssistant = ref(true)
|
const aiAssistantExpanded = ref(true) // 改为展开/折叠状态
|
||||||
|
|
||||||
// 更多课程相关状态
|
// 更多课程相关状态
|
||||||
const moreCourses = ref<any[]>([])
|
const moreCourses = ref<any[]>([])
|
||||||
@ -3639,6 +3652,11 @@ const loadUserInfo = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跳转到AI页面
|
||||||
|
const goToAiPage = () => {
|
||||||
|
router.push('/ai')
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
console.log('课程详情页加载完成,课程ID:', courseId.value)
|
console.log('课程详情页加载完成,课程ID:', courseId.value)
|
||||||
loadUserInfo() // 加载用户信息
|
loadUserInfo() // 加载用户信息
|
||||||
@ -3717,8 +3735,8 @@ onActivated(() => {
|
|||||||
.container {
|
.container {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 120px;
|
padding-left: 80px;
|
||||||
padding-right: 72px;
|
padding-right: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 练习/讨论模式整体布局 */
|
/* 练习/讨论模式整体布局 */
|
||||||
@ -3876,6 +3894,13 @@ onActivated(() => {
|
|||||||
height: 42px;
|
height: 42px;
|
||||||
margin: 20px 0 -3px 0;
|
margin: 20px 0 -3px 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semester-display:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 136, 209, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.semester-text {
|
.semester-text {
|
||||||
@ -3887,10 +3912,11 @@ onActivated(() => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
font-family: PingFangSC, PingFang SC;
|
font-family: PingFangSC, PingFang SC;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
color: #0088D1;
|
color: #0088D1;
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@ -5890,13 +5916,84 @@ onActivated(() => {
|
|||||||
/* AI助手界面样式 */
|
/* AI助手界面样式 */
|
||||||
.ai-assistant-interface {
|
.ai-assistant-interface {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 360px; /* 固定宽度,不再自适应 */
|
width: 392px; /* 从360px增加到392px,增加32px宽度 */
|
||||||
flex-shrink: 0; /* 防止收缩 */
|
flex-shrink: 0; /* 防止收缩 */
|
||||||
/* background: white; */
|
/* background: white; */
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AI助手折叠状态样式 */
|
||||||
|
.ai-collapsed-area {
|
||||||
|
position: relative;
|
||||||
|
width: 392px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
animation: slideInCollapsed 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInCollapsed {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-collapsed-content {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: 1px solid #E6F7FF;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 136, 209, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-collapsed-area:hover .ai-collapsed-content {
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
border-color: #0088D1;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 136, 209, 0.2);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-collapsed-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-collapsed-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #0088D1;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 136, 209, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-collapsed-area:hover .expand-icon {
|
||||||
|
background: rgba(0, 136, 209, 0.2);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
/* 顶部控制栏 */
|
/* 顶部控制栏 */
|
||||||
.top-controls {
|
.top-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -5945,6 +6042,19 @@ onActivated(() => {
|
|||||||
background-color: rgba(255, 255, 255, 0.5);
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border: 1px solid #fff;
|
border: 1px solid #fff;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AI头部栏 */
|
/* AI头部栏 */
|
||||||
@ -6879,8 +6989,8 @@ onActivated(() => {
|
|||||||
/* 大屏幕 - 使用120px左右边距 */
|
/* 大屏幕 - 使用120px左右边距 */
|
||||||
@media (min-width: 1400px) {
|
@media (min-width: 1400px) {
|
||||||
.container {
|
.container {
|
||||||
padding-left: 120px;
|
padding-left: 80px;
|
||||||
padding-right: 72px;
|
padding-right: 80px;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@ -8307,7 +8417,7 @@ onActivated(() => {
|
|||||||
|
|
||||||
/* 练习模式布局调整 */
|
/* 练习模式布局调整 */
|
||||||
.course-layout.practice-layout-mode {
|
.course-layout.practice-layout-mode {
|
||||||
padding-left: 120px;
|
padding-left: 80px;
|
||||||
padding-right: 140px;
|
padding-right: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,7 +125,7 @@
|
|||||||
<div class="training-meta">
|
<div class="training-meta">
|
||||||
<span class="training-students">{{ training.studentsCount }}{{
|
<span class="training-students">{{ training.studentsCount }}{{
|
||||||
t('home.specialTraining.studentsCheckedIn') }}</span>
|
t('home.specialTraining.studentsCheckedIn') }}</span>
|
||||||
<button class="join-btn">{{ t('home.specialTraining.join') }}</button>
|
<button class="join-btn" @click="showDevelopmentTip">{{ t('home.specialTraining.join') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -138,7 +138,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="ad-container">
|
<div class="ad-container">
|
||||||
<!-- <button class="close-btn" @click="closeAdvertisement">关闭</button> -->
|
<!-- <button class="close-btn" @click="closeAdvertisement">关闭</button> -->
|
||||||
<img src="/images/advertising/advertising3.png" alt="广告图片" class="ad-image" />
|
<img src="/images/advertising/advertising3.png" alt="广告图片" class="ad-image" @click="showDevelopmentTip" />
|
||||||
<div class="ad-container-text">
|
<div class="ad-container-text">
|
||||||
2025AI算法挑战活动大赛
|
2025AI算法挑战活动大赛
|
||||||
</div>
|
</div>
|
||||||
@ -195,7 +195,7 @@
|
|||||||
{{ t('home.learningPaths.warmTip') }}
|
{{ t('home.learningPaths.warmTip') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="path-learn-btn">{{ t('home.learningPaths.startLearning') }}</button>
|
<button class="path-learn-btn" @click="showDevelopmentTip">{{ t('home.learningPaths.startLearning') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -225,7 +225,7 @@
|
|||||||
程、模型训练等方法提高动作识别精度,探索开发能够指导“五禽戏”练习
|
程、模型训练等方法提高动作识别精度,探索开发能够指导“五禽戏”练习
|
||||||
的虚拟现实应用、智能健身教练系统等作品。
|
的虚拟现实应用、智能健身教练系统等作品。
|
||||||
</p>
|
</p>
|
||||||
<button class="activity-btn">立即报名</button>
|
<button class="activity-btn" @click="showDevelopmentTip">立即报名</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-left-img">
|
<div class="activity-left-img">
|
||||||
<img src="/images/activity/activity1.png" alt="">
|
<img src="/images/activity/activity1.png" alt="">
|
||||||
@ -251,7 +251,7 @@
|
|||||||
<div class="view-all-btn">查看更多 > </div>
|
<div class="view-all-btn">查看更多 > </div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-cards-grid">
|
<div class="ai-cards-grid">
|
||||||
<div class="ai-card" v-for="aiCard in aiCards" :key="aiCard.id">
|
<div class="ai-card" v-for="aiCard in aiCards" :key="aiCard.id" @click="goToAiPage">
|
||||||
<div class="ai-card-icon">
|
<div class="ai-card-icon">
|
||||||
<div class="ai-icon-wrapper">
|
<div class="ai-icon-wrapper">
|
||||||
<img :src="aiCard.icon" :alt="aiCard.title" class="ai-icon-image" />
|
<img :src="aiCard.icon" :alt="aiCard.title" class="ai-icon-image" />
|
||||||
@ -307,7 +307,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 精选评论 -->
|
<!-- 精选评论 -->
|
||||||
<section class="featured-reviews">
|
<!-- <section class="featured-reviews">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div class="section-title-group">
|
<div class="section-title-group">
|
||||||
@ -473,7 +473,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section> -->
|
||||||
|
|
||||||
<!-- 合作伙伴 -->
|
<!-- 合作伙伴 -->
|
||||||
<!-- <section class="partners-section">
|
<!-- <section class="partners-section">
|
||||||
@ -794,6 +794,16 @@ const closeAdvertisement = () => {
|
|||||||
showAdvertisement.value = false
|
showAdvertisement.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示开发中提示
|
||||||
|
const showDevelopmentTip = () => {
|
||||||
|
message.info('该功能正在开发中,敬请期待!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到AI页面
|
||||||
|
const goToAiPage = () => {
|
||||||
|
router.push('/ai')
|
||||||
|
}
|
||||||
|
|
||||||
// 轮播图根据语言动态切换
|
// 轮播图根据语言动态切换
|
||||||
const bannerImage = computed(() => {
|
const bannerImage = computed(() => {
|
||||||
return locale.value === 'zh' ? '/banners/banner6.png' : '/banners/banner1-en.png'
|
return locale.value === 'zh' ? '/banners/banner6.png' : '/banners/banner1-en.png'
|
||||||
@ -1502,6 +1512,7 @@ onUnmounted(() => {
|
|||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ad-container {
|
.ad-container {
|
||||||
@ -1532,10 +1543,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 广告悬停效果 */
|
/* 广告悬停效果 */
|
||||||
/* .ad-image:hover {
|
.ad-image:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||||
} */
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user