Browse Source

1、试题选择答案
2、课程类型新增实操课
3、发声人tts功能
4、增加上传请求时间

liyanbo 8 months ago
parent
commit
db24cebb10

+ 53 - 0
src/api/ai/tts/index.ts

@@ -0,0 +1,53 @@
+import request from '@/config/axios'
+
+// AI TTS文转音 VO
+export interface TtsVO {
+  id: number // 编号
+  name: string // 发音人名字
+  model: string // 发音人标识
+  platform: string // TTS平台
+  type: number // 发音人类型
+  sort: number // 排序
+  status: number // 状态
+  speechRate: number // 语速
+  volume: number // 语调
+  pitchRate: number // 音量
+}
+
+// AI TTS文转音 API
+export const TtsApi = {
+  // 查询AI TTS文转音分页
+  getTtsPage: async (params: any) => {
+    return await request.get({ url: `/ai/tts/page`, params })
+  },
+
+  // 查询AI TTS文转音详情
+  getTts: async (id: number) => {
+    return await request.get({ url: `/ai/tts/get?id=` + id })
+  },
+
+  // 新增AI TTS文转音
+  createTts: async (data: TtsVO) => {
+    return await request.post({ url: `/ai/tts/create`, data })
+  },
+
+  // 修改AI TTS文转音
+  updateTts: async (data: TtsVO) => {
+    return await request.put({ url: `/ai/tts/update`, data })
+  },
+
+  // 删除AI TTS文转音
+  deleteTts: async (id: number) => {
+    return await request.delete({ url: `/ai/tts/delete?id=` + id })
+  },
+
+  // 导出AI TTS文转音 Excel
+  exportTts: async (params) => {
+    return await request.download({ url: `/ai/tts/export-excel`, params })
+  },
+
+  // 查询AI TTS文转音列表
+  getTtsSimpleList: async () => {
+    return await request.get({ url: `/ai/tts/simple-list`})
+  }
+}

+ 1 - 0
src/api/bjdx/course/index.ts

