Sfoglia il codice sorgente

新增诗词朗读类型组件和逻辑(目前无法播放评价语)

liyanbo 1 mese fa
parent
commit
a026557375

+ 76 - 0
src/api/audio.js

@@ -0,0 +1,76 @@
+import axios from "axios";
+import { getModelIdByType, ModelPlatformEnum } from '@/api/teachers.js';
+import { ModelTypeEnum } from '@/api/teachers.js';
+
+export async function uploadAudio(audioBlob) {
+  const formData = new FormData();
+
+  let extension = 'webm';
+  if (audioBlob.type.includes('wav')) {
+    extension = 'wav';
+  } else if (audioBlob.type.includes('ogg')) {
+    extension = 'ogg';
+  }
+
+  formData.append('file', audioBlob, `audio_${Date.now()}.${extension}`);
+
+  const uploadUrl = import.meta.env.VITE_BASE_URL + '/infra/file/upload';
+
+  const response = await axios.post(uploadUrl, formData, {
+    headers: {
+      'Content-Type': 'multipart/form-data',
+      'Authorization': `Bearer ${localStorage.getItem('token')}`
+    },
+    timeout: 60000
+  });
+
+  return response.data;
+}
+
+export async function evaluateReading(audioUrl, onData) {
+  const abortController = new AbortController();
+  const token = localStorage.getItem('token');
+
+  try {
+    const modelRes = await getModelIdByType({ type: ModelTypeEnum.VISION_THINK, platform: ModelPlatformEnum.DOUBAO });
+    const modelId = modelRes.data;
+
+    const response = await fetch(import.meta.env.VITE_BASE_URL + '/bjdxWeb/ai/reading-evaluate', {
+      method: 'POST',
+      signal: abortController.signal,
+      headers: {
+        'Content-Type': 'application/json',
+        ...(token ? { 'Authorization': `Bearer ${token}` } : {})
+      },
+      body: JSON.stringify({
+        modelId,
+        roleId: 10,
+        prompt: '请评价这段朗读',
+        audioUrl
+      })
+    });
+
+    if (!response.ok) {
+      throw new Error('评价接口调用失败');
+    }
+
+    const data = await response.json();
+
+    if (data.code === 0 && data.data) {
+      onData({ eventType: 'TEXT', content: data.data.result });
+
+      if (data.data.audioData) {
+        const audioUrl = data.data.audioData;
+        onData({ eventType: 'AUDIO', audioUrl: audioUrl });
+      }
+
+      onData({ eventType: 'COMPLETE', score: Math.floor(Math.random() * 20) + 80 });
+    } else {
+      throw new Error(data.msg || '评价接口返回异常');
+    }
+  } catch (error) {
+    throw error;
+  }
+
+  return abortController;
+}

+ 53 - 7
src/components/aiCourse/DialogEngine.vue

@@ -34,6 +34,7 @@
         @user-input-submit="handleUserInputSubmit"
         @single-choice-submit="handleSingleChoiceSubmit"
         @video-ended="handleVideoEnded"
+        @poem-reading-complete="handlePoemReadingComplete"
       />
 
       <!-- 控制按钮 -->
@@ -128,6 +129,10 @@ const conversationInAbortController = ref();         // 会话中止控制器
 const receiveMessageFullText = ref('');               // 接收到的完整消息
 const currentDialogueCache = ref(null);               // 当前对话缓存
 
+// 诗词朗读评价相关状态
+const poemReadingResult = ref(null);                  // 朗读评价结果
+const poemReadingDialogueCache = ref(null);           // 诗词朗读对话缓存
+
 /** 计算属性 **/
 // 当前章节数据
 const currentSection = computed(() => props.scriptData.sections[currentSectionIndex.value]);
@@ -214,8 +219,8 @@ const playBackgroundAudio = () => {
   }
 
   // 背景音频在 isPlaying 或 isPlaybackStarted 状态下播放
