SelfDirectedLearning.vue 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427
  1. <!-- AI自主学习 -->
  2. <template>
  3. <div class="self-directed-learning">
  4. <div class="title-section">
  5. <h3>和AI一起,学你想学!</h3>
  6. </div>
  7. <div class="input-section">
  8. <!-- 发送消息输入框 -->
  9. <div class="dialogue-card user-input-card">
  10. <div class="dialogue-content">
  11. <textarea
  12. v-model="prompt"
  13. class="user-input-textarea"
  14. placeholder=""
  15. @keyup.enter.exact="doSendMessage(prompt.trim())"
  16. ></textarea>
  17. <div class="dropdowns-container">
  18. <el-dropdown
  19. v-for="dropdown in dropdowns"
  20. :key="dropdown.key"
  21. v-model="dropdown.value"
  22. @command="(command) => handleSelect(dropdown.key, command)"
  23. @visible-change="(visible) => handleVisibleChange(dropdown.key, visible)"
  24. >
  25. <el-button type="primary">
  26. <el-icon class="el-icon--left"><component :is="dropdown.icon" /></el-icon>
  27. {{ dropdown.value }}
  28. <el-icon class="el-icon--right" v-if="!dropdown.visible"><ArrowDownBold /></el-icon>
  29. <el-icon class="el-icon--right" v-else><ArrowUpBold /></el-icon>
  30. </el-button>
  31. <template #dropdown>
  32. <el-dropdown-menu class="dropdown-menu">
  33. <el-dropdown-item
  34. v-for="item in dropdown.options"
  35. :key="item.value"
  36. :command="item.value"
  37. >{{ item.label }}</el-dropdown-item
  38. >
  39. </el-dropdown-menu>
  40. </template>
  41. </el-dropdown>
  42. </div>
  43. <button class="send-button" :class="{ 'active': prompt.trim().length > 0 }" @click="doSendMessage(prompt.trim())" :disabled="!prompt.trim().length">
  44. <Top />
  45. </button>
  46. </div>
  47. </div>
  48. <!-- 生成/进度 -->
  49. <div class="new-container" v-if="showNewContainer">
  50. <!-- 全部完成时显示成功状态 -->
  51. <div v-if="allStepsCompleted" class="success-container">
  52. <div class="success-checkmark">✓</div>
  53. <div class="success-text">完成</div>
  54. </div>
  55. <!-- 未完成时显示进度 -->
  56. <template v-else>
  57. <div class="left-box">
  58. <div class="loading-container">
  59. <div class="loading-spinner"></div>
  60. <div class="loading-text">正在生成中</div>
  61. </div>
  62. </div>
  63. <div class="right-box">
  64. <div class="progress-container">
  65. <div class="progress-title">正在生成课程</div>
  66. <div class="progress-steps">
  67. <div class="progress-step" v-for="(step, index) in visibleProgressSteps" :key="index" :style="{ animationDelay: `${index * 0.2}s` }">
  68. <span class="step-text">{{ step.text }}</span>
  69. <span class="step-status" :class="{ 'completed': step.completed, 'active': step.active }">
  70. <span v-if="step.completed" class="checkmark">✓</span>
  71. <span v-else-if="step.active" class="loading-dot"></span>
  72. <span v-else class="step-placeholder"></span>
  73. </span>
  74. </div>
  75. </div>
  76. </div>
  77. </div>
  78. </template>
  79. </div>
  80. </div>
  81. </div>
  82. <el-divider></el-divider>
  83. </template>
  84. <script setup>
  85. import {ref, onMounted, computed, reactive, defineEmits} from "vue";
  86. // 定义事件
  87. const emit = defineEmits(['refreshData']);
  88. import { Top, Search, User,Memo,ArrowUpBold,ArrowDownBold } from '@element-plus/icons-vue'
  89. import {
  90. aIGenerateCourse,
  91. AiImageStatusEnum,
  92. CreateDialogue,
  93. CreatePainting,
  94. PaintingGetMys,
  95. sendChatMessageStream, textToSpeech
  96. } from "@/api/questions.js";
  97. import {Message} from "@/utils/message/Message.js";
  98. // 定义props
  99. const props = defineProps({
  100. // 数字人列表
  101. scriptRoles: {
  102. type: Array,
  103. default: () => []
  104. }
  105. })
  106. // 发送消息输入框
  107. const prompt = ref(""); // prompt
  108. // 对话相关
  109. const activeConversationId = ref(null) // 选中的对话编号
  110. const conversationInAbortController = ref() // 对话进行中 abort 控制器(控制 stream 对话)
  111. const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作
  112. const enableContext = ref(true) // 是否开启上下文
  113. // 接收 Stream 消息
  114. const receiveMessageFullText = ref('')
  115. const scriptDataTemp = ref(null)
  116. // 生成状态
  117. const isGenerating = ref(false)// 是否正在生成脚本
  118. const isGeneratingImages = ref(false)// 是否正在生成图片
  119. const isGeneratingVoiceovers = ref(false)// 是否正在生成语音
  120. const generatingImageUrl = ref('')
  121. // 图片生成相关状态
  122. const inProgressImageMap = ref({}) // 监听的图片映射,key 为 image 编号,value 为 { sectionIndex, type: 'image'|'audio' }
  123. const inProgressTimer = ref(null) // 生成中的图片定时器,轮询生成进展
  124. // 小节列表
  125. const lessonList = ref([
  126. {id: '1', name: '课程引入'},
  127. {id: '2', name: '知识讲解'},
  128. {id: '3', name: '课程总结'}
  129. ])
  130. //主讲人、助讲人
  131. const selectedMainTeacher = ref('李白')
  132. const selectedAssistants = ref(['小智'])
  133. //脚本数据
  134. const scriptData = reactive(
  135. {
  136. courseName: '', // 课程名称
  137. coverImage: { // 封面图
  138. prompt: '',
  139. url: '',
  140. generating: false
  141. },
  142. lessons: [ // 课程小节
  143. {
  144. lessonName: '课程引入', // 小节课程名称
  145. sections: [ // 环节
  146. {
  147. sectionName: '环节一', // 环节名称
  148. backgroundImage: {
  149. prompt: '',
  150. url: '',
  151. generating: false
  152. },
  153. backgroundAudio: {
  154. type: '',
  155. url: ''
  156. },
  157. dialogues: [
  158. {
  159. type: 'digital',
  160. roleName: '',
  161. content: '',
  162. voiceoverUrl: '',
  163. generatingVoiceover: false
  164. },
  165. {
  166. type: 'user',
  167. roleName: '',
  168. content: ''
  169. }
  170. ]
  171. }
  172. ]
  173. },
  174. {
  175. lessonName: '知识讲解', // 小节课程名称
  176. sections: [ // 环节
  177. {
  178. sectionName: '环节一', // 环节名称
  179. backgroundImage: {
  180. prompt: '',
  181. url: '',
  182. generating: false
  183. },
  184. backgroundAudio: {
  185. type: '',
  186. url: ''
  187. },
  188. dialogues: [
  189. {
  190. type: 'digital',
  191. roleName: '',
  192. content: '',
  193. voiceoverUrl: '',
  194. generatingVoiceover: false
  195. },
  196. {
  197. type: 'user',
  198. roleName: '',
  199. content: ''
  200. }
  201. ]
  202. }
  203. ]
  204. },
  205. {
  206. lessonName: '课程总结', // 小节课程名称
  207. sections: [ // 环节
  208. {
  209. sectionName: '环节一', // 环节名称
  210. backgroundImage: {
  211. prompt: '',
  212. url: '',
  213. generating: false
  214. },
  215. backgroundAudio: {
  216. type: '',
  217. url: ''
  218. },
  219. dialogues: [
  220. {
  221. type: 'digital',
  222. roleName: '',
  223. content: '',
  224. voiceoverUrl: '',
  225. generatingVoiceover: false
  226. },
  227. {
  228. type: 'user',
  229. roleName: '',
  230. content: ''
  231. }
  232. ]
  233. }
  234. ]
  235. }
  236. ]
  237. }
  238. )
  239. // 生成脚本
  240. const generateScript = async () => {
  241. try {
  242. isGenerating.value = true
  243. const role = props.scriptRoles.find((r) => r.name === selectedMainTeacher.value)
  244. let content =
  245. prompt.value +
  246. '(主讲人:' + role.name +
  247. ',主讲人角色定位:' + role.description +
  248. ';助讲有:'
  249. let zhujiang = []
  250. selectedAssistants.value.forEach((rName) => {
  251. const assistantRole = props.scriptRoles.find((r) => r.name === rName)
  252. zhujiang.push(assistantRole.name + '[' + assistantRole.description + ']')
  253. })
  254. content += zhujiang.join(',') + ')'
  255. // 1. 创建对话
  256. await createAiRoleIdConversation(13)
  257. //2、生成课程信息
  258. content += "注意:我需要让你生成"+lessonList.value.length+"节课程,";
  259. for (let i = 1; i <= lessonList.value.length; i++) {
  260. content+="第"+i+"节课程是"+lessonList.value[i-1].name+";"
  261. }
  262. //3、生成课程小节
  263. progressSteps.value[0].visible = true;
  264. progressSteps.value[0].active = true;
  265. let courseLessons = [];//存储课程小节信息
  266. scriptData.lessons = []
  267. for (let i = 0; i < lessonList.value.length; i++) {
  268. let lesson = lessonList.value[i]
  269. progressSteps.value[0].next = "生成课程小节【" + lesson.name + "】..."
  270. content += "现在开始生成第"+(i+1)+"节课:" + lesson.name
  271. let lessonInfo = {courseName: lesson.name, courseLabel: lesson.id};
  272. await doSendMessageStream(activeConversationId.value, content, lessonInfo)
  273. console.log("生成课程小节信息:", lessonInfo)
  274. courseLessons.push(lessonInfo)
  275. }
  276. scriptData.lessons = courseLessons;
  277. progressSteps.value[0].completed = true;
  278. progressSteps.value[0].active = false;
  279. content = "最后请给我围绕着这几节课的课程封面图描述词(coverImage)和课程名称(courseName)。"
  280. progressSteps.value[1].visible = true;
  281. progressSteps.value[1].active = true;
  282. let courseInfo = {}//存储课程信息
  283. await doSendMessageStream(activeConversationId.value, content, courseInfo)
  284. console.log("生成课程信息:", courseInfo)
  285. scriptData.courseName = courseInfo.courseName
  286. scriptData.coverImage.prompt = courseInfo.coverImage
  287. progressSteps.value[1].completed = true;
  288. progressSteps.value[1].active = false;
  289. //4、生成全部图片
  290. progressSteps.value[2].visible = true;
  291. progressSteps.value[2].active = true;
  292. //取courseInfo.coverImage、取courseLessons[*].sections[*].backgroundImage课程小节背景图描述词;生成图片
  293. await generateAllImages()
  294. progressSteps.value[2].completed = true;
  295. progressSteps.value[2].active = false;
  296. //5、生成全部配音
  297. progressSteps.value[3].visible = true;
  298. progressSteps.value[3].active = true;
  299. //取courseLessons[*].sections[*].dialogues[*].voiceoverUrl课程小节课程内对话文本生成配音
  300. await generateAllVoiceovers()
  301. progressSteps.value[3].completed = true;
  302. progressSteps.value[3].active = false;
  303. localStorage.setItem('scriptData', JSON.stringify(scriptData))
  304. console.log("【最终数据:】", scriptData)
  305. // 调用完成方法,封装数据并存储到后台
  306. await completeGenerate()
  307. } catch (error) {
  308. console.error('生成脚本失败:', error)
  309. } finally {
  310. isGenerating.value = false
  311. }
  312. }
  313. // 完成生成,封装数据并存储到后台
  314. const completeGenerate = async () => {
  315. try {
  316. console.log('开始封装数据并存储到后台...')
  317. const courses = scriptData.lessons.map(lesson => ({
  318. courseName: lesson.courseName,
  319. courseLabel: lesson.courseLabel,
  320. courseContent: JSON.stringify(lesson)
  321. }))
  322. // 封装数据
  323. const dataToStore = {
  324. grade: localStorage.getItem('selectedGradeId'),
  325. courseName: scriptData.courseName,
  326. coverImage: scriptData.coverImage.url,
  327. courseList: courses
  328. }
  329. console.log('封装的数据:', dataToStore)
  330. // 调用后端API将文本转成语音
  331. const res = await aIGenerateCourse(dataToStore)
  332. if (res.code !== 0) {
  333. console.error('生成课程失败:', res.msg)
  334. Message().error('生成课程失败:' + res.msg)
  335. return
  336. }
  337. // 所有步骤完成后,3秒后隐藏容器
  338. setTimeout(() => {
  339. showNewContainer.value = false;
  340. // 通知父组件刷新数据
  341. emit('refreshData');
  342. }, 2000);
  343. } catch (error) {
  344. console.error('存储数据失败:', error)
  345. }
  346. }
  347. /** 新建聊天对话 */
  348. const createAiRoleIdConversation = async (roleId) => {
  349. // 智能问答
  350. await CreateDialogue({ roleId: roleId })
  351. .then((res) => {
  352. console.log("创建会话:", res.data);
  353. activeConversationId.value = res.data;
  354. })
  355. .catch((error) => {
  356. console.error("请求出错:", error);
  357. });
  358. }
  359. /** 调取数字人生成脚本 */
  360. const doSendMessageStream = async (conversationId, content, outputContent) => {
  361. conversationInAbortController.value = new AbortController()
  362. conversationInProgress.value = true
  363. receiveMessageFullText.value = '' // 清空之前的文本
  364. try {
  365. let isFirstChunk = true
  366. await sendChatMessageStream(
  367. conversationId,
  368. content,
  369. undefined, // contentAnswer 参数
  370. conversationInAbortController.value,
  371. enableContext.value,
  372. async (res) => {
  373. const { code, data, msg } = JSON.parse(res.data)
  374. if (code !== 0) {
  375. console.error(`对话异常! ${msg}`)
  376. return
  377. }
  378. if (data.eventType === 'TEXT') {
  379. if (data.receive?.content === '') {
  380. return
  381. }
  382. if (isFirstChunk) {
  383. isFirstChunk = false
  384. }
  385. receiveMessageFullText.value += data.receive.content
  386. console.log('数据加载中..')
  387. try {
  388. const parsedData = JSON.parse(receiveMessageFullText.value)
  389. // 将解析后的数据填充到outputContent对象中
  390. console.log('zuizhon..', parsedData)
  391. Object.assign(outputContent, parsedData)
  392. // updateScriptData(parsedData)
  393. } catch (e) {
  394. // 解析失败,说明数据还不完整,继续等待
  395. }
  396. }
  397. },
  398. (error) => {
  399. console.error(`对话异常! ${error}`)
  400. throw error
  401. },
  402. () => {
  403. try {
  404. if (receiveMessageFullText.value) {
  405. console.log('最终数据:', receiveMessageFullText.value)
  406. const parsedData = JSON.parse(receiveMessageFullText.value)
  407. console.log('最终数据json:', parsedData)
  408. // 将解析后的数据填充到outputContent对象中
  409. // updateScriptData(parsedData)
  410. console.log('zuizhon..', parsedData)
  411. Object.assign(outputContent, parsedData)
  412. }
  413. } catch (e) {
  414. console.error('最终数据解析失败:', e)
  415. // 清洗规则:移除```json、```、** 等非JSON标记,只保留中间的JSON内容
  416. try {
  417. let cleanJsonStr = receiveMessageFullText.value
  418. const parsedData = JSON.parse(
  419. cleanJsonStr
  420. .replace(/^```json\s*/, '')
  421. .replace(/\s*```$/, '')
  422. .replace(/\*\*/g, '')
  423. .trim()
  424. )
  425. console.log('最终清洗后数据json:', parsedData)
  426. // 将解析后的数据填充到outputContent对象中
  427. console.log('zuizhon..', parsedData)
  428. Object.assign(outputContent, parsedData)
  429. // updateScriptData(parsedData)
  430. } catch (cleanError) {
  431. console.error('清洗后数据解析失败:', cleanError)
  432. }
  433. }
  434. }
  435. )
  436. } catch (error) {
  437. console.error('发送消息流失败:', error)
  438. } finally {
  439. conversationInProgress.value = false
  440. }
  441. }
  442. // 生成所有背景图
  443. const generateAllImages = async () => {
  444. return new Promise(async (resolve, reject) => {
  445. isGeneratingImages.value = true
  446. try {
  447. // 确保scriptData和coverImage存在
  448. if (!scriptData.coverImage) {
  449. scriptData.coverImage = { prompt: '', url: '', generating: false }
  450. }
  451. // 生成封面图
  452. if (scriptData.coverImage.prompt && !scriptData.coverImage.url) {
  453. await generateCoverImage()
  454. }
  455. // 生成所有环节的背景图
  456. if (scriptData.lessons && scriptData.lessons.length > 0) {
  457. for (let lessonIndex = 0; lessonIndex < scriptData.lessons.length; lessonIndex++) {
  458. const lesson = scriptData.lessons[lessonIndex]
  459. if (lesson && lesson.sections) {
  460. for (let sectionIndex = 0; sectionIndex < lesson.sections.length; sectionIndex++) {
  461. const section = lesson.sections[sectionIndex]
  462. // 确保section和backgroundImage存在
  463. if (section) {
  464. if (!section.backgroundImage) {
  465. section.backgroundImage = { prompt: '', url: '', generating: false }
  466. }
  467. // 只处理没有背景图URL的环节
  468. if (section.backgroundImage.prompt && !section.backgroundImage.url) {
  469. await generateImage(lessonIndex, sectionIndex)
  470. }
  471. }
  472. }
  473. }
  474. }
  475. }
  476. // 检查是否有图片正在生成
  477. if (Object.keys(inProgressImageMap.value).length === 0) {
  478. // 没有图片需要生成,直接resolve
  479. isGeneratingImages.value = false
  480. resolve()
  481. } else {
  482. // 启动图片轮询
  483. startImagePolling()
  484. // 检查是否有图片正在生成
  485. const checkImagesComplete = setInterval(() => {
  486. if (Object.keys(inProgressImageMap.value).length === 0) {
  487. isGeneratingImages.value = false
  488. clearInterval(checkImagesComplete)
  489. stopImagePolling()
  490. resolve()
  491. }
  492. }, 1000)
  493. }
  494. } catch (error) {
  495. console.error('生成所有背景图失败:', error)
  496. isGeneratingImages.value = false
  497. reject(error)
  498. }
  499. })
  500. }
  501. // 生成封面图
  502. const generateCoverImage = async () => {
  503. scriptData.coverImage.generating = true
  504. try {
  505. const response = await CreatePainting({
  506. "modelId": 56,
  507. "prompt": scriptData.coverImage.prompt,
  508. "width": 1024,
  509. "height": 1024
  510. })
  511. console.log("生成封面图", response)
  512. inProgressImageMap.value[response.data] = {
  513. type: 'cover'
  514. }
  515. } catch (error) {
  516. console.error('生成封面图失败:', error)
  517. scriptData.coverImage.generating = false
  518. }
  519. }
  520. // 生成图片
  521. const generateImage = async (lessonIndex, sectionIndex) => {
  522. const lesson = scriptData.lessons[lessonIndex]
  523. if (!lesson) return
  524. const section = lesson.sections[sectionIndex]
  525. if (!section) return
  526. const media = section.backgroundImage
  527. media.generating = true
  528. try {
  529. const res = await CreatePainting({
  530. "modelId": 56,
  531. "prompt": media.prompt,
  532. "width": 1024,
  533. "height": 1024
  534. })
  535. console.log("生成图片", res)
  536. inProgressImageMap.value[res.data] = {
  537. lessonIndex: lessonIndex,
  538. sectionIndex: sectionIndex,
  539. type: 'image'
  540. }
  541. } catch (error) {
  542. console.error('生成图片失败:', error);
  543. media.generating = false
  544. }
  545. }
  546. // 启动图片轮询
  547. const startImagePolling = () => {
  548. if (inProgressTimer.value) {
  549. clearInterval(inProgressTimer.value)
  550. }
  551. inProgressTimer.value = setInterval(refreshWatchImages, 3000)
  552. }
  553. // 停止图片轮询
  554. const stopImagePolling = () => {
  555. if (inProgressTimer.value) {
  556. clearInterval(inProgressTimer.value)
  557. inProgressTimer.value = null
  558. }
  559. }
  560. // 轮询生成中的图片列表
  561. const refreshWatchImages = async () => {
  562. const imageIds = Object.keys(inProgressImageMap.value).map(Number)
  563. if (imageIds.length === 0) {
  564. return
  565. }
  566. try {
  567. const list = await PaintingGetMys(imageIds)
  568. const newWatchImages = {}
  569. // 遍历所有正在生成的图片
  570. Object.keys(inProgressImageMap.value).forEach((key) => {
  571. const imageId = Number(key)
  572. const info = inProgressImageMap.value[key]
  573. // 查找对应的图片状态
  574. const image = list.data.find(item => item.id === imageId)
  575. if (image) {
  576. if (image.status === AiImageStatusEnum.IN_PROGRESS) {
  577. // 图片正在生成中,保留在map中
  578. newWatchImages[key] = info
  579. } else if (image.status === AiImageStatusEnum.SUCCESS && image.picUrl) {
  580. // 图片生成成功
  581. if (info.type === 'cover') {
  582. // 处理封面图
  583. scriptData.coverImage.url = image.picUrl
  584. scriptData.coverImage.generating = false
  585. } else {
  586. // 处理课程小节背景图
  587. const lesson = scriptData.lessons[info.lessonIndex]
  588. if (lesson) {
  589. const section = lesson.sections[info.sectionIndex]
  590. if (section) {
  591. section.backgroundImage.url = image.picUrl
  592. section.backgroundImage.generating = false
  593. }
  594. }
  595. }
  596. // 更新生成中的图片预览
  597. generatingImageUrl.value = image.picUrl
  598. localStorage.setItem('scriptData', JSON.stringify(scriptData))
  599. console.log("【最终数据,生成图片成功】", scriptData)
  600. } else if (image.status === AiImageStatusEnum.FAIL) {
  601. // 图片生成失败
  602. if (info.type === 'cover') {
  603. // 处理封面图失败
  604. scriptData.coverImage.generating = false
  605. } else {
  606. // 处理课程小节背景图失败
  607. const lesson = scriptData.lessons[info.lessonIndex]
  608. if (lesson) {
  609. const section = lesson.sections[info.sectionIndex]
  610. if (section) {
  611. section.backgroundImage.generating = false
  612. }
  613. }
  614. }
  615. }
  616. } else {
  617. // 图片不存在于返回列表中,可能已经处理完成或超时,从map中移除
  618. if (info.type === 'cover') {
  619. scriptData.coverImage.generating = false
  620. } else {
  621. const lesson = scriptData.lessons[info.lessonIndex]
  622. if (lesson) {
  623. const section = lesson.sections[info.sectionIndex]
  624. if (section) {
  625. section.backgroundImage.generating = false
  626. }
  627. }
  628. }
  629. }
  630. })
  631. inProgressImageMap.value = newWatchImages
  632. } catch (error) {
  633. console.error('轮询图片状态失败:', error)
  634. }
  635. }
  636. // 生成所有语音
  637. const generateAllVoiceovers = async () => {
  638. isGeneratingVoiceovers.value = true
  639. try {
  640. // 确保scriptData和lessons存在
  641. if (scriptData.lessons && scriptData.lessons.length > 0) {
  642. for (let lessonIndex = 0; lessonIndex < scriptData.lessons.length; lessonIndex++) {
  643. const lesson = scriptData.lessons[lessonIndex]
  644. if (lesson && lesson.sections) {
  645. for (let sectionIndex = 0; sectionIndex < lesson.sections.length; sectionIndex++) {
  646. const section = lesson.sections[sectionIndex]
  647. if (section && section.dialogues) {
  648. for (let dialogueIndex = 0; dialogueIndex < section.dialogues.length; dialogueIndex++) {
  649. const dialogue = section.dialogues[dialogueIndex]
  650. // 只处理没有语音URL且有内容的对话
  651. if (dialogue && !dialogue.url && dialogue.content) {
  652. await generateVoiceover(lessonIndex, sectionIndex, dialogueIndex)
  653. }
  654. }
  655. }
  656. }
  657. }
  658. }
  659. }
  660. } catch (error) {
  661. console.error('生成所有语音失败:', error)
  662. } finally {
  663. isGeneratingVoiceovers.value = false
  664. }
  665. }
  666. // 文本转语音API调用
  667. // 生成单个语音
  668. const generateVoiceover = async (lessonIndex, sectionIndex, dialogueIndex) => {
  669. const lesson = scriptData.lessons[lessonIndex]
  670. if (!lesson) return
  671. const section = lesson.sections[sectionIndex]
  672. if (!section) return
  673. const dialogue = section.dialogues[dialogueIndex]
  674. // 只处理数字人对话和提问
  675. if (dialogue.type === 'user') {
  676. return
  677. }
  678. dialogue.generatingVoiceover = true
  679. let role = props.scriptRoles.find((r) => r.name === dialogue.roleName)
  680. if (!role) {
  681. console.error('未找到角色:', dialogue.roleName)
  682. dialogue.generatingVoiceover = false
  683. return
  684. }
  685. try {
  686. // 调用后端API将文本转成语音
  687. const response = await textToSpeech({
  688. roleId: role.id,
  689. content: dialogue.content
  690. })
  691. // 将返回的URL赋值给对话
  692. dialogue.voiceoverUrl = response.data
  693. localStorage.setItem('scriptData', JSON.stringify(scriptData))
  694. localStorage.setItem('【最终数据,生成语音成功】', scriptData)
  695. } catch (error) {
  696. console.error('生成语音失败:', error)
  697. } finally {
  698. dialogue.generatingVoiceover = false
  699. }
  700. }
  701. // 控制生成进度显示状态
  702. const showNewContainer = ref(false);
  703. // 生成进度步骤
  704. const progressSteps = ref([
  705. { text: '生成课程脚本...', completed: false, active: false, visible: false },
  706. { text: '生成课程小节...', completed: false, active: false, visible: false },
  707. { text: '生成背景图...', completed: false, active: false, visible: false },
  708. { text: '生成配音...', completed: false, active: false, visible: false }
  709. ]);
  710. // 计算主讲下拉框选项
  711. const teacherOptions = computed(() => {
  712. return props.scriptRoles.map(role => ({
  713. label: role.name,
  714. value: role.name
  715. }));
  716. });
  717. // 计算助教下拉框选项
  718. const assistantOptions = computed(() => {
  719. return props.scriptRoles.map(role => ({
  720. label: role.name,
  721. value: role.name
  722. }));
  723. });
  724. // 下拉框配置
  725. const dropdowns = ref([
  726. {
  727. key: 'course',
  728. value: '生成主题',
  729. visible: false,
  730. icon: Search,
  731. options: [
  732. { label: '诗词课', value: '诗词课' }
  733. ]
  734. },
  735. {
  736. key: 'teacher',
  737. value: '主讲',
  738. visible: false,
  739. icon: User,
  740. options: teacherOptions.value
  741. },
  742. {
  743. key: 'assistant',
  744. value: '助教',
  745. visible: false,
  746. icon: Memo,
  747. options: assistantOptions.value
  748. }
  749. ]);
  750. // 下拉框配置映射,用于生成内容和正则表达式
  751. const dropdownConfig = {
  752. course: {
  753. prefix: '诗词课',
  754. default: '诗词课',
  755. regex: /诗词课[^,]+/g
  756. },
  757. teacher: {
  758. prefix: '主讲为',
  759. default: '主讲',
  760. regex: /主讲为[^,]+/g
  761. },
  762. assistant: {
  763. prefix: '助教为',
  764. default: '助教',
  765. regex: /助教为[^,]+/g
  766. }
  767. };
  768. // 处理下拉框选择
  769. const handleSelect = (key, command) => {
  770. const dropdown = dropdowns.value.find(item => item.key === key);
  771. if (dropdown && dropdownConfig[key]) {
  772. // 更新下拉框显示值
  773. dropdown.value = command;
  774. // 如果选择的是主讲,更新selectedMainTeacher
  775. if (key === 'teacher') {
  776. selectedMainTeacher.value = command;
  777. }
  778. // 如果选择的是助教,更新selectedAssistants
  779. else if (key === 'assistant') {
  780. selectedAssistants.value = [command];
  781. }
  782. // const config = dropdownConfig[key];
  783. // // 生成新内容,仅当选择的值与默认值不同时添加
  784. // const newContent = command === config.default
  785. // ? config.prefix
  786. // : `${config.prefix}${command}`;
  787. // // 检查输入框中是否已存在该类型的内容
  788. // if (config.regex.test(prompt.value)) {
  789. // // 替换已存在的内容
  790. // prompt.value = prompt.value.replace(config.regex, newContent);
  791. // } else {
  792. // // 添加到输入框,用逗号隔开
  793. // if (prompt.value.trim()) {
  794. // prompt.value += `, ${newContent}`;
  795. // } else {
  796. // prompt.value = newContent;
  797. // }
  798. // }
  799. }
  800. };
  801. // 处理下拉框可见性变化
  802. const handleVisibleChange = (key, visible) => {
  803. const dropdown = dropdowns.value.find(item => item.key === key);
  804. if (dropdown) {
  805. dropdown.visible = visible;
  806. }
  807. };
  808. /** 处理来自 keydown 的发送消息 */
  809. const handleSendByKeydown = async (event) => {
  810. const content = prompt.value?.trim();
  811. if (event.key === "Enter") {
  812. if (event.shiftKey) {
  813. // 插入换行
  814. prompt.value += "\r\n";
  815. event.preventDefault(); // 防止默认的换行行为
  816. } else {
  817. // 发送消息
  818. await doSendMessage(content);
  819. event.preventDefault(); // 防止默认的提交行为
  820. }
  821. }
  822. };
  823. /** 生成操作 */
  824. const doSendMessage = async (content) => {
  825. // 校验
  826. if (content.length < 1) {
  827. console.error("发送失败,原因:内容为空!");
  828. return;
  829. }
  830. showNewContainer.value = true;
  831. // 调用生成脚本方法
  832. await generateScript();
  833. };
  834. // 计算属性:返回可见的进度步骤
  835. const visibleProgressSteps = computed(() => {
  836. return progressSteps.value.filter(step => step.visible);
  837. });
  838. // 计算属性:检查是否所有步骤都已完成
  839. const allStepsCompleted = computed(() => {
  840. return progressSteps.value.every(step => step.completed);
  841. });
  842. // 组件挂载时
  843. onMounted(() => {
  844. // 初始状态下不显示生成进度
  845. showNewContainer.value = false;
  846. // 初始化主讲下拉框选项
  847. dropdowns.value[1].options = teacherOptions.value;
  848. // 初始化助教下拉框选项
  849. dropdowns.value[2].options = assistantOptions.value;
  850. });
  851. </script>
  852. <style scoped lang="scss">
  853. @use "sass:math";
  854. // 定义rpx转换函数
  855. @function rpx($px) {
  856. @return math.div($px, 750) * 100vw;
  857. }
  858. .self-directed-learning {
  859. width: 100%;
  860. display: flex;
  861. flex-direction: column;
  862. flex-grow: 1;
  863. }
  864. .title-section {
  865. display: flex;
  866. justify-content: center;
  867. align-items: center;
  868. h3 {
  869. font-size: rpx(12);
  870. color: black;
  871. font-weight: bold;
  872. letter-spacing: rpx(5);
  873. }
  874. }
  875. .input-section {
  876. display: flex;
  877. flex-direction: column;
  878. justify-content: flex-start;
  879. margin-left: rpx(10);
  880. width: calc(100% - rpx(10));
  881. }
  882. .dialogue-card {
  883. background-color: #f1f0ff;
  884. border: rpx(1) solid #a7a4ed;
  885. border-radius: rpx(6);
  886. padding: rpx(8);
  887. max-width: 50%;
  888. min-width: rpx(200);
  889. width: auto;
  890. display: inline-block;
  891. z-index: 2;
  892. }
  893. .user-input-card {
  894. width: 93%;
  895. margin-left: rpx(20);
  896. max-width: none;
  897. animation: dialogueEnterCenter 0.6s ease forwards;
  898. box-sizing: border-box;
  899. }
  900. .user-input-card::before {
  901. display: none;
  902. }
  903. @keyframes dialogueEnterCenter {
  904. from {
  905. opacity: 0;
  906. transform: translateY(rpx(20));
  907. }
  908. to {
  909. opacity: 1;
  910. transform: translateY(0);
  911. }
  912. }
  913. .dialogue-content {
  914. font-size: rpx(12);
  915. line-height: 1.2;
  916. color: #333;
  917. text-align: left;
  918. display: flex;
  919. flex-direction: column;
  920. position: relative;
  921. width: 100%;
  922. }
  923. .user-input-textarea {
  924. width: 100%;
  925. min-height: rpx(70);
  926. max-height: rpx(150);
  927. border: none;
  928. outline: none;
  929. background: transparent;
  930. font-size: rpx(8);
  931. line-height: 1.2;
  932. color: #333;
  933. resize: none;
  934. font-family: inherit;
  935. overflow-y: auto;
  936. text-align: left;
  937. padding: rpx(5) rpx(0);
  938. }
  939. .dropdowns-container {
  940. display: flex;
  941. gap: rpx(5);
  942. margin-top: rpx(5);
  943. align-self: flex-start;
  944. }
  945. .dropdowns-container .el-button {
  946. width: rpx(60);
  947. height: rpx(18);
  948. background-color: #f1f0ff;
  949. border: rpx(1) solid #a7a4ed;
  950. color: black;
  951. border-radius: rpx(4);
  952. font-size: rpx(8);
  953. display: flex;
  954. align-items: center;
  955. justify-content: center;
  956. }
  957. .dropdown-menu {
  958. min-width: rpx(50);
  959. width: auto;
  960. max-height: rpx(160);
  961. overflow-y: auto;
  962. border-radius: rpx(3);
  963. border: 1px white solid;
  964. background-color: rgb(255, 255, 255, 0.5);
  965. backdrop-filter: blur(rpx(5));
  966. box-shadow: 0 4px 8px rgba(202, 52, 52, 0.1);
  967. }
  968. .dropdown-menu ::v-deep::-webkit-scrollbar {
  969. width: rpx(2);
  970. }
  971. .dropdown-menu ::v-deep::-webkit-scrollbar-track {
  972. background: #f1f1f1;
  973. border-radius: rpx(2);
  974. }
  975. .dropdown-menu ::v-deep::-webkit-scrollbar-thumb {
  976. background: #c1c1c1;
  977. border-radius: rpx(2);
  978. }
  979. .dropdown-menu ::v-deep::-webkit-scrollbar-thumb:hover {
  980. background: #a8a8a8;
  981. }
  982. .dropdown-menu ::v-deep(.el-dropdown-menu__item) {
  983. font-size: rpx(8);
  984. color: black;
  985. border-radius: rpx(5);
  986. width: auto;
  987. min-width: rpx(35);
  988. height: rpx(20);
  989. margin-bottom: rpx(8);
  990. display: flex;
  991. align-items: center;
  992. justify-content: center;
  993. padding: 0 rpx(8);
  994. }
  995. .dropdown-menu ::v-deep(.el-dropdown-menu__item:hover),
  996. .dropdown-menu ::v-deep(.el-dropdown-menu__item:focus),
  997. .dropdown-menu ::v-deep(.el-dropdown-menu__item:active) {
  998. background: linear-gradient(
  999. to bottom,
  1000. #fee78a,
  1001. #ffce1b
  1002. );
  1003. border: none;
  1004. outline: none;
  1005. }
  1006. /* 确保下拉按钮点击时也没有边框 */
  1007. .dropdowns-container .el-button:focus,
  1008. .dropdowns-container .el-button:active {
  1009. outline: none;
  1010. box-shadow: none;
  1011. }
  1012. /* 滚动条样式 */
  1013. .user-input-textarea::-webkit-scrollbar {
  1014. width: rpx(0);
  1015. }
  1016. .user-input-textarea::-webkit-scrollbar-track {
  1017. background: rgba(0, 0, 0, 0.05);
  1018. border-radius: rpx(3);
  1019. }
  1020. .user-input-textarea::-webkit-scrollbar-thumb {
  1021. background: rgba(64, 158, 255, 0.5);
  1022. border-radius: rpx(3);
  1023. }
  1024. .user-input-textarea::-webkit-scrollbar-thumb:hover {
  1025. background: rgba(64, 158, 255, 0.8);
  1026. }
  1027. .send-button {
  1028. position: absolute;
  1029. bottom: rpx(0);
  1030. right: rpx(0);
  1031. width: rpx(18);
  1032. height: rpx(18);
  1033. border-radius: 50%;
  1034. background: transparent;
  1035. color: #a7a4ed;
  1036. border: rpx(1) solid #a7a4ed;
  1037. font-size: rpx(2);
  1038. font-weight: bold;
  1039. display: flex;
  1040. align-items: center;
  1041. justify-content: center;
  1042. cursor: not-allowed;
  1043. transition: all 0.3s ease;
  1044. outline: none;
  1045. box-shadow: none;
  1046. opacity: 0.6;
  1047. }
  1048. .send-button.active {
  1049. background: linear-gradient(
  1050. to bottom,
  1051. #ffefb0,
  1052. #ffcc00
  1053. );
  1054. color: white;
  1055. border: none;
  1056. cursor: pointer;
  1057. opacity: 1;
  1058. }
  1059. .send-button:hover:not(.active) {
  1060. border-color: #a7a4ed;
  1061. color: #a7a4ed;
  1062. transform: none;
  1063. outline: none;
  1064. box-shadow: none;
  1065. }
  1066. .send-button:hover.active {
  1067. transform: scale(1.1);
  1068. outline: none;
  1069. box-shadow: 0 rpx(2) rpx(5) rgba(255, 204, 0, 0.3);
  1070. }
  1071. .send-button:active.active {
  1072. transform: scale(0.95);
  1073. outline: none;
  1074. box-shadow: none;
  1075. }
  1076. .send-button:focus {
  1077. outline: none;
  1078. box-shadow: none;
  1079. }
  1080. .send-button span {
  1081. display: block;
  1082. }
  1083. .new-container {
  1084. width: 93%;
  1085. margin-left: rpx(20);
  1086. margin-top: rpx(10);
  1087. display: flex;
  1088. gap: rpx(5);
  1089. background-color: #f1f0ff;
  1090. border: rpx(1) solid #a7a4ed;
  1091. border-radius: rpx(6);
  1092. padding: rpx(2);
  1093. max-width: none;
  1094. align-self: flex-start;
  1095. box-sizing: border-box;
  1096. min-height: rpx(50);
  1097. }
  1098. .success-container {
  1099. width: 100%;
  1100. display: flex;
  1101. flex-direction: column;
  1102. align-items: center;
  1103. justify-content: center;
  1104. padding: rpx(10);
  1105. gap: rpx(5);
  1106. }
  1107. .success-checkmark {
  1108. width: rpx(20);
  1109. height: rpx(20);
  1110. border-radius: 50%;
  1111. background-color: #4caf50;
  1112. color: white;
  1113. display: flex;
  1114. align-items: center;
  1115. justify-content: center;
  1116. font-size: rpx(12);
  1117. font-weight: bold;
  1118. animation: successScale 0.5s ease-out;
  1119. }
  1120. .success-text {
  1121. font-size: rpx(10);
  1122. color: #666;
  1123. font-weight: bold;
  1124. animation: successFadeIn 0.5s ease-out 0.2s both;
  1125. }
  1126. @keyframes successScale {
  1127. 0% {
  1128. transform: scale(0);
  1129. opacity: 0;
  1130. }
  1131. 50% {
  1132. transform: scale(1.2);
  1133. opacity: 1;
  1134. }
  1135. 100% {
  1136. transform: scale(1);
  1137. opacity: 1;
  1138. }
  1139. }
  1140. @keyframes successFadeIn {
  1141. from {
  1142. opacity: 0;
  1143. transform: translateY(rpx(5));
  1144. }
  1145. to {
  1146. opacity: 1;
  1147. transform: translateY(0);
  1148. }
  1149. }
  1150. .left-box {
  1151. flex: 0.5;
  1152. // background-color: rgba(241, 240, 255, 0.8);
  1153. backdrop-filter: blur(rpx(5));
  1154. // border: rpx(1) solid rgba(167, 164, 237);
  1155. border-radius: rpx(6);
  1156. padding: rpx(2);
  1157. min-height: rpx(50);
  1158. display: flex;
  1159. align-items: center;
  1160. justify-content: center;
  1161. }
  1162. .loading-container {
  1163. display: flex;
  1164. flex-direction: column;
  1165. align-items: center;
  1166. justify-content: center;
  1167. gap: rpx(5);
  1168. }
  1169. .loading-spinner {
  1170. width: rpx(12);
  1171. height: rpx(12);
  1172. border: rpx(2) solid rgba(167, 164, 237, 0.3);
  1173. border-top: rpx(2) solid #a7a4ed;
  1174. border-radius: 50%;
  1175. animation: spin 1s linear infinite;
  1176. }
  1177. .loading-text {
  1178. font-size: rpx(8);
  1179. color: #666;
  1180. animation: pulse 1.5s ease-in-out infinite;
  1181. }
  1182. @keyframes spin {
  1183. 0% { transform: rotate(0deg); }
  1184. 100% { transform: rotate(360deg); }
  1185. }
  1186. @keyframes pulse {
  1187. 0%, 100% { opacity: 1; }
  1188. 50% { opacity: 0.6; }
  1189. }
  1190. .right-box {
  1191. flex: 1;
  1192. // background-color: rgba(241, 240, 255, 0.8);
  1193. backdrop-filter: blur(rpx(5));
  1194. // border: rpx(1) solid rgba(167, 164, 237, 0.5);
  1195. border-radius: rpx(6);
  1196. padding: rpx(8);
  1197. min-height: rpx(50);
  1198. display: flex;
  1199. align-items: flex-start;
  1200. justify-content: flex-start;
  1201. text-align: left;
  1202. }
  1203. .progress-container {
  1204. width: 100%;
  1205. display: flex;
  1206. flex-direction: column;
  1207. gap: rpx(5);
  1208. }
  1209. .progress-title {
  1210. font-size: rpx(10);
  1211. font-weight: bold;
  1212. color: #666;
  1213. margin-bottom: rpx(3);
  1214. }
  1215. .progress-steps {
  1216. display: flex;
  1217. flex-direction: column;
  1218. gap: rpx(2);
  1219. }
  1220. .progress-step {
  1221. display: flex;
  1222. justify-content: flex-start;
  1223. align-items: center;
  1224. text-align: left;
  1225. font-size: rpx(8);
  1226. color: #666;
  1227. padding: rpx(1) 0;
  1228. transition: all 0.3s ease;
  1229. opacity: 0;
  1230. transform: translateY(rpx(5));
  1231. animation: stepEnter 0.5s ease forwards;
  1232. }
  1233. @keyframes stepEnter {
  1234. to {
  1235. opacity: 1;
  1236. transform: translateY(0);
  1237. }
  1238. }
  1239. .progress-step.active .step-text {
  1240. color: #a7a4ed;
  1241. font-weight: bold;
  1242. }
  1243. .step-text {
  1244. flex: 1;
  1245. overflow: hidden;
  1246. text-overflow: ellipsis;
  1247. white-space: nowrap;
  1248. transition: color 0.3s ease;
  1249. }
  1250. .step-status {
  1251. width: rpx(8);
  1252. height: rpx(8);
  1253. display: flex;
  1254. align-items: center;
  1255. justify-content: center;
  1256. border-radius: 50%;
  1257. margin-left: rpx(10);
  1258. transition: all 0.3s ease;
  1259. }
  1260. .step-placeholder {
  1261. width: rpx(4);
  1262. height: rpx(4);
  1263. border-radius: 50%;
  1264. background-color: rgba(167, 164, 237, 0.2);
  1265. }
  1266. .step-status.completed {
  1267. background-color: #4caf50;
  1268. color: white;
  1269. }
  1270. .step-status.active {
  1271. background-color: rgba(167, 164, 237, 0.3);
  1272. }
  1273. .checkmark {
  1274. font-size: rpx(6);
  1275. font-weight: bold;
  1276. }
  1277. .loading-dot {
  1278. width: rpx(4);
  1279. height: rpx(4);
  1280. background-color: #a7a4ed;
  1281. border-radius: 50%;
  1282. animation: pulse 1.5s ease-in-out infinite;
  1283. }
  1284. @keyframes pulse {
  1285. 0%, 100% { opacity: 1; transform: scale(1); }
  1286. 50% { opacity: 0.6; transform: scale(1.2); }
  1287. }
  1288. </style>