@@ -8,6 +8,7 @@ export interface CourseVO {
   courseImagePath: undefined, // 课程图片路径
   courseVideoPath: undefined, // 课程视频路径
   courseMusicPath: undefined, // 课程音乐路径
+  courseFilePath: undefined, // 课程文件路径
   courseContent: undefined, // 课程内容
   courseAuthor: string // 课程作者
   courseTeacher: string // 课程老师

+ 72 - 0
src/components/TTS/useAudioPlayer.js

@@ -0,0 +1,72 @@
+import { ref } from 'vue';
+
+export function useAudioPlayer() {
+    let audioContext = null;
+    let audioQueue = [];
+    let isPlaying = false;
+
+    // 初始化AudioContext
+    const initAudioContext = () => {
+        if (!audioContext) {
+            audioContext = new (window.AudioContext || window.webkitAudioContext)({
+                sampleRate: 24000 // 匹配TTS采样率
+            });
+        }
+    };
+
+    // 播放音频块
+    const playAudioChunk = async (base64Audio) => {
+
+        // console.log('playAudioChunk=========', base64Audio);
+        initAudioContext();
+
+        // 解码Base64音频数据
+        const audioBytes = Uint8Array.from(atob(base64Audio), c => c.charCodeAt(0));
+        audioQueue.push(audioBytes);
+
+        if (!isPlaying) {
+            processAudioQueue();
+        }
+    };
+
+    // 处理音频队列
+    const processAudioQueue = async () => {
+        if (audioQueue.length === 0) {
+            isPlaying = false;
+            return;
+        }
+
+        isPlaying = true;
+        const audioData = audioQueue.shift();
+
+        try {
+            const audioBuffer = await audioContext.decodeAudioData(audioData.buffer);
+            const source = audioContext.createBufferSource();
+            source.buffer = audioBuffer;
+            source.connect(audioContext.destination);
+            source.start(0);
+
+            // 播放完成后继续处理队列
+            source.onended = processAudioQueue;
+        } catch (error) {
+            console.error('音频解码失败:', error);
+            isPlaying = false;
+        }
+    };
+
+    // 停止播放并清理
+    const stopPlayback = () => {
+        if (audioContext) {
+            audioContext.close().then(() => {
+                audioContext = null;
+            });
+        }
+        audioQueue = [];
+        isPlaying = false;
+    };
+
+    return {
+        playAudioChunk,
+        stopPlayback
+    };
+}

+ 7 - 0
src/components/UploadFile/src/useUpload.ts

@@ -2,6 +2,7 @@ import * as FileApi from '@/api/infra/file'
 // import CryptoJS from 'crypto-js'
 import { UploadRawFile, UploadRequestOptions } from 'element-plus/es/components/upload/src/upload'
 import axios from 'axios'
+import { service } from '@/config/axios/service'
 
 /**
  * 获得上传 URL
@@ -13,6 +14,12 @@ export const getUploadUrl = (): string => {
 export const useUpload = (directory?: string) => {
   // 后端上传地址
   const uploadUrl = getUploadUrl()
+
+
+  // 设置更长的超时时间,单位为毫秒
+  axios.defaults.timeout = 60000*5; // 60 秒
+
+
   // 是否使用前端直连上传
   const isClientUpload = UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE
   // 重写ElUpload上传方法

+ 3 - 4
src/types/auto-components.d.ts

@@ -23,7 +23,6 @@ declare module 'vue' {
     ConditionDialog: typeof import('./../components/SimpleProcessDesignerV2/src/nodes-config/components/ConditionDialog.vue')['default']
     ConditionNodeConfig: typeof import('./../components/SimpleProcessDesignerV2/src/nodes-config/ConditionNodeConfig.vue')['default']
     ConfigGlobal: typeof import('./../components/ConfigGlobal/src/ConfigGlobal.vue')['default']
-    ConfigQuestion: typeof import('./../views/bjdx/questionnaire/ConfigQuestion.vue')['default']
     ContentDetailWrap: typeof import('./../components/ContentDetailWrap/src/ContentDetailWrap.vue')['default']
     ContentWrap: typeof import('./../components/ContentWrap/src/ContentWrap.vue')['default']
     CopperModal: typeof import('./../components/Cropper/src/CopperModal.vue')['default']
@@ -144,7 +143,6 @@ declare module 'vue' {
     InputPassword: typeof import('./../components/InputPassword/src/InputPassword.vue')['default']
     InputWithColor: typeof import('./../components/InputWithColor/index.vue')['default']
     MagicCubeEditor: typeof import('./../components/MagicCubeEditor/index.vue')['default']
-    ManageForm: typeof import('./../views/report/reportmanage/ManageForm.vue')['default']
     MarkdownView: typeof import('./../components/MarkdownView/index.vue')['default']
     NodeHandler: typeof import('./../components/SimpleProcessDesignerV2/src/NodeHandler.vue')['default']
     OperateLogV2: typeof import('./../components/OperateLogV2/src/OperateLogV2.vue')['default']
@@ -158,9 +156,7 @@ declare module 'vue' {
     ProcessViewer: typeof import('./../components/bpmnProcessDesigner/package/designer/ProcessViewer.vue')['default']
     PropertiesPanel: typeof import('./../components/bpmnProcessDesigner/package/penal/PropertiesPanel.vue')['default']
     Qrcode: typeof import('./../components/Qrcode/src/Qrcode.vue')['default']
-    Questionnaire: typeof import('./../api/bjdx/questionnaire/index.ts')['default']
     ReceiveTask: typeof import('./../components/bpmnProcessDesigner/package/penal/task/task-components/ReceiveTask.vue')['default']
-    Reportmanage: typeof import('./../views/report/reportmanage/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterNode: typeof import('./../components/SimpleProcessDesignerV2/src/nodes/RouterNode.vue')['default']
     RouterNodeConfig: typeof import('./../components/SimpleProcessDesignerV2/src/nodes-config/RouterNodeConfig.vue')['default']
@@ -184,12 +180,15 @@ declare module 'vue' {
     Tooltip: typeof import('./../components/Tooltip/src/Tooltip.vue')['default']
     TriggerNode: typeof import('./../components/SimpleProcessDesignerV2/src/nodes/TriggerNode.vue')['default']
     TriggerNodeConfig: typeof import('./../components/SimpleProcessDesignerV2/src/nodes-config/TriggerNodeConfig.vue')['default']
+    Tts: typeof import('./../api/ai/tts/index.ts')['default']
+    TtsForm: typeof import('./../views/ai/tts/TtsForm.vue')['default']
     UploadFile: typeof import('./../components/UploadFile/src/UploadFile.vue')['default']
     UploadImg: typeof import('./../components/UploadFile/src/UploadImg.vue')['default']
     UploadImgs: typeof import('./../components/UploadFile/src/UploadImgs.vue')['default']
     UploadModel: typeof import('./../components/UploadFile/src/UploadModel.vue')['default']
     UploadMusic: typeof import('./../components/UploadFile/src/UploadMusic.vue')['default']
     UploadVideo: typeof import('./../components/UploadFile/src/UploadVideo.vue')['default']
+    UseAudioPlayer: typeof import('./../components/TTS/useAudioPlayer.js')['default']
     UserSelectForm: typeof import('./../components/UserSelectForm/index.vue')['default']
     UserTask: typeof import('./../components/bpmnProcessDesigner/package/penal/task/task-components/UserTask.vue')['default']
     UserTaskCustomConfig: typeof import('./../components/bpmnProcessDesigner/package/penal/custom-config/components/UserTaskCustomConfig.vue')['default']

+ 1 - 0
src/utils/dict.ts

@@ -227,6 +227,7 @@ export enum DICT_TYPE {
   AI_WRITE_FORMAT = 'ai_write_format', // AI 写作格式
   AI_WRITE_TONE = 'ai_write_tone', // AI 写作语气
   AI_WRITE_LANGUAGE = 'ai_write_language', // AI 写作语言
+  AI_TTS_TYPE = 'ai_tts_type', // AI 写作语言
 
   // ========== IOT - 物联网模块  ==========
   IOT_NET_TYPE = 'iot_net_type', // IOT 联网方式

+ 92 - 15
src/views/ai/chat/index/index.vue

@@ -123,6 +123,9 @@ import MessageList from './components/message/MessageList.vue'
 import MessageListEmpty from './components/message/MessageListEmpty.vue'
 import MessageLoading from './components/message/MessageLoading.vue'
 import MessageNewConversation from './components/message/MessageNewConversation.vue'
+import { useAudioPlayer } from '@/components/TTS/useAudioPlayer';
+
+const { playAudioChunk } = useAudioPlayer();
 
 /** AI 聊天对话 列表 */
 defineOptions({ name: 'AiChat' })
@@ -155,6 +158,20 @@ const enableContext = ref<boolean>(true) // 是否开启上下文
 // 接收 Stream 消息
 const receiveMessageFullText = ref('')
 const receiveMessageDisplayedText = ref('')
+// 新增:流式音频相关变量
+const audioContext = ref<AudioContext | null>(null) // 音频上下文
+const audioBufferQueue = ref<AudioChunk[]>([])       // 音频片段缓冲区队列
+const currentSource = ref<AudioBufferSourceNode | null>(null) // 当前播放的音频节点// 新增:音频队列类型定义(包含数据和元数据)
+
+type AudioChunk = {
+  data: Uint8Array;       // 音频二进制数据
+  meta: {                 // 后端返回的音频元数据
+    channels: number;     // 声道数
+    sampleRate: number;   // 采样率
+    bitDepth: number;     // 位深(如16位)
+  };
+};
+
 
 // =========== 【聊天对话】相关 ===========
 
@@ -459,22 +476,31 @@ const doSendMessageStream = async (userMessage: ChatMessageVO) => {
           return
         }
 
-        // 如果内容为空,就不处理。
-        if (data.receive.content === '') {
-          return
-        }
-        // 首次返回需要添加一个 message 到页面,后面的都是更新
-        if (isFirstChunk) {
-          isFirstChunk = false
-          // 弹出两个假数据
-          activeMessageList.value.pop()
-          activeMessageList.value.pop()
-          // 更新返回的数据
-          activeMessageList.value.push(data.send)
-          activeMessageList.value.push(data.receive)
+        // 根据事件类型处理
+        if (data.eventType === 'TEXT') {
+
+          // 如果内容为空,就不处理。
+          if (data.receive?.content === '') {
+            return
+          }
+
+          // 首次返回需要添加一个 message 到页面,后面的都是更新
+          if (isFirstChunk) {
+            isFirstChunk = false
+            // 弹出两个假数据
+            activeMessageList.value.pop()
+            activeMessageList.value.pop()
+            // 更新返回的数据
+            activeMessageList.value.push(data.send)
+            activeMessageList.value.push(data.receive)
+          }
+          // debugger
+          receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
+        } else if (data.eventType === 'AUDIO') {
+          // 处理音频消息
+          await playAudioChunk(data.audioData);
         }
-        // debugger
-        receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
+
         // 滚动到最下面
         await scrollToBottom()
       },
@@ -486,17 +512,68 @@ const doSendMessageStream = async (userMessage: ChatMessageVO) => {
       },
       () => {
         stopStream()
+        // 停止时清空队列并关闭上下文
+        if (audioContext.value) {
+          audioContext.value.close()
+          audioContext.value = null
+        }
+        audioBufferQueue.value = []
+        currentSource.value = null
       }
     )
   } catch {}
 }
 
