feat:bug修改

This commit is contained in:
小张 2025-09-27 01:21:26 +08:00
parent a55bf916c2
commit b52a954e86
19 changed files with 6274 additions and 28 deletions

944
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View 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. **性能优化**:避免频繁的显示/隐藏操作

View File

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

View File

@ -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;
} }
/* 进度头部样式 */ /* 进度头部样式 */

View 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
View 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>

View 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(() => {
//
// 246(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
View 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

View 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">
支持 JPGPNGGIF 格式建议尺寸 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>

View 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>

View 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>

View 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
View 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
View 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

View 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
View 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

View File

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

View File

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