Browse Source

语音矩阵手动打点功能

dusenyao 1 week ago
parent
commit
8727e70d2a

+ 5 - 0
src/icons/svg/font-strikethrough.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" focusable="false" xmlns="http://www.w3.org/2000/svg">
+  <path
+    d="M16 5c.6 0 1 .4 1 1v5.5a4 4 0 01-.4 1.8l-1 1.4a5.3 5.3 0 01-5.5 1 5 5 0 01-1.6-1c-.5-.4-.8-.9-1.1-1.4a4 4 0 01-.4-1.8V6c0-.6.4-1 1-1s1 .4 1 1v5.5c0 .3 0 .6.2 1l.6.7a3.3 3.3 0 002.2.8 3.4 3.4 0 002.2-.8c.3-.2.4-.5.6-.8l.2-.9V6c0-.6.4-1 1-1zM8 17h8c.6 0 1 .4 1 1s-.4 1-1 1H8a1 1 0 010-2z"
+    fill-rule="evenodd"></path>
+</svg>

+ 8 - 0
src/icons/svg/font-underline.svg

@@ -0,0 +1,8 @@
+<svg width="24" height="24" focusable="false" xmlns="http://www.w3.org/2000/svg">
+  <g fill-rule="evenodd">
+    <path
+      d="M15.6 8.5c-.5-.7-1-1.1-1.3-1.3-.6-.4-1.3-.6-2-.6-2.7 0-2.8 1.7-2.8 2.1 0 1.6 1.8 2 3.2 2.3 4.4.9 4.6 2.8 4.6 3.9 0 1.4-.7 4.1-5 4.1A6.2 6.2 0 017 16.4l1.5-1.1c.4.6 1.6 2 3.7 2 1.6 0 2.5-.4 3-1.2.4-.8.3-2-.8-2.6-.7-.4-1.6-.7-2.9-1-1-.2-3.9-.8-3.9-3.6C7.6 6 10.3 5 12.4 5c2.9 0 4.2 1.6 4.7 2.4l-1.5 1.1z">
+    </path>
+    <path d="M5 11h14a1 1 0 010 2H5a1 1 0 010-2z" fill-rule="nonzero"></path>
+  </g>
+</svg>

+ 14 - 0
src/utils/transform.js