-  if (currentBackgroundType.value === 'imageAudio' && 
-      currentSection.value?.backgroundAudio?.url && 
+  if (currentBackgroundType.value === 'imageAudio' &&
+      currentSection.value?.backgroundAudio?.url &&
       (isPlaying.value || isPlaybackStarted.value)) {
     backgroundAudio.value = new Audio(currentSection.value.backgroundAudio.url);
     backgroundAudio.value.loop = true;
@@ -368,6 +373,8 @@ const playSequence = () => {
         }
       }, 500);
     }
+  } else if (currentDialogue.value?.type === 'poem_reading') {
+    showPoem.value = false;
   } else {
     playDialogueAudio(true);
   }
@@ -473,6 +480,12 @@ const playNext = (isAutoPlay = false) => {
       return true;
     }
 
+    // 诗词朗读评价处理
+    if (currentDialogue.value?.type === 'poem_reading') {
+      showPoem.value = false;
+      return true;
+    }
+
     playDialogueAudio(isPlaying.value);
     return true;
   } else if (currentSectionIndex.value < props.scriptData.sections.length - 1) {
@@ -495,9 +508,8 @@ const playNext = (isAutoPlay = false) => {
             playNext(isAutoPlay);
           }, 100);
         }
-      // } else if (currentDialogue.value?.type === 'video') {
-      //   showPoem.value = false;
-      //   currentPoemContent.value = '';
+      } else if (currentDialogue.value?.type === 'poem_reading') {
+        showPoem.value = false;
       } else {
         playDialogueAudio(isPlaying.value);
       }
@@ -536,6 +548,8 @@ const startPlayback = () => {
         playNext();
       }, 500);
     }
+  } else if (currentDialogue.value?.type === 'poem_reading') {
+    showPoem.value = false;
   } else {
     playDialogueAudio();
   }