+// 新增:处理音频队列的核心函数(替代原decodeAudioData逻辑)
+async function processAudioQueue() {
+  if (audioBufferQueue.value.length === 0 || !audioContext.value) return
+
+  const { data, meta } = audioBufferQueue.value.shift()
+
+  try {
+    // 解码音频数据
+    const audioBuffer = await audioContext.value.decodeAudioData(data.buffer)
+    const source = audioContext.value.createBufferSource()
+    source.buffer = audioBuffer
+    source.connect(audioContext.value.destination)
+
+    // 播放完成后处理下一个队列
+    source.onended = () => {
+      currentSource.value = null
+      if (audioBufferQueue.value.length > 0) {
+        processAudioQueue()
+      }
+    }
+
+    // 开始播放
+    if (currentSource.value) {
+      currentSource.value.stop()
+    }
+    currentSource.value = source
+    source.start(0)
+  } catch (e) {
+    console.error('音频处理失败:', e)
+    if (audioBufferQueue.value.length > 0) {
+      processAudioQueue() // 处理下一个
+    }
+  }
+}
+
+
 /** 停止 stream 流式调用 */
 const stopStream = async () => {
   // tip:如果 stream 进行中的 message,就需要调用 controller 结束
   if (conversationInAbortController.value) {
     conversationInAbortController.value.abort()
   }
+  // 停止当前音频播放
+  if (currentSource.value) {
+    currentSource.value.stop()
+    currentSource.value = null
+  }
+  if (audioContext.value) {
+    audioContext.value.suspend() // 暂停上下文(保留实例以便继续播放)
+  }
   // 设置为 false
   conversationInProgress.value = false
 }

