Procházet zdrojové kódy

富文本加音频

zq před 1 týdnem
rodič
revize
ea44ea8dee

+ 57 - 114
src/components/PinyinText.vue

@@ -1,51 +1,31 @@
 <template>
   <div class="pinyin-area" :style="{ 'text-align': pinyinOverallPosition, padding: pinyinPadding }">
+    <AudioPlay v-if="isEnable(isEnableVoice)" :file-id="audioFileId" :theme-color="unifiedAttrib && unifiedAttrib.topic_color ? unifiedAttrib.topic_color : ''
+      " />
+
     <!-- 新规则:使用 richTextList -->
     <div v-if="richTextList && richTextList.length > 0" class="rich-text-container">
       <template v-for="(block, index) in parsedBlocks">
         <!-- 文字块:包含 word_list -->
-        <span
-          v-if="block.type === 'text'"
-          :key="'text-' + index"
-          class="pinyin-sentence"
-          :style="[{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }, block.styleObj]"
-        >
+        <span v-if="block.type === 'text'" :key="'text-' + index" class="pinyin-sentence"
+          :style="[{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }, block.styleObj]">
           <span v-for="(word, wIndex) in block.word_list" :key="wIndex" class="pinyin-text">
-            <span
-              :class="{ active: visible && word_index == wIndex && sentence_index === block.index }"
-              :title="isPreview ? '' : '点击校对'"
-              :style="{
+            <span :class="{ active: visible && word_index == wIndex && sentence_index === block.index }"
+              :title="isPreview ? '' : '点击校对'" :style="{
                 cursor: isPreview ? '' : 'pointer',
                 'align-items': wIndex == 0 ? 'flex-start' : 'center',
-              }"
-              @click="correctPinyin(word, block.paragraphIndex, block.oldIndex, wIndex)"
-            >
-              <span
-                v-if="pinyinPosition === 'top' && hasPinyinInParagraphNeVersion(block.paragraphIndex)"
-                class="pinyin"
-              >
-                {{ getPinyinText(word) }}</span
-              >
+              }" @click="correctPinyin(word, block.paragraphIndex, block.oldIndex, wIndex)">
+              <span v-if="pinyinPosition === 'top' && hasPinyinInParagraphNeVersion(block.paragraphIndex)"
+                class="pinyin">
+                {{ getPinyinText(word) }}</span>
               <span class="py-char" :style="textStyle(word)">{{ convertText(word.text) }}</span>
-              <span
-                v-if="pinyinPosition !== 'top' && hasPinyinInParagraphNeVersion(block.paragraphIndex)"
-                class="pinyin"
-              >
-                {{ getPinyinText(word) }}</span
-              >
+              <span v-if="pinyinPosition !== 'top' && hasPinyinInParagraphNeVersion(block.paragraphIndex)"
+                class="pinyin">
+                {{ getPinyinText(word) }}</span>
             </span>
           </span>
         </span>
-        <!-- 图片块 -->
-        <img
-          v-else-if="block.type === 'image'"
-          :key="'image-' + index"
-          :src="block.src"
-          :alt="block.alt"
-          :width="block.width"
-          :height="block.height"
-          class="inline-image"
-        />
+
         <!-- 换行符 -->
         <br v-else-if="block.type === 'newline'" :key="'newline-' + index" />
       </template>
@@ -54,63 +34,33 @@
     <!-- 老规则:使用 paragraphList -->
     <template v-else-if="paragraphList && paragraphList.length > 0">
       <div v-for="(paragraph, pIndex) in paragraphList" :key="pIndex" class="pinyin-paragraph">
-        <div
-          v-for="(sentence, sIndex) in paragraph"
-          :key="sIndex"
-          class="pinyin-sentence"
-          :style="{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }"
-        >
+        <div v-for="(sentence, sIndex) in paragraph" :key="sIndex" class="pinyin-sentence"
+          :style="{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }">
           <span v-for="(word, wIndex) in sentence" :key="wIndex" class="pinyin-text">