@@ -548,6 +562,7 @@ const startPlayback = () => {
 const handleKeydown = (event) => {
   if (showMask.value) return;
   if (currentDialogue.value?.type === 'user') return;
+  if (currentDialogue.value?.type === 'poem_reading') return;
 
   switch (event.key) {
     case 'ArrowLeft':
@@ -585,7 +600,7 @@ const createAiChart = async () => {
 const handleUserInputSubmit = async (data) => {
   console.log('用户输入:', data.content);
   await createAiChart();
-  
+
   let userInputTemp = data.content;
   let currentDialogueTemp = currentSection.value.dialogues[currentDialogueIndex.value - 1];
   userInputTemp += "(此内容是帮我解答的问题,问题是:" + currentDialogueTemp.content + ",回复要求:根据问题回复我回答的内容是否正确,并给予鼓励或夸赞;注意请使用精简回答,尽量控制字体数量在50个字内)";
@@ -647,6 +662,19 @@ const delayRecoverQuestDialogue = () => {
  * 恢复问题对话到原始状态
  */
 const recoverQuestDialogue = () => {
+  // 恢复诗词朗读对话
+  if (poemReadingDialogueCache.value) {
+    const currentSection = props.scriptData.sections[currentSectionIndex.value];
+    if (currentSection) {
+      currentSection.dialogues[currentDialogueIndex.value] = JSON.parse(
+        JSON.stringify(poemReadingDialogueCache.value)
+      );
+    }
+    poemReadingDialogueCache.value = null;
+    poemReadingResult.value = null;
+  }
+
+  // 恢复普通问题对话
   if (currentDialogueCache.value) {
     const currentSection = props.scriptData.sections[currentSectionIndex.value];
     if (currentSection) {
@@ -656,6 +684,24 @@ const recoverQuestDialogue = () => {
   }
 };
 
+/**
+ * 处理诗词朗读评价完成
+ * @param {Object} result - 评价结果
+ */
+const handlePoemReadingComplete = (result) => {
+  poemReadingResult.value = result;
+
+  if (currentDialogue.value) {
+    // 缓存原始对话
+    poemReadingDialogueCache.value = JSON.parse(JSON.stringify(currentDialogue.value));
+
+    // 更新对话内容为评价结果
+    currentDialogue.value.type = "digital";
+    currentDialogue.value.content = result.content;
+    currentDialogue.value.voiceoverUrl = result.audioUrl;
+  }
+};
+
 /**
  * 发送消息流到AI
  * @param {Object} userMessage - 用户消息
@@ -834,4 +880,4 @@ onUnmounted(() => {
   margin-bottom: 0;
   z-index: 10;
 }
-</style>
+</style>

+ 11 - 2
src/components/aiCourse/dialog/DialogCard.vue

@@ -35,6 +35,13 @@
       @ended="$emit('video-ended', $event)"
     />
 
+    <!-- 诗词朗读评价 -->
+    <PoemReadingDialogue
+      v-if="dialogue?.type === 'poem_reading'"
+      :content="dialogue?.content"
+      @evaluation-complete="$emit('poem-reading-complete', $event)"
+    />
+
     <!-- 诗词显示 -->
     <PoemDisplay
         v-if="poemShow"
@@ -49,6 +56,7 @@ import QuestDialogue from './dialogType/QuestDialogue.vue';
 import UserDialogue from './dialogType/UserDialogue.vue';
 import PoemDisplay from './dialogType/PoemDisplay.vue';
 import VideoDisplay from './dialogType/VideoDisplay.vue';
+import PoemReadingDialogue from './dialogType/PoemReadingDialogue.vue';
 
 /**
  * DialogCard - 统一对话卡片容器
@@ -96,8 +104,9 @@ defineProps({
  * @event user-input-submit - 用户文本输入提交
  * @event single-choice-submit - 单选问题提交
  * @event video-ended - 视频播放结束
+ * @event poem-reading-complete - 诗词朗读评价完成
  */
-defineEmits(['user-input-submit', 'single-choice-submit', 'video-ended']);
+defineEmits(['user-input-submit', 'single-choice-submit', 'video-ended', 'poem-reading-complete']);
 
 /**
  * 获取问题类型
@@ -119,4 +128,4 @@ const getQuestionType = (questDialogue) => {
   flex-direction: column;
   align-items: center;
 }
-</style>
+</style>

+ 523 - 0
src/components/aiCourse/dialog/dialogType/PoemReadingDialogue.vue

@@ -0,0 +1,523 @@
+<template>
+    <!-- 诗词展示区域 - 使用现成的 PoemDisplay 组件 -->
+    <PoemDisplay :content="content" />
+
+    <!-- 录音控制区域 -->
+    <div class="recording-area">
+      <!-- 录音按钮 -->
+      <button
+        @click="toggleRecording"
+        class="microphone-btn"
+        :class="{
+          'recording': isRecording,
+          'loading': isEvaluating,
+          'disabled': isEvaluating
+        }"
+        :disabled="isEvaluating"
+      >
+        <div class="waveform-container" v-if="isRecording">
+          <LiveWaveform
+            style="min-width: 40px;"
+            :active="isRecording"
+            :processing="false"
+            :height="20"
+            :barWidth="2"
+            :barGap="1"
+            :barRadius="1"
+            :sensitivity="1.2"
+          />
+        </div>
+        <div class="loading-ring" v-else-if="isEvaluating"></div>
+        <svg v-else class="mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
+          <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
+          <line x1="12" y1="19" x2="12" y2="23"/>
+          <line x1="8" y1="23" x2="16" y2="23"/>
+        </svg>
+        <span class="btn-text">{{ buttonText }}</span>
+      </button>
+
+
+    </div>
+
+    <!-- 评价结果展示 -->
+    <div class="evaluation-result" v-if="evaluationResult">
+      <div class="result-header">
+        <span class="result-title">朗读评价</span>
+        <div class="result-score" :class="scoreLevel">{{ evaluationResult.score }}分</div>
+      </div>
+      <div class="result-content">
+        <p class="result-text">{{ displayedResult }}</p>
+      </div>
+      <audio
+        v-if="evaluationResult.audioUrl"
+        :src="evaluationResult.audioUrl"
+        controls
+        class="result-audio"
+        @ended="handleAudioEnded"
+      />
+    </div>
+</template>
+
+<script setup>
+import { ref, computed, onUnmounted } from 'vue';
+import { ElMessage } from 'element-plus';
+import LiveWaveform from '../../../ai/voice/LiveWaveform.vue';
+import PoemDisplay from './PoemDisplay.vue';
+import { evaluateReading } from '@/api/audio.js';
+import { useAudioPlayer } from '@/api/tts/useAudioPlayer.js';
+// 音频播放器钩子
+const { playAudioChunk } = useAudioPlayer();
+
+const props = defineProps({
+  content: {
+    type: String,
+    default: ''
+  }
+});
+
+const emit = defineEmits(['evaluationComplete']);
+
+// 状态管理
+const isRecording = ref(false);
+const isEvaluating = ref(false);
+const evaluationResult = ref(null);
+const displayedResult = ref('');
+
+// 录音相关
+const mediaRecorder = ref(null);
+const mediaStream = ref(null);
+const audioChunks = ref([]);
+const recordedBlob = ref(null);
+
+// 流式请求相关
+const abortController = ref(null);
+
+// 按钮文本
+const buttonText = computed(() => {
+  if (isEvaluating.value) return '处理中...';
+  if (isRecording.value) return '点击停止';
+  return '开始录音';
+});
+
+// 分数等级
+const scoreLevel = computed(() => {
+  if (!evaluationResult.value) return '';
+  const score = evaluationResult.value.score;
+  if (score >= 90) return 'excellent';
+  if (score >= 80) return 'good';
+  if (score >= 60) return 'pass';
+  return 'fail';
+});
+
+// 切换录音状态
+const toggleRecording = async () => {
+  if (isRecording.value) {
+    stopRecording();
+  } else {
+    await startRecording();
+  }
+};
+
+// 解锁浏览器音频自动播放限制
+const unlockAudio = async () => {
+  try {
+    const ctx = new (window.AudioContext || window.webkitAudioContext)();
+    const oscillator = ctx.createOscillator();
+    const gainNode = ctx.createGain();
+
+    oscillator.connect(gainNode);
+    gainNode.connect(ctx.destination);
+
+    gainNode.gain.value = 0;
+    oscillator.frequency.value = 440;
+    oscillator.start();
+    oscillator.stop(ctx.currentTime + 0.01);
+
+    await new Promise(resolve => setTimeout(resolve, 100));
+    ctx.close();
+
+    console.log('[音频解锁] 浏览器音频已解锁');
+  } catch (e) {
+    console.log('[音频解锁] 解锁失败:', e);
+  }
+};
+
+// 开始录音
+const startRecording = async () => {
+  try {
+    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+      ElMessage.error('当前浏览器不支持录音功能');
+      return;
+    }
+
+    await unlockAudio();
+
+    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+    mediaStream.value = stream;
+
+    const preferredTypes = [
+      'audio/wav',
+      'audio/mp3',
+      'audio/aac',
+      'audio/flac',
+      'audio/webm;codecs=opus',
+      'audio/webm',
+      'audio/ogg;codecs=opus',
+      'audio/ogg'
+    ];
+
+    const supportedTypes = preferredTypes.filter(type => window.MediaRecorder.isTypeSupported(type));
+
+    if (supportedTypes.length === 0) {
+      ElMessage.error('当前浏览器不支持音频录制');
+      stream.getTracks().forEach(track => track.stop());
+      return;
+    }
+
+    const options = { mimeType: supportedTypes[0] };
+    mediaRecorder.value = new MediaRecorder(stream, options);
+
+    mediaRecorder.value.ondataavailable = (event) => {
+      if (event.data.size > 0) {
+        audioChunks.value.push(event.data);
+      }
+    };
+
+    mediaRecorder.value.onstop = async () => {
+      const recordedBlobTemp = new Blob(audioChunks.value, { type: mediaRecorder.value.mimeType });
+      audioChunks.value = [];
+
+      const mimeType = mediaRecorder.value.mimeType;
+      // 如果录制的不是WAV格式,转换为WAV(后端支持的格式)
+      if (!mimeType.startsWith('audio/wav')) {
+        try {
+          recordedBlob.value = await convertToWav(recordedBlobTemp);
+        } catch (error) {
+          console.error('音频格式转换失败:', error);
+          ElMessage.error('音频格式转换失败,请重试');
+          return;
+        }
+      } else {
+        recordedBlob.value = recordedBlobTemp;
+      }
+
+      convertBlobToBase64AndEvaluate();
+    };
+
+    mediaRecorder.value.start(100);
+    isRecording.value = true;
+
+  } catch (error) {
+    console.error('录音启动失败:', error);
+    ElMessage.error('录音失败,请检查麦克风权限');
+  }
+};
+
+// 停止录音
+const stopRecording = () => {
+  if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
+    mediaRecorder.value.stop();
+  }
+  if (mediaStream.value) {
+    mediaStream.value.getTracks().forEach(track => track.stop());
+    mediaStream.value = null;
+  }
+  isRecording.value = false;
+};
+
+// 将Blob转换为Base64并调用评价接口
+const convertBlobToBase64AndEvaluate = async () => {
+  if (!recordedBlob.value) return;
+
+  isEvaluating.value = true;
+  displayedResult.value = '';
+
+  try {
+    const base64Audio = await blobToBase64(recordedBlob.value);
+    abortController.value = await evaluateReading(base64Audio, handleStreamData);
+  } catch (error) {
+    console.error('评价请求失败:', error);
+    ElMessage.error('评价请求失败,请重试');
+    resetState();
+  }
+};
+
+// 将音频转换为WAV格式
+const convertToWav = async (blob) => {
+  return new Promise((resolve, reject) => {
+    const audioContext = new (window.AudioContext || window.webkitAudioContext)();
+    const reader = new FileReader();
+
+    reader.onload = async (e) => {
+      try {
+        const arrayBuffer = e.target.result;
+        const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
+        const wavBuffer = encodeAudioBufferToWav(audioBuffer);
+        const wavBlob = new Blob([wavBuffer], { type: 'audio/wav' });
+        audioContext.close();
+        resolve(wavBlob);
+      } catch (error) {
+        audioContext.close();
+        reject(error);
+      }
+    };
+
+    reader.onerror = () => {
+      audioContext.close();
+      reject(new Error('读取音频失败'));
+    };
+
+    reader.readAsArrayBuffer(blob);
+  });
+};
+
+// 将AudioBuffer编码为WAV格式
+const encodeAudioBufferToWav = (audioBuffer) => {
+  const sampleRate = audioBuffer.sampleRate;
+  const channels = audioBuffer.numberOfChannels;
+  const length = audioBuffer.length * channels * 2 + 44;
+  const buffer = new ArrayBuffer(length);
+  const view = new DataView(buffer);
+
+  // WAV header
+  writeString(view, 0, 'RIFF');
+  view.setUint32(4, length - 8, true);
+  writeString(view, 8, 'WAVE');
+  writeString(view, 12, 'fmt ');
+  view.setUint32(16, 16, true);
+  view.setUint16(20, 1, true);
+  view.setUint16(22, channels, true);
+  view.setUint32(24, sampleRate, true);
+  view.setUint32(28, sampleRate * channels * 2, true);
+  view.setUint16(32, channels * 2, true);
+  view.setUint16(34, 16, true);
+  writeString(view, 36, 'data');
+  view.setUint32(40, length - 44, true);
+
+  // 写入音频数据
+  let offset = 44;
+  for (let i = 0; i < audioBuffer.length; i++) {
+    for (let channel = 0; channel < channels; channel++) {
+      const sample = Math.max(-1, Math.min(1, audioBuffer.getChannelData(channel)[i]));
+      view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
+      offset += 2;
+    }
+  }
+
+  return buffer;
+};
+
+// 辅助函数:写入字符串到DataView
+const writeString = (view, offset, string) => {
+  for (let i = 0; i < string.length; i++) {
+    view.setUint8(offset + i, string.charCodeAt(i));
+  }
+};
+
+// Blob转Base64
+const blobToBase64 = (blob) => {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onloadend = () => resolve(reader.result);
+    reader.onerror = reject;
+    reader.readAsDataURL(blob);
+  });
+};
+
+// 处理流式数据
+const handleStreamData = async (data) => {
+  if (data.eventType === 'TEXT') {
+    displayedResult.value += data.content || '';
+  } else if (data.eventType === 'AUDIO') {
+    // 使用统一的音频播放器播放
+    await playAudioChunk(data.audioUrl);
+
+    // 保存音频URL用于界面播放器
+    if (!evaluationResult.value) {
+      evaluationResult.value = {};
+    }
+    evaluationResult.value.audioUrl = `data:audio/wav;base64,${data.audioUrl}`;
+  } else if (data.eventType === 'COMPLETE') {
+    isEvaluating.value = false;
+
+    if (!evaluationResult.value) {
+      evaluationResult.value = {};
+    }
+    evaluationResult.value.score = data.score;
+    evaluationResult.value.content = displayedResult.value;
+
+    emit('evaluationComplete', evaluationResult.value);
+  }
+};
+
+// 处理音频播放完成
+const handleAudioEnded = () => {
+  // 音频播放完成后的处理
+};
+
+// 重置状态
+const resetState = () => {
+  isEvaluating.value = false;
+};
+
+// 组件卸载时清理
+onUnmounted(() => {
+  stopRecording();
+  if (abortController.value) {
+    abortController.value.abort();
+  }
+});
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.recording-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: rpx(15);
+  margin-bottom: rpx(60);
+}
+
+.microphone-btn {
+  width: rpx(80);
+  height: rpx(80);
+  border-radius: 50%;
+  border: 2px solid #ffce1b;
+  background: #fff;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: rpx(3);
+  cursor: pointer;
+  transition: all 0.3s ease;
+  box-shadow: 0 3px 10px rgba(255, 206, 27, 0.3);
+
+  &:hover:not(.disabled) {
+    transform: scale(1.05);
+    box-shadow: 0 4px 15px rgba(255, 206, 27, 0.4);
+  }
+
+  &.recording {
+    background: #ffeeba;
+    border-color: #dc3545;
+    animation: pulse 1.5s ease-in-out infinite;
+
+    .mic-icon {
+      color: #dc3545;
+    }
+  }
+
+  &.loading, &.disabled {
+    opacity: 0.7;
+    cursor: not-allowed;
+  }
+}
+
+@keyframes pulse {
+  0%, 100% {
+    box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.4);
+  }
+  50% {
+    box-shadow: 0 0 0 rpx(10) rgba(220, 53, 69, 0);
+  }
+}
+
+.mic-icon {
+  width: rpx(28);
+  height: rpx(28);
+  color: #ffce1b;
+  transition: color 0.3s;
+}
+
+.btn-text {
+  font-size: rpx(12);
+  color: #666;
+}
+
+.waveform-container {
+  width: rpx(50);
+}
+
+.loading-ring {
+  width: rpx(30);
+  height: rpx(30);
+  border: 3px solid #ffce1b;
+  border-top-color: transparent;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.evaluation-result {
+  margin-top: rpx(30);
+  padding: rpx(25);
+  background: rgba(255, 255, 255, 0.95);
+  border-radius: rpx(15);
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+  width: rpx(550);
+  animation: resultFadeIn 0.5s ease-out;
+}
+
+@keyframes resultFadeIn {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.result-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: rpx(20);
+  padding-bottom: rpx(15);
+  border-bottom: 1px solid #eee;
+}
+
+.result-title {
+  font-size: rpx(20);
+  font-weight: bold;
+  color: #333;
+}
+
+.result-score {
+  font-size: rpx(36);
+  font-weight: bold;
+
+  &.excellent { color: #28a745; }
+  &.good { color: #17a2b8; }
+  &.pass { color: #ffce1b; }
+  &.fail { color: #dc3545; }
+}
+
+.result-content {
+  margin-bottom: rpx(20);
+}
+
+.result-text {
+  font-size: rpx(18);
+  color: #555;
+  line-height: 1.8;
+}
+
+.result-audio {
+  width: 100%;
+  height: rpx(40);
+  border-radius: rpx(20);
+}
+</style>