SelfDirectedLearning.vue 40 KB

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