@@ -41,3 +41,17 @@ export function secondFormatConversion(val = 0, type = 'normal') {
 export function timeFormatConversion(date) {
   return `${zeroFill(date.getHours())}:${zeroFill(date.getMinutes())}:${zeroFill(date.getSeconds())}`;
 }
+
+/**
+ * 将audio元素的currentTime转换为 分:秒:毫秒 格式
+ * @param {Number} currentTime audio元素的currentTime属性(秒)
+ * @returns {String} MM:ss:SSS
+ * @description 将以秒为单位的浮点数转换为分:秒:毫秒格式
+ */
+export function audioTimeToMMSS(currentTime) {
+  const totalMilliseconds = Math.floor(currentTime * 1000);
+  const minutes = zeroFill(Math.floor(totalMilliseconds / 60000));
+  const seconds = zeroFill(Math.floor((totalMilliseconds % 60000) / 1000));
+  const milliseconds = String(totalMilliseconds % 1000).padStart(3, '0');
+  return `${minutes}:${seconds}:${milliseconds}`;
+}

+ 373 - 0
src/views/book/courseware/create/components/question/voice_matrix/CheckSubtitles.vue

@@ -0,0 +1,373 @@
+<template>
+  <el-dialog :visible="visible" title="校对字幕" width="580px" :close-on-click-modal="false" @close="dialogClose">
+    <div class="dialog-container">
+      <div class="lrc">
+        <template v-for="(list, i) in dataList">
+          <div v-for="({ content, mark, lrc_data }, j) in list" :key="mark" class="lrc-item">
+            <div class="lrc-mark" @click="selectLrcItem(i, j)">
+              <span :class="[lrcIndex.row === i && lrcIndex.col === j ? 'triangle' : 'lrc-line']"></span>
+            </div>
+            <div class="lrc-time">
+              <span>[{{ audioTimeToMMSS(lrc_data.begin_time) }}]</span>
+            </div>
+            <span class="lrc-text" v-html="content"></span>
+          </div>
+        </template>
+      </div>
+
+      <div class="audio-player">
+        <div class="player-line">
+          <div class="progress-bar" :style="{ width: (audio.current_time / audio.max_time) * 100 + '%' }"></div>
+          <div class="progress-dot"></div>
+        </div>
+        <div class="player-controls">
+          <div class="timer">
+            <span>{{ secondFormatConversion(audio.current_time) }}</span> / <span>{{ audio_allTime }}</span>
+          </div>
+          <div class="svg-btn">
+            <SvgIcon :icon-class="iconClass" size="16" @click="playAudio" />
+          </div>
+        </div>
+      </div>
+
+      <div class="controls">
+        <span class="tips">播放时按空格进行打点</span>
+        <el-button type="primary" @click="saveSubtitles">保存</el-button>
+      </div>
+    </div>
+
+    <audio
+      :id="audioId"
+      :ref="audioId"
+      :src="url"
+      preload="metadata"
+      @loadedmetadata="onLoadedmetadata"
+      @timeupdate="onTimeupdate"
+      @canplaythrough="oncanplaythrough"
+      @pause="onPause"
+      @play="onPlay"
+    ></audio>
+  </el-dialog>
+</template>
+
+<script>
+import { GetFileURLMap } from '@/api/app';
+import { secondFormatConversion, audioTimeToMMSS } from '@/utils/transform';
+
+export default {
+  name: 'CheckSubtitles',
+  props: {
+    visible: {
+      type: Boolean,
+      default: false,
+    },
+    audioId: {
+      type: String,
+      required: true,
+    },
+    optionList: {
+      type: Array,
+      default: () => [],
+    },
+  },
+  data() {
+    return {
+      url: '',
+      audio: {
+        paused: true,
+        playing: false,
+        // 音频当前播放时长
+        current_time: 0,
+        // 音频最大播放时长
+        max_time: 0,
+        isPlaying: false,
+        loading: false,
+      },
+      play_value: 0,
+      dataList: [],
+      lrcIndex: {
+        row: 0,
+        col: 0,
+      },
+      audio_allTime: null, // 展示总时间
+      secondFormatConversion,
+      audioTimeToMMSS,
+    };
+  },
+  computed: {
+    iconClass() {
+      return this.audio.paused ? 'paused' : 'playing';
+    },
+  },
+  watch: {
+    audioId: {
+      handler(val) {
+        if (!val) return;
+        GetFileURLMap({ file_id_list: [val] }).then(({ url_map }) => {
+          this.url = url_map[val];
+        });
+      },
+      immediate: true,
+    },
+    visible: {
+      handler(val) {
+        if (!val) {
+          this.$refs[this.audioId].pause();
+        }
+      },
+    },
+    optionList: {
+      handler(val) {
+        this.dataList = JSON.parse(JSON.stringify(val));
+      },
+      immediate: true,
+      deep: true,
+    },
+  },
+  mounted() {
+    window.addEventListener('keydown', this.handleKeydown);
+  },
+  beforeDestroy() {
+    window.removeEventListener('keydown', this.handleKeydown);
+  },
+  methods: {
+    dialogClose() {
+      this.$emit('update:visible', false);
+    },
+    /**
+     * 处理键盘按下事件
+     */
+    handleKeydown(event) {
+      if (!this.visible) return;
+      if (event.code === 'Space') {
+        event.preventDefault();
+        // 获取当前播放时间
+        const audio = this.$refs[this.audioId];
+        if (audio) {
+          const currentTime = audio.currentTime;
+          let obj = {
+            begin_time: currentTime,
+            end_time: currentTime + 1,
+            text: '',
+          };
+          const { row, col } = this.lrcIndex;
+          if (row === -1) {
+            this.$message.warning('没有可用的标记位置');
+            const { begin_time, end_time } =
+              this.dataList[this.dataList.length - 1][this.dataList[this.dataList.length - 1].length - 1].lrc_data;
+
+            // 判断最后一个标记是否有效,如果无效则更新最后一个标记的结束时间
+            if (begin_time + 1 === end_time) {
+              this.dataList[this.dataList.length - 1][
+                this.dataList[this.dataList.length - 1].length - 1
+              ].lrc_data.end_time = currentTime;
+            }
+
+            this.lrcIndex = {
+              row: -1,
+              col: -1,
+            };
+            return;
+          }
+          this.dataList[row][col].lrc_data = obj;
+          // 并将上一个标记的结束时间设置为当前时间
+          if (col > 0) {
+            this.dataList[row][col - 1].lrc_data.end_time = currentTime;
+          }
+          if (col === 0 && row > 0) {
+            this.dataList[row - 1][this.dataList[row - 1].length - 1].lrc_data.end_time = currentTime;
+          }
+          let maxCol = this.dataList[row].length - 1;
+          let maxRow = this.dataList.length - 1;
+          let nextCol = col + 1;
+          let nextRow = row;
+          if (nextCol > maxCol) {
+            nextCol = 0;
+            nextRow += 1;
+            if (nextRow > maxRow) {
+              nextRow = -1;
+            }
+          }
+          // 更新当前标记的索引
+          this.lrcIndex = {
+            row: nextRow,
+            col: nextCol,
+          };
+        }
+      }
+    },
+    selectLrcItem(i, j) {
+      this.lrcIndex = {
+        row: i,
+        col: j,
+      };
+    },
+    playAudio() {
+      if (!this.url) return;
+      const audio = this.$refs[this.audioId];
+      // 暂停其他音频
+      let audioArr = document.getElementsByTagName('audio');
+      if (audioArr && audioArr.length > 0) {
+        for (let i = 0; i < audioArr.length; i++) {
+          if (audioArr[i].src === this.url) {
+            if (audioArr[i].id !== this.audioId) {
+              audioArr[i].pause();
+            }
+          } else {
+            audioArr[i].pause();
+          }
+        }
+      }
+      audio.paused ? audio.play() : audio.pause();
+    },
+    /**
+     * 音频元数据加载完成
+     */
+    onLoadedmetadata(res) {
+      this.audio.max_time = parseInt(res.target.duration);
+      this.audio_allTime = secondFormatConversion(this.audio.max_time);
+    },
+    onTimeupdate(res) {
+      this.audio.current_time = res.target.currentTime;
+      this.play_value = (this.audio.current_time / this.audio.max_time) * 100;
+      if (this.audio.current_time * 1000 > this.ed) {
+        this.$refs[this.audioId].pause();
+      }
+    },
+    oncanplaythrough() {
+      this.audio.loading = false;
+    },
+    onPause() {
+      this.audio.paused = true;
+    },
+    onPlay() {
+      this.audio.paused = false;
+    },
+    saveSubtitles() {
+      this.$emit('saveSubtitles', this.dataList);
+      this.$message.success('字幕已保存');
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.dialog-container {
+  display: flex;
+  flex-direction: column;
+  row-gap: 8px;
+
+  > div {
+    padding: 8px 4px;
+  }
+
+  .lrc {
+    height: 280px;
+    overflow-y: auto;
+
+    &-item {
+      display: flex;
+      column-gap: 8px;
+      align-items: stretch;
+
+      .lrc-mark {
+        display: flex;
+        align-items: center;
+        cursor: pointer;
+
+        .triangle {
+          width: 0;
+          height: 0;
+          border-top: 4px solid transparent;
+          border-right: 0 solid transparent;
+          border-bottom: 4px solid transparent;
+          border-left: 8px solid $main-color;
+        }
+
+        .lrc-line {
+          width: 6px;
+          height: 1px;
+          background-color: #666; // 灰色线条
+        }
+      }
+
+      .lrc-time {
+        display: flex;
+        align-items: center;
+      }
+
+      .lrc-text {
+        :deep p {
+          margin: 0;
+        }
+      }
+    }
+  }
+
+  .audio-player {
+    display: flex;
+    flex-direction: column;
+    row-gap: 16px;
+    color: #000;
+    background-color: #f2f3f5;
+    border: $border;
+    border-radius: 4px;
+
+    .player-line {
+      width: 100%;
+      height: 3px;
+      background-color: #ccc;
+
+      .progress-bar {
+        position: relative;
+        width: 0%;
+        height: 100%;
+        background-color: $main-color;
+
+        &::after {
+          position: absolute;
+          top: -2px;
+          right: -4px;
+          display: inline-block;
+          width: 8px;
+          height: 8px;
+          content: '';
+          background-color: $main-color;
+          border-radius: 50%;
+        }
+      }
+    }
+
+    .player-controls {
+      display: flex;
+      align-items: center;
+
+      svg {
+        cursor: pointer;
+      }
+
+      .svg-btn {
+        display: flex;
+        flex: 1;
+        align-items: center;
+        justify-content: center;
+      }
+    }
+  }
+
+  .controls {
+    display: flex;
+    align-items: center;
+    background-color: #f2f3f5;
+    border: $border;
+    border-radius: 4px;
+
+    .tips {
+      flex: 1;
+      text-align: center;
+    }
+  }
+}
+</style>

+ 43 - 2
src/views/book/courseware/create/components/question/voice_matrix/VoiceMatrix.vue

@@ -2,7 +2,12 @@
   <ModuleBase :type="data.type">
     <template #content>
       <div class="container">
-        <span class="link" @click="BatchSetFormat('bold')">批量设置粗体</span>
+        <div class="batch-set">
+          <SvgIcon icon-class="font-bold" size="16" @click="BatchSetFormat('bold')" />
+          <SvgIcon icon-class="font-italic" size="16" @click="BatchSetFormat('italic')" />
+          <SvgIcon icon-class="font-underline" size="16" @click="BatchSetFormat('strikethrough')" />
+          <SvgIcon icon-class="font-strikethrough" size="16" @click="BatchSetFormat('underline')" />
+        </div>
 
         <div class="option-list">
           <div v-for="(item, i) in data.option_list" :key="i" class="voice-matrix">
@@ -44,7 +49,7 @@
             :is-show-resource="false"
             @uploadSuccess="uploadLrcSuccess"
           />
-          <el-button size="small" type="primary">校对字幕</el-button>
+          <el-button size="small" type="primary" @click="openCheckSubtitles">校对字幕</el-button>
         </div>
 
         <div v-if="data.lrc_data.url.length > 0" class="upload-file">
@@ -57,6 +62,13 @@
           <SvgIcon icon-class="delete-black" size="12" @click="removeFile('lrc')" />
         </div>
       </div>
+
+      <CheckSubtitles
+        :visible.sync="visible"
+        :audio-id="data.audio_data.file_id"
+        :option-list="data.option_list"
+        @saveSubtitles="saveSubtitles"
+      />
     </template>
   </ModuleBase>
 </template>
@@ -64,6 +76,7 @@
 <script>
 import ModuleMixin from '../../common/ModuleMixin';
 import SelectUpload from '@/views/book/courseware/create/components/common/SelectUpload.vue';
+import CheckSubtitles from './CheckSubtitles.vue';
 
 import { getVoiceMatrixData, getOption } from '@/views/book/courseware/data/voiceMatrix';
 import { GetStaticResources } from '@/api/app';
@@ -72,11 +85,13 @@ export default {
   name: 'VoiceMatrix',
   components: {
     SelectUpload,
+    CheckSubtitles,
   },
   mixins: [ModuleMixin],
   data() {
     return {
       data: getVoiceMatrixData(),
+      visible: false,
     };
   },
   watch: {
@@ -192,6 +207,22 @@ export default {
         richText.setRichFormat(text);
       });
     },
+    /**
+     * 打开校对字幕
+     */
+    openCheckSubtitles() {
+      if (this.data.audio_data.url.length === 0) {
+        return this.$message.warning('请先上传音频文件');
+      }
+      this.visible = true;
+    },
+    /**
+     * 保存字幕
+     * @param {Array} dataList
+     */
+    saveSubtitles(dataList) {
+      this.data.option_list = dataList;
+    },
   },
 };
 </script>
@@ -202,6 +233,16 @@ export default {
   flex-direction: column;
   row-gap: 8px;
 
+  .batch-set {
+    display: flex;
+    column-gap: 8px;
+    align-items: center;
+
+    .svg-icon {
+      cursor: pointer;
+    }
+  }
+
   .option-list {
     margin-bottom: 12px;
 

+ 26 - 4
src/views/personal_workbench/project/ProductionEditorialManage.vue

@@ -31,7 +31,17 @@
           <span class="title-cell">操作</span>
         </div>
         <div
-          v-for="{ id, name, deep, producer_list, is_leaf_chapter, is_root, auditor_desc } in node_list"
+          v-for="{
+            id,
+            name,
+            deep,
+            producer_list,
+            is_leaf_chapter,
+            is_root,
+            is_inherited_producer,
+            auditor_desc,
+            status_name,
+          } in node_list"
           :key="id"
           :class="['catalogue', { active: curSelectId === id }]"
           @click="selectActiveChapter(id, is_leaf_chapter === 'true')"
@@ -43,11 +53,21 @@
           >
             {{ name }}
           </div>
-          <div class="producer nowrap-ellipsis" :title="producer_list.map((producer) => producer.name).join(';')">
+          <div
+            class="producer nowrap-ellipsis"
+            :style="{ color: isEnable(is_inherited_producer) ? '#ff4757' : 'default' }"
+            :title="producer_list.map((producer) => producer.name).join(';')"
+          >
             <span>{{ producer_list.map((producer) => producer.name).join(';') }}</span>
           </div>
-          <div class="audit nowrap-ellipsis" :title="auditor_desc">{{ auditor_desc }}</div>
-          <div class="status"></div>
+          <div
+            class="audit nowrap-ellipsis"
+            :style="{ color: isEnable(is_inherited_producer) ? '#ff4757' : 'default' }"
+            :title="auditor_desc"
+          >
+            {{ auditor_desc }}
+          </div>
+          <div class="status">{{ status_name }}</div>
           <div class="operator">
             <span
               v-if="is_root !== 'true'"
@@ -121,6 +141,7 @@ import {
   ChapterUpdateChapter,
   ChapterUpdateCoursewareName,
 } from '@/api/book';
+import { isEnable } from '@/views/book/courseware/data/common';
 
 export default {
   name: 'ProductionEditorialManage',
@@ -135,6 +156,7 @@ export default {
   data() {
     return {
       book_id: this.$route.params.id,
+      isEnable,
       visible: false,
       curSelectId: '', // 当前选中的章节ID
       addType: 'chapter', // 添加类型