-            <span
-              :class="{
-                active: visible && word_index == wIndex && sentence_index === sIndex && paragraph_index === pIndex,
-              }"
-              :title="isPreview ? '' : '点击校对'"
-              :style="{
-                cursor: isPreview ? '' : 'pointer',
-                'align-items': wIndex == 0 ? 'flex-start' : 'center',
-              }"
-              @click="correctPinyin(word, pIndex, sIndex, wIndex)"
-            >
-              <span
-                v-if="pinyinPosition === 'top' && hasPinyinInParagraph(sIndex)"
-                class="pinyin"
-                :style="{ 'font-size': pinyinSize }"
-              >
-                {{ getPinyinText(word) }}</span
-              >
+            <span :class="{
+              active: visible && word_index == wIndex && sentence_index === sIndex && paragraph_index === pIndex,
+            }" :title="isPreview ? '' : '点击校对'" :style="{
+              cursor: isPreview ? '' : 'pointer',
+              'align-items': wIndex == 0 ? 'flex-start' : 'center',
+            }" @click="correctPinyin(word, pIndex, sIndex, wIndex)">
+              <span v-if="pinyinPosition === 'top' && hasPinyinInParagraph(sIndex)" class="pinyin"
+                :style="{ 'font-size': pinyinSize }">
+                {{ getPinyinText(word) }}</span>
               <span class="py-char" :style="textStyle(word)">{{ convertText(word.text) }}</span>
-              <span
-                v-if="pinyinPosition !== 'top' && hasPinyinInParagraph(sIndex)"
-                class="pinyin"
-                :style="{ 'font-size': pinyinSize }"
-              >
-                {{ getPinyinText(word) }}</span
-              >
+              <span v-if="pinyinPosition !== 'top' && hasPinyinInParagraph(sIndex)" class="pinyin"
+                :style="{ 'font-size': pinyinSize }">
+                {{ getPinyinText(word) }}</span>
             </span>
           </span>
         </div>
       </div>
     </template>
 
-    <CorrectPinyin
-      :visible.sync="visible"
-      :new-version="newVersion"
-      :select-content="selectContent"
-      :component-type="componentType"
-      @fillTonePinyin="fillTonePinyin"
-    />
-
-    <el-dialog
-      ref="optimizedDialog"
-      title=""
-      :visible.sync="noteDialogVisible"
-      width="680px"
-      :style="dialogStyle"
-      :close-on-click-modal="false"
-      destroy-on-close
-      @close="noteDialogVisible = false"
-    >
+    <CorrectPinyin :visible.sync="visible" :new-version="newVersion" :select-content="selectContent"
+      :component-type="componentType" @fillTonePinyin="fillTonePinyin" />
+
+    <el-dialog ref="optimizedDialog" title="" :visible.sync="noteDialogVisible" width="680px" :style="dialogStyle"
+      :close-on-click-modal="false" destroy-on-close @close="noteDialogVisible = false">
       <span v-html="sanitizeHTML(note)"></span>
     </el-dialog>
   </div>
@@ -120,14 +70,28 @@
 import CorrectPinyin from '@/views/book/courseware/create/components/base/common/CorrectPinyin.vue';
 import { sanitizeHTML } from '@/utils/common';
 import { isEnable } from '@/views/book/courseware/data/common';