+ 1 - 1
src/views/ai/image/manager/index.vue

@@ -24,7 +24,7 @@
         </el-select>
       </el-form-item>
       <el-form-item label="平台" prop="platform">
-        <el-select v-model="queryParams.status" placeholder="请选择平台" clearable class="!w-240px">
+        <el-select v-model="queryParams.platform" placeholder="请选择平台" clearable class="!w-240px">
           <el-option
             v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
             :key="dict.value"

+ 9 - 0
src/views/ai/model/chatRole/ChatRoleForm.vue

@@ -48,6 +48,11 @@
       <el-form-item label="问题引导" prop="questTip">
         <el-input type="textarea" v-model="formData.questTip" placeholder="请输入角色问题引导" />
       </el-form-item>
+      <el-form-item label="发声人" prop="ttsId">
+        <el-select v-model="formData.ttsId" placeholder="请选择发声人" clearable>
+          <el-option v-for="item in ttsList" :key="item.id" :label="item.name" :value="item.id" />
+        </el-select>
+      </el-form-item>
       <el-form-item label="引用知识库" prop="knowledgeIds">
         <el-select v-model="formData.knowledgeIds" placeholder="请选择知识库" clearable multiple>
           <el-option
@@ -104,6 +109,7 @@ import { FormRules } from 'element-plus'
 import { AiModelTypeEnum } from '@/views/ai/utils/constants'
 import { KnowledgeApi, KnowledgeVO } from '@/api/ai/knowledge/knowledge'
 import { ToolApi, ToolVO } from '@/api/ai/model/tool'
+import { TtsApi, TtsVO } from '@/api/ai/tts'
 
 /** AI 聊天角色 表单 */
 defineOptions({ name: 'ChatRoleForm' })
@@ -136,6 +142,7 @@ const formRef = ref() // 表单 Ref
 const models = ref([] as ModelVO[]) // 聊天模型列表
 const knowledgeList = ref([] as KnowledgeVO[]) // 知识库列表
 const toolList = ref([] as ToolVO[]) // 工具列表
+const ttsList = ref([] as TtsVO[]) // 发声人列表
 
 /** 是否【我】自己创建,私有角色 */
 const isUser = computed(() => {
@@ -174,6 +181,8 @@ const open = async (type: string, id?: number, title?: string) => {
   knowledgeList.value = await KnowledgeApi.getSimpleKnowledgeList()
   // 获取工具列表
   toolList.value = await ToolApi.getToolSimpleList()
+  // 获取TTS列表
+  ttsList.value = await TtsApi.getTtsSimpleList()
 }
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 

+ 20 - 0
src/views/ai/model/chatRole/index.vue

@@ -82,6 +82,11 @@
           <span v-else>引用 {{ scope.row.toolIds.length }} 个</span>
         </template>
       </el-table-column>
+      <el-table-column label="发声人" align="center" prop="ttsId">
+        <template #default="scope">
+          {{ ttsList.find(tts => tts.id === scope.row.ttsId)?.name || '-' }}
+        </template>
+      </el-table-column>
       <el-table-column label="是否公开" align="center" prop="publicStatus">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.publicStatus" />
@@ -131,6 +136,7 @@
 import { getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
 import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole'
 import ChatRoleForm from './ChatRoleForm.vue'
+import { TtsApi} from '@/api/ai/tts'
 
 /** AI 聊天角色 列表 */
 defineOptions({ name: 'AiChatRole' })
@@ -140,6 +146,7 @@ const { t } = useI18n() // 国际化
 
 const loading = ref(true) // 列表的加载中
 const list = ref<ChatRoleVO[]>([]) // 列表的数据
+const ttsList = ref([]) // 发声人列表
 const total = ref(0) // 列表的总页数
 const queryParams = reactive({
   pageNo: 1,
@@ -153,6 +160,7 @@ const queryFormRef = ref() // 搜索的表单
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
+
   try {
     const data = await ChatRoleApi.getChatRolePage(queryParams)
     list.value = data.list
@@ -161,6 +169,17 @@ const getList = async () => {
     loading.value = false
   }
 }
+/** 查询列表 */
+const getTtsList = async () => {
+  // 获取TTS列表
+  try {
+    ttsList.value = await TtsApi.getTtsSimpleList();
+    console.log('TTS列表数据:', ttsList.value);
+  } catch (error) {
+    console.error('获取TTS列表失败:', error);
+    ttsList.value = [];
+  }
+}
 
 /** 搜索按钮操作 */
 const handleQuery = () => {
@@ -196,5 +215,6 @@ const handleDelete = async (id: number) => {
 /** 初始化 **/
 onMounted(() => {
   getList()
+  getTtsList()
 })
 </script>

+ 160 - 0
src/views/ai/tts/TtsForm.vue

@@ -0,0 +1,160 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="TTS平台" prop="platform">
+        <el-select v-model="formData.platform" placeholder="请选择平台" clearable >
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="发音人名字" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入发音人名字" />
+      </el-form-item>
+      <el-form-item label="发音人标识" prop="model">
+        <el-input v-model="formData.model" placeholder="请输入发音人标识" />
+      </el-form-item>
+      <el-form-item label="发音人类型" prop="type">
+        <el-select v-model="formData.type" placeholder="请选择发音人类型">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.AI_TTS_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="语速" prop="speechRate">
+        <el-slider v-model="formData.speechRate" show-input size="large" :min="-500" :max="500" :step="1" />
+      </el-form-item>
+      <el-form-item label="语调" prop="volume">
+        <el-slider v-model="formData.volume" show-input size="large" :min="-500" :max="500" :step="1" />
+      </el-form-item>
+      <el-form-item label="音量" prop="pitchRate">
+        <el-slider v-model="formData.pitchRate" show-input size="large" :min="-100" :max="100" :step="1" />
+      </el-form-item>
+      <el-form-item label="排序" prop="sort">
+        <el-input-number v-model="formData.sort"  :step="1" :min="1" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select
+          v-model="formData.status"
+          placeholder="请选择状态"
+          clearable
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { TtsApi, TtsVO } from '@/api/ai/tts'
+
+/** AI TTS文转音 表单 */
+defineOptions({ name: 'TtsForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  model: undefined,
+  platform: undefined,
+  type: undefined,
+  sort: undefined,
+  status: undefined,
+  speechRate: undefined,
+  volume: undefined,
+  pitchRate: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '发音人名字不能为空', trigger: 'blur' }],
+  model: [{ required: true, message: '发音人标识不能为空', trigger: 'blur' }],
+  platform: [{ required: true, message: 'TTS平台不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await TtsApi.getTts(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as TtsVO
+    if (formType.value === 'create') {
+      await TtsApi.createTts(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await TtsApi.updateTts(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    model: undefined,
+    platform: undefined,
+    type: undefined,
+    sort: undefined,
+    status: undefined,
+    speechRate: undefined,
+    volume: undefined,
+    pitchRate: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 301 - 0
src/views/ai/tts/index.vue

@@ -0,0 +1,301 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="100px"
+    >
+      <el-form-item label="发音人名字" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入发音人名字"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="发音人标识" prop="model">
+        <el-input
+          v-model="queryParams.model"
+          placeholder="请输入发音人标识"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="TTS平台" prop="platform">
+        <el-select v-model="queryParams.platform" placeholder="请选择平台" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="发音人类型" prop="type">
+        <el-select
+          v-model="queryParams.type"
+          placeholder="请选择发音人类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.AI_TTS_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="排序" prop="sort">
+        <el-input
+          v-model="queryParams.sort"
+          placeholder="请输入排序"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="语速" prop="speechRate">
+        <el-input
+          v-model="queryParams.speechRate"
+          placeholder="请输入语速"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="语调" prop="volume">
+        <el-input
+          v-model="queryParams.volume"
+          placeholder="请输入语调"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="音量" prop="pitchRate">
+        <el-input
+          v-model="queryParams.pitchRate"
+          placeholder="请输入音量"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['ai:tts:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['ai:tts:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="发音人名字" align="center" prop="name" />
+      <el-table-column label="发音人标识" align="center" prop="model" />
+      <el-table-column label="TTS平台" align="center" prop="platform">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" />
+        </template>
+      </el-table-column>
+      <el-table-column label="发音人类型" align="center" prop="type">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_TTS_TYPE" :value="scope.row.type" />
+        </template>
+      </el-table-column>
+      <el-table-column label="排序" align="center" prop="sort" />
+      <el-table-column label="状态" align="center" prop="status" >
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="语速" align="center" prop="speechRate" />
+      <el-table-column label="语调" align="center" prop="volume" />
+      <el-table-column label="音量" align="center" prop="pitchRate" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" min-width="120px">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['ai:tts:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:tts:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <TtsForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import download from '@/utils/download'
+import { TtsApi, TtsVO } from '@/api/ai/tts'
+import TtsForm from './TtsForm.vue'
+
+/** AI TTS文转音 列表 */
+defineOptions({ name: 'AiTts' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<TtsVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  model: undefined,
+  platform: undefined,
+  type: undefined,
+  sort: undefined,
+  status: undefined,
+  speechRate: undefined,
+  volume: undefined,
+  pitchRate: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await TtsApi.getTtsPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await TtsApi.deleteTts(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await TtsApi.exportTts(queryParams)
+    download.excel(data, 'AI TTS文转音.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 6 - 0
src/views/bjdx/course/CourseForm.vue

@@ -52,6 +52,10 @@
         <UploadVideo v-model="formData.courseVideoPath" />
       </el-form-item>
 
+      <el-form-item v-if="formData.courseContentType === 'ppt'" label="课程PPT" prop="courseFilePath">
+        <UploadFile v-model="formData.courseFilePath" :fileType="['ppt','pptx']"/>
+      </el-form-item>
+
       <!--            <el-form-item label="课程大小" prop="courseSize">-->
       <!--              <el-input-number v-model="formData.courseSize" :min="1" :step="1" step-strictly>-->
       <!--                <template #suffix><span>MB</span></template>-->
@@ -266,6 +270,7 @@ const formData = ref({
   courseImagePath: undefined,
   courseVideoPath: undefined,
   courseMusicPath: undefined,
+  courseFilePath: undefined,
   courseContent: undefined,
   courseBlocklyJson:{
     title: '',
@@ -363,6 +368,7 @@ const resetForm = () => {
     courseContentType: undefined,
     courseImagePath: undefined,
     courseVideoPath: undefined,
+    courseFilePath: undefined,
     courseContent: undefined,
     courseBlocklyJson:{
       title: '',

+ 3 - 3
src/views/bjdx/course/CoursePreview.vue

@@ -23,7 +23,7 @@
       </el-descriptions>
 
       <div class="video-container" style="margin-top: 20px">
-        <video
+        <video v-if="courseInfo.courseContentType === 'video'"
           ref="videoRef"
           :src="courseInfo.courseVideoPath"
           controls
@@ -73,7 +73,7 @@ const title = ref('课程预览')
 
 // 视频引用
 const videoRef = ref<HTMLVideoElement | null>(null)// HLS控制器实例
-let HLS_Controller = Hls
+let HLS_Controller = null
 // 试题数据(示例,可根据实际接口调整)
 const examQuestions = ref<any[]>([
   { id: 1, content: '问题1:这是测试题吗?', options: ['是', '否'], ccTime: 3 },
@@ -110,7 +110,7 @@ const open = async (row) => {
   // 处理HLS视频播放
   if (videoUrl && videoUrl.endsWith('.m3u8')) {
     if (Hls.isSupported()) {
-      HLS_Controller = new Hls()
+      HLS_Controller = new Hls();
       HLS_Controller.loadSource(videoUrl)
       HLS_Controller.attachMedia(videoElement)
       HLS_Controller.on(Hls.Events.MANIFEST_PARSED, () => {

+ 18 - 4
src/views/bjdx/coursequestion/CourseQuestionForm.vue

@@ -23,8 +23,15 @@
       <el-form-item label="试题解析" prop="cqQuestAnalysis">
         <Editor v-model="formData.cqQuestAnalysis" height="150px" />
       </el-form-item>
-      <el-form-item label="试题答案id" prop="cqQuestAnswerId">
-        <el-input v-model="formData.cqQuestAnswerId" placeholder="请输入试题答案id" />
+      <el-form-item label="试题答案" prop="cqQuestAnswerId">
+        <el-select v-model="formData.cqQuestAnswerId" placeholder="试题答案">
+          <el-option
+            v-for="questOption in questOptionList"
+            :key="questOption.id"
+            :label="questOption.cqoOption"
+            :value="questOption.id"
+          />
+        </el-select>
       </el-form-item>
     </el-form>
     <template #footer>
@@ -36,6 +43,7 @@
 <script setup lang="ts">
 import { CourseQuestionApi, CourseQuestionVO } from '@/api/bjdx/coursequestion'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { CourseQuestOptionApi } from '@/api/bjdx/coursequestoption'
 
 /** 课程-试题 表单 */
 defineOptions({ name: 'CourseQuestionForm' })
@@ -54,8 +62,11 @@ const formData = ref({
   cqQuestType: undefined,
   cqQuestAnswerId: undefined
 })
-const formRules = reactive({
-})
+
+//试题选项列表
+const questOptionList = ref([])
+
+const formRules = reactive({})
 const formRef = ref() // 表单 Ref
 
 /** 打开弹窗 */
@@ -69,6 +80,9 @@ const open = async (type: string, id?: number) => {
     formLoading.value = true
     try {
       formData.value = await CourseQuestionApi.getCourseQuestion(id)
+      const data = await CourseQuestOptionApi.getCourseQuestOptionPage({ cqoQuestId: id })
+      questOptionList.value = data.list
+      console.log(questOptionList.value)
     } finally {
       formLoading.value = false
     }

+ 1 - 1
src/views/bjdx/coursequestion/index.vue

@@ -75,7 +75,6 @@
           <div v-html="scope.row.cqQuestAnalysis"></div>
         </template>
       </el-table-column>
-<!--      <el-table-column label="试题答案id" align="center" prop="cqQuestAnswerId" />-->
       <el-table-column label="操作" align="center" min-width="120px">
         <template #default="scope">
           <el-button
@@ -149,6 +148,7 @@ const exportLoading = ref(false) // 导出的加载中
 const getList = async () => {
   loading.value = true
   try {
+
     const data = await CourseQuestionApi.getCourseQuestionPage(queryParams)
     list.value = data.list
     total.value = data.total

+ 2 - 1
src/views/bjdx/coursetype/CourseTypeForm.vue

@@ -21,7 +21,8 @@
       <el-form-item label="课程类型节点" prop="ctTypeNode">
         <el-segmented v-model="formData.ctTypeNode" :options="[
           { label: '年级', value: '0' },
-          { label: '课程', value: '1' },
+          { label: '通识课', value: '1' },
+          { label: '实操课', value: '2' },
         ]" />
       </el-form-item>
       <el-form-item label="课程类型名称" prop="ctType">

+ 2 - 1
src/views/bjdx/coursetype/index.vue

@@ -25,7 +25,8 @@
           class="!w-240px"
         >
           <el-option label="年级" value="0" />
-          <el-option label="大纲课程" value="1" />
+          <el-option label="ai通识课" value="1" />
+          <el-option label="ai实操课" value="2" />
         </el-select>
       </el-form-item>
       <el-form-item label="课程类型描述" prop="ctTypeDescribe">

+ 12 - 8
src/views/bjdx/questionnaire/ConfigQuestion.vue

@@ -79,14 +79,14 @@
       <el-table-column label="排序" align="center" prop="bqOrder" />
       <el-table-column label="操作" align="center" min-width="120px">
         <template #default="scope">
-<!--          <el-button-->
-<!--            link-->
-<!--            type="primary"-->
-<!--            @click="openForm('update', scope.row.id)"-->
-<!--            v-hasPermi="['bjdx:course-question:update']"-->
-<!--          >-->
-<!--            编辑-->
-<!--          </el-button>-->
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['bjdx:course-question:update']"
+          >
+            编辑
+          </el-button>
           <el-button
             link
             type="danger"
@@ -185,6 +185,8 @@
     </template>
   </el-dialog>
 
+  <!-- 表单弹窗:添加/修改 -->
+  <CourseQuestionForm ref="formRef" @success="getList" />
 </template>
 
 <script setup lang="ts">
@@ -193,6 +195,7 @@ import { QuestionnaireApi, CourseQuestionVO } from '@/api/bjdx/questionnaire'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 import { useRoute, useRouter } from 'vue-router'
 import { useI18n } from 'vue-i18n'
+import CourseQuestionForm from '@/views/bjdx/coursequestion/CourseQuestionForm.vue'
 
 
 /** 课程-试题 列表 */
@@ -242,6 +245,7 @@ const getList = async () => {
     queryParams.questionnaire = route.query.questionnaire
 
     const data = await QuestionnaireApi.getConfigQuest(queryParams)
+
     console.log(data)
     list.value = data.list
     total.value = data.total