+import AudioPlay from '@/views/book/courseware/preview/components/character_base/components/AudioPlay.vue';
 
 export default {
   name: 'PinyinText',
   components: {
     CorrectPinyin,
+    AudioPlay
   },
   inject: ['convertText'],
   props: {
+    isEnableVoice:{
+      type: String,
+      default: 'false'
+    },
+    audioFileId: {
+      type: String,
+      default: '',
+    },
+    unifiedAttrib: {
+      type: Object,
+      default: () => ({}),
+    },
     paragraphList: {
       type: Array,
       default: () => [],
@@ -211,32 +175,19 @@ export default {
       for (const item of this.richTextList) {
         oldIndex += 1;
 
-        if (item.text && typeof item.text === 'string' && item.text.includes('<img')) {
-          // 处理图片
-          const imgMatch = item.text.match(/<img\s+([^>]*)\/?\s*>/i);
-          if (imgMatch) {
-            const attrs = imgMatch[1];
-            const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
-            const altMatch = attrs.match(/alt=["']([^"']*)["']/i);
-            const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
-            const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
-
-            blocks.push({
-              type: 'image',
-              src: srcMatch ? srcMatch[1] : '',
-              alt: altMatch ? altMatch[1] : '',
-              width: widthMatch ? widthMatch[1] : null,
-              height: heightMatch ? heightMatch[1] : null,
-            });
-          }
-        } else if (item.is_style === 'true' || item.is_style === true) {
+        if (item.is_style === 'true' || item.is_style === true) {
           const tagText = item.text || '';
           // 正则匹配闭标签:匹配 </tagname> 格式
           const closeTagMatch = tagText.match(/^<\/(\w+)>$/);
           if (closeTagMatch) {
             // 闭标签:从栈顶开始查找最近的同名标签并移除(LIFO 原则)
             const tagName = closeTagMatch[1];
-            this.removeTagFromStack(tagStack, tagName);
+            for (let i = tagStack.length - 1; i >= 0; i--) {
+              if (tagStack[i].tag === tagName) {
+                tagStack.splice(i, 1);
+                break;
+              }
+            }
           } else {
             // 开标签:解析标签名和样式并压栈
             const openTagMatch = tagText.match(/^<(\w+)([^>]*)>$/);
@@ -325,14 +276,6 @@ export default {
   },
 
   methods: {
-    removeTagFromStack(tagStack, tagName) {
-      for (let i = tagStack.length - 1; i >= 0; i--) {
-        if (tagStack[i].tag === tagName) {
-          tagStack.splice(i, 1);
-          break;
-        }
-      }
-    },
     textStyle(item) {
       const styles = { ...item.activeTextStyle };
       styles['font-size'] = styles.fontSize;
@@ -477,7 +420,7 @@ export default {
     text-wrap: pretty;
     hanging-punctuation: allow-end;
 
-    > span {
+    >span {
       display: inline-flex;
       flex-direction: column;
       align-items: center;
@@ -508,7 +451,7 @@ export default {
 </style>
 
 <style lang="scss">
-.pinyin-area + .pinyin-area {
+.pinyin-area+.pinyin-area {
   margin-top: 4px;
 }
 </style>

+ 115 - 68
src/views/book/courseware/create/components/base/rich_text/RichText.vue

@@ -2,85 +2,61 @@
   <ModuleBase ref="base" :type="data.type">
     <template #content>
       <div :style="{ width: data.note_list.length > 0 ? '' : '100%' }">
-        <RichText
-          ref="richText"
-          v-model="data.content"
-          :is-view-note="true"
-          :is-title="isEnable(data.property.is_title)"
-          :is-view-pinyin="isEnable(data.property.view_pinyin)"
-          :font-size="data?.unified_attrib?.font_size"
-          :font-family="data?.unified_attrib?.font"
-          placeholder="输入内容"
-          @view-explanatory-note="viewExplanatoryNote"
-          @selectContentSetMemo="selectContentSetMemo"
+        <RichText ref="richText" v-model="data.content" :is-view-note="true"
+          :is-title="isEnable(data.property.is_title)" :is-view-pinyin="isEnable(data.property.view_pinyin)"
+          :font-size="data?.unified_attrib?.font_size" :font-family="data?.unified_attrib?.font" placeholder="输入内容"
+          @view-explanatory-note="viewExplanatoryNote" @selectContentSetMemo="selectContentSetMemo"
           @createParsedTextInfoPinyin="createParsedTextInfoPinyin"
           @createParsedTextStyleForTitle="createParsedTextStyleForTitle"
-          @compareAnnotationAndSave="compareAnnotationAndSave"
-        />
+          @compareAnnotationAndSave="compareAnnotationAndSave" />
+
+        <!-- 生成音频 -->
+        <template v-if="data.property.is_enable_voice === 'true'">
+          <div v-if="data.audio_file_id" style="margin-top: 8px">
+            <SoundRecord :wav-blob.sync="data.audio_file_id" />
+          </div>
+          <template v-else>
+            <div :class="['upload-audio-play']">
+              <UploadAudio v-if="data.property.audio_generation_method === 'upload'" :file-id="data.audio_file_id"
+                :show-upload="!data.audio_file_id" @upload="uploads" @deleteFile="deleteFiles" />
+              <div v-else-if="data.property.audio_generation_method === 'auto'" class="auto-matic"
+                @click="handleMatic(data.content)">
+                <SvgIcon icon-class="voiceprint-line" class="record" />
+                <span class="auto-btn">{{ data.audio_file_id ? '已生成' : '生成音频' }}</span>{{ data.audio_file_id ? '成功' : ''
+                }}
+              </div>
+              <SoundRecord v-else :wav-blob.sync="data.audio_file_id" />
+            </div>
+          </template>
+        </template>
+
         <el-button class="btn" @click="openMultilingual">多语言</el-button>
 
-        <MultilingualFill
-          :visible.sync="multilingualVisible"
-          :text="data.content"
-          :translations="data.multilingual"
-          @SubmitTranslation="handleMultilingualTranslation"
-        />
+        <MultilingualFill :visible.sync="multilingualVisible" :text="data.content" :translations="data.multilingual"
+          @SubmitTranslation="handleMultilingualTranslation" />
 
-        <el-button
-          v-show="isEnable(data.property.view_pinyin)"
-          style="margin-left: 10px"
-          class="btn"
-          @click.native="showWordFlag = true"
-          >分词校对</el-button
-        >
+        <el-button v-show="isEnable(data.property.view_pinyin)" style="margin-left: 10px" class="btn"
+          @click.native="showWordFlag = true">分词校对</el-button>
 
-        <el-dialog
-          v-if="showWordFlag"
-          :visible.sync="showWordFlag"
-          :show-close="true"
-          :close-on-click-modal="true"
-          :modal-append-to-body="true"
-          :append-to-body="true"
-          :lock-scroll="true"
-          width="80%"
-          class="practiceBox"
-        >
+        <el-dialog v-if="showWordFlag" :visible.sync="showWordFlag" :show-close="true" :close-on-click-modal="true"
+          :modal-append-to-body="true" :append-to-body="true" :lock-scroll="true" width="80%" class="practiceBox">
           <CheckWord :data="wordData" @saveWord="saveWord" />
         </el-dialog>
 
-        <el-button
-          v-show="isEnable(data.property.view_pinyin)"
-          style="margin-left: 10px"
-          class="btn"
-          @click.native="generatPinyin"
-          >生成拼音</el-button
-        >
+        <el-button v-show="isEnable(data.property.view_pinyin)" style="margin-left: 10px" class="btn"
+          @click.native="generatPinyin">生成拼音</el-button>
 
         <el-divider v-if="isEnable(data.property.view_pinyin)" content-position="left">拼音效果</el-divider>
-        <PinyinText
-          v-if="isEnable(data.property.view_pinyin)"
-          :id="richId + '_pinyin_text'"
-          ref="PinyinText"
-          :paragraph-list="data.paragraph_list"
-          :rich-text-list="data.rich_text_list"
-          :paragraph-version="paragraphVersion"
-          :pinyin-position="data.property.pinyin_position"
+        <PinyinText v-if="isEnable(data.property.view_pinyin)" :id="richId + '_pinyin_text'" ref="PinyinText"
+          :paragraph-list="data.paragraph_list" :rich-text-list="data.rich_text_list"
+          :paragraph-version="paragraphVersion" :pinyin-position="data.property.pinyin_position"
           :pinyin-overall-position="data.property.pinyin_overall_position"
-          :pinyin-size="data?.unified_attrib?.pinyin_size"
-          :font-size="data?.unified_attrib?.font_size"
-          :font-family="data?.unified_attrib?.font"
-          :component-type="data.type"
-          :pinyin-padding="data.property.pinyin_padding"
-          @fillCorrectPinyin="fillCorrectPinyin"
-        />
+          :pinyin-size="data?.unified_attrib?.pinyin_size" :font-size="data?.unified_attrib?.font_size"
+          :font-family="data?.unified_attrib?.font" :component-type="data.type"
+          :pinyin-padding="data.property.pinyin_padding" @fillCorrectPinyin="fillCorrectPinyin" />
       </div>
-      <ExplanatoryNoteDialog
-        ref="explanatoryNote"
-        :open.sync="isViewExplanatoryNoteDialog"
-        :init-data="oldRichData"
-        @confirm="confirmExplanatoryNote"
-        @cancel="cancelExplanatoryNote"
-      />
+      <ExplanatoryNoteDialog ref="explanatoryNote" :open.sync="isViewExplanatoryNoteDialog" :init-data="oldRichData"
+        @confirm="confirmExplanatoryNote" @cancel="cancelExplanatoryNote" />
     </template>
   </ModuleBase>
 </template>
@@ -95,10 +71,13 @@ import PinyinText from '@/components/PinyinText.vue';
 import DOMPurify from 'dompurify';
 import ExplanatoryNoteDialog from '@/components/ExplanatoryNoteDialog.vue';
 import CheckWord from '@/views/book/courseware/create/components/question/article/CheckWord.vue';
+import SoundRecord from '@/views/book/courseware/create/components/question/fill/components/SoundRecord.vue';
+import UploadAudio from '@/views/book/courseware/create/components/question/fill/components/UploadAudio.vue';
+import { TextToAudioFile } from '@/api/app';
 
 export default {
   name: 'RichTextPage',
-  components: { PinyinText, ExplanatoryNoteDialog, CheckWord },
+  components: { PinyinText, ExplanatoryNoteDialog, CheckWord, SoundRecord, UploadAudio },
   mixins: [ModuleMixin],
   inject: ['getBookUnifiedTitleList'],
   data() {
@@ -165,6 +144,28 @@ export default {
     },
   },
   methods: {
+    uploads(file_id, index, file_url) {
+      this.data.audio_file_id = file_id;
+    },
+    deleteFiles(index) {
+      this.data.audio_file_id = '';
+    },
+    // 生成音频
+    handleMatic(con, index) {
+      TextToAudioFile({
+        text: con.replace(/<[^>]+>/g, ''),
+        voice_type: this.data.property.voice_type,
+        emotion: this.data.property.emotion,
+        speed_ratio: this.data.property.speed_ratio,
+      })
+        .then(({ status, file_id, file_url }) => {
+          if (status === 1) {
+            this.data.audio_file_id = file_id;
+          }
+        })
+        .catch(() => { });
+    },
+
     async saveWord(saveArr) {
       const text = this.data.content.replace(/<[^>]+>/g, '');
       await this.createParsedTextInfoPinyin(null, null, saveArr);
@@ -180,7 +181,7 @@ export default {
       let isCreate =
         this.data.paragraph_list.length <= 0 ||
         this.data.paragraph_list_parameter.is_first_sentence_first_hz_pinyin_first_char_upper_case !==
-          property.is_first_sentence_first_hz_pinyin_first_char_upper_case;
+        property.is_first_sentence_first_hz_pinyin_first_char_upper_case;
       if (text && isCreate) {
         this.data.paragraph_list_parameter.text = text;
         this.data.paragraph_list_parameter.is_first_sentence_first_hz_pinyin_first_char_upper_case =
@@ -398,4 +399,50 @@ export default {
 .btn {
   margin-top: 12px;
 }
+
+.auto-matic,
+.upload-audio-play {
+  margin-top: 8px;
+
+  :deep .upload-wrapper {
+    margin-top: 0;
+  }
+
+  .audio-wrapper {
+    :deep .audio-play {
+      width: 16px;
+      height: 16px;
+      color: #000;
+      background-color: initial;
+    }
+
+    :deep .audio-play.not-url {
+      color: #a1a1a1;
+    }
+
+    :deep .voice-play {
+      width: 16px;
+      height: 16px;
+    }
+  }
+}
+
+.auto-matic {
+  display: flex;
+  flex-shrink: 0;
+  column-gap: 12px;
+  align-items: center;
+  width: 200px;
+  padding: 5px 12px;
+  background-color: $fill-color;
+  border-radius: 2px;
+
+  .auto-btn {
+    font-size: 16px;
+    font-weight: 400;
+    line-height: 22px;
+    color: #1d2129;
+    cursor: pointer;
+  }
+}
 </style>

+ 56 - 29
src/views/book/courseware/create/components/base/rich_text/RichTextSetting.vue

@@ -19,34 +19,48 @@
         <el-switch v-model="property.view_pinyin" active-value="true" inactive-value="false" />
       </el-form-item>
       <el-form-item label="拼音位置">
-        <el-radio
-          v-for="{ value, label } in pinyinPositionList"
-          :key="value"
-          v-model="property.pinyin_position"
-          :label="value"
-          :disabled="!isEnable(property.view_pinyin)"
-        >
+        <el-radio v-for="{ value, label } in pinyinPositionList" :key="value" v-model="property.pinyin_position"
+          :label="value" :disabled="!isEnable(property.view_pinyin)">
           {{ label }}
         </el-radio>
       </el-form-item>
       <el-form-item label="对齐样式">
-        <el-radio
-          v-for="{ value, label } in pinyinOverallPositionList"
-          :key="value"
-          v-model="property.pinyin_overall_position"
-          :label="value"
-          :disabled="!isEnable(property.view_pinyin)"
-        >
+        <el-radio v-for="{ value, label } in pinyinOverallPositionList" :key="value"
+          v-model="property.pinyin_overall_position" :label="value" :disabled="!isEnable(property.view_pinyin)">
           {{ label }}
         </el-radio>
       </el-form-item>
+
+      <el-form-item label="读音">
+        <el-switch v-model="property.is_enable_voice" active-value="true" inactive-value="false" />
+      </el-form-item>
+      <el-form-item label="读音" v-if="property.is_enable_voice === 'true'">
+        <el-select v-model="property.audio_generation_method" placeholder="请选择">
+          <el-option v-for="{ value, label } in audioGenerationMethodList" :key="value" :label="label" :value="value" />
+        </el-select>
+      </el-form-item>
+      <template v-if="property.audio_generation_method === 'auto'">
+        <el-form-item label="音色">
+          <el-select v-model="property.voice_type" placeholder="请选择">
+            <el-option v-for="{ voice_type, name } in voice_type_list" :key="voice_type" :label="name"
+              :value="voice_type" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="风格情感">
+          <el-select v-model="property.emotion">
+            <el-option v-for="{ emotion, name } in emotion_list" :key="emotion" :label="name" :value="emotion" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="语速">
+          <el-select v-model="property.speed_ratio">
+            <el-option v-for="{ value, label } in speedRatioList" :key="value" :label="label" :value="value" />
+          </el-select>
+        </el-form-item>
+      </template>
+
       <el-form-item label="边距">
-        <el-input
-          v-model="property.pinyin_padding"
-          :disabled="!isEnable(property.view_pinyin)"
-          placeholder="例:10px 20px 30px 40px"
-          style="width: 95%"
-        />
+        <el-input v-model="property.pinyin_padding" :disabled="!isEnable(property.view_pinyin)"
+          placeholder="例:10px 20px 30px 40px" style="width: 95%" />
       </el-form-item>
       <el-form-item label="">
         <div style="font-size: 12px; line-height: 1.5; color: #999">
@@ -54,13 +68,8 @@
         </div>
       </el-form-item>
       <el-form-item label="">
-        <el-checkbox
-          v-model="property.is_first_sentence_first_hz_pinyin_first_char_upper_case"
-          :disabled="!isEnable(property.view_pinyin)"
-          true-label="true"
-          false-label="false"
-          >句首大写</el-checkbox
-        >
+        <el-checkbox v-model="property.is_first_sentence_first_hz_pinyin_first_char_upper_case"
+          :disabled="!isEnable(property.view_pinyin)" true-label="true" false-label="false">句首大写</el-checkbox>
       </el-form-item>
     </el-form>
   </div>
@@ -68,8 +77,9 @@
 
 <script>
 import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
-import { isEnable, pinyinPositionList, pinyinOverallPositionList } from '@/views/book/courseware/data/common';
+import { isEnable, pinyinPositionList, pinyinOverallPositionList, audioGenerationMethodList,speedRatioList } from '@/views/book/courseware/data/common';
 import { getRichTextProperty } from '@/views/book/courseware/data/richText';
+import { GetTextToAudioConfParamList } from '@/api/app';
 
 export default {
   name: 'RichTextSetting',
@@ -79,19 +89,36 @@ export default {
       isEnable,
       pinyinPositionList,
       pinyinOverallPositionList,
+      audioGenerationMethodList,
       labelPosition: 'left',
       property: getRichTextProperty(),
       selectContent: '',
       visible: false,
       content: '',
       titleList: [],
+      voice_type_list: [],
+      emotion_list: [],
+      speedRatioList,
     };
   },
   inject: ['getBookUnifiedTitleList'],
   created() {
     if (typeof this.getBookUnifiedTitleList === 'function') this.titleList = this.getBookUnifiedTitleList() || [];
+    this.getTextToAudioConfParamList();
+  },
+  methods: {
+     // 得到文本转音频的配置参数列表
+    getTextToAudioConfParamList() {
+      GetTextToAudioConfParamList()
+        .then(({ status, voice_type_list, emotion_list }) => {
+          if (status === 1) {
+            this.voice_type_list = voice_type_list;
+            this.emotion_list = emotion_list;
+          }
+        })
+        .catch(() => {});
+    },
   },
-  methods: {},
 };
 </script>
 

+ 16 - 0
src/views/book/courseware/data/common.js

@@ -112,6 +112,22 @@ export const switchOption = [
   },
 ];
 
+// 读音生成方式
+export const audioGenerationMethodList = [
+  {
+    value: 'upload',
+    label: '上传',
+  },
+  {
+    value: 'auto',
+    label: '自动生成',
+  },
+  {
+    value: 'record',
+    label: '录音',
+  },
+];
+
 // 参考答案数据模板
 export const answerData = {
   answer_rich_text: '', // 参考答案富文本

+ 7 - 0
src/views/book/courseware/data/richText.js

@@ -6,6 +6,7 @@ import {
   pinyinPositionList,
   pinyinOverallPositionList,
   commonComponentProperty,
+  audioGenerationMethodList,
 } from '@/views/book/courseware/data/common';
 
 // 按用途分类的数学公式宏列表
@@ -467,6 +468,11 @@ export function getRichTextProperty() {
     title_style_level: '', // 标题样式级别
     is_title: 'false', // 是否标题
     ...commonComponentProperty,
+    is_enable_voice: 'false',
+    voice_type: '', // 音色
+    emotion: '', // 风格,情感
+    speed_ratio: '',
+    audio_generation_method: audioGenerationMethodList[0].value,
   };
 }
 
@@ -488,5 +494,6 @@ export function getRichTextData() {
       node_list: [{ name: '富文本' }],
     },
     multilingual: [], // 多语言
+    audio_file_id: '',
   };
 }

+ 20 - 29
src/views/book/courseware/preview/components/rich_text/RichTextPreview.vue

@@ -5,41 +5,31 @@
 
     <div class="main">
       <div ref="leftDiv" :style="{ width: data.note_list?.length > 0 ? '' : '100%' }">
-        <PinyinText
-          v-if="isEnable(data.property.view_pinyin)"
-          :paragraph-list="data.paragraph_list"
-          :rich-text-list="data.rich_text_list"
+        <PinyinText v-if="isEnable(data.property.view_pinyin)" :is-enable-voice="data.property.is_enable_voice"
+          :audio-file-id="data.audio_file_id" :unified-attrib="data.unified_attrib"
+          :paragraph-list="data.paragraph_list" :rich-text-list="data.rich_text_list"
           :pinyin-position="data.property.pinyin_position"
           :pinyin-overall-position="data.property.pinyin_overall_position"
-          :pinyin-size="data?.unified_attrib?.pinyin_size"
-          :font-size="data?.unified_attrib?.font_size"
-          :font-family="data?.unified_attrib?.font"
-          :pinyin-padding="data.property.pinyin_padding"
-          :is-preview="isPreview"
-        />
-        <span
-          v-else
-          class="rich-text"
-          @click="handleRichFillClick"
-          v-html="convertText(sanitizeHTML(data.content))"
-        ></span>
+          :pinyin-size="data?.unified_attrib?.pinyin_size" :font-size="data?.unified_attrib?.font_size"
+          :font-family="data?.unified_attrib?.font" :pinyin-padding="data.property.pinyin_padding"
+          :is-preview="isPreview" />
+        <div v-else>
+          <AudioPlay v-if="isEnable(data.property.is_enable_voice)" :file-id="data.audio_file_id" :theme-color="data.unified_attrib && data.unified_attrib.topic_color ? data.unified_attrib.topic_color : ''
+            " />
+          <span class="rich-text" @click="handleRichFillClick" v-html="convertText(sanitizeHTML(data.content))"></span>
+
+        </div>
+
       </div>
 
+
       <div v-show="showLang" class="lang">
-        {{ data.multilingual?.find((item) => item.type === getLang())?.translation }}
+        {{data.multilingual?.find((item) => item.type === getLang())?.translation}}
       </div>
     </div>
 
-    <el-dialog
-      ref="optimizedDialog"
-      title=""
-      :visible.sync="noteDialogVisible"
-      width="680px"
-      :style="dialogStyle"
-      :close-on-click-modal="false"
-      destroy-on-close
-      @close="noteDialogVisible = false"
-    >
+    <el-dialog ref="optimizedDialog" title="" :visible.sync="noteDialogVisible" width="680px" :style="dialogStyle"
+      :close-on-click-modal="false" destroy-on-close @close="noteDialogVisible = false">
       <span v-html="sanitizeHTML(selectedNote)"></span>
     </el-dialog>
   </div>
@@ -50,10 +40,11 @@ import { getRichTextData } from '@/views/book/courseware/data/richText';
 import PreviewMixin from '../common/PreviewMixin';
 import { isEnable } from '@/views/book/courseware/data/common';
 import PinyinText from '@/components/PinyinText.vue';
+import AudioPlay from '../character_base/components/AudioPlay.vue';
 
 export default {
   name: 'RichTextPreview',
-  components: { PinyinText },
+  components: { PinyinText, AudioPlay },
   mixins: [PreviewMixin],
   data() {
     return {
@@ -179,7 +170,7 @@ export default {
   :deep .el-dialog {
     position: fixed;
     margin: 0 !important;
-    transition: all 0.2s; /* 添加平滑过渡效果 */
+    transition: all 0.2s;
 
     .el-dialog__header {
       padding: 0 !important;