Ver Fonte

feat:新增情感标签以及音频切块播放

hanyang há 1 ano atrás
pai
commit
4c73508c4d

+ 74 - 2
src/modules/Home/AudioPlayer.vue

@@ -1,13 +1,85 @@
 <template>
-  <audio style="width: 500px; height: 32px" ref="audioPlayer" :src="audioSrc" controls></audio>
+  <audio ref="audioPlayer" style="width: 420px; height: 32px" :src="audioSrc" controls @play="onPlay"></audio>
 </template>
 
 <script setup>
+import { ref, onMounted, onUnmounted, watch } from 'vue';
+
 const props = defineProps({
   audioSrc: {
     type: String,
     default: ''
+  },
+  // 单位秒
+  startTime: {
+    type: [Number, String],
+    default: 0
+  },
+  // 单位秒
+  endTime: {
+    type: [Number, String],
+    default: 0
+  },
+  content: {
+    type: String,
+    default: ''
+  }
+});
+
+const emit = defineEmits(['playing-content']);
+
+const audioPlayer = ref(null);
+let isPausedAtEndTime = false; // 用于标记是否在endTime暂停
+
+// 判断传入的startTime和endTime是否合法
+const isValidTime = (time) => {
+  return time !== '' && time !== undefined && !isNaN(time) && time >= 0 && props.endTime != 0;
+};
+
+const onPlay = () => {
+  // 播放时上抛一个事件给详情信息中传递当前播放文件的内容
+  emit('playing-content', props.content);
+  // 如果是暂停后从startTime继续播放
+  if (isPausedAtEndTime) {
+    audioPlayer.value.currentTime = props.startTime;
+    isPausedAtEndTime = false;
   }
+
+  // 如果传入的start和end为有效值,则进行时长控制
+  if (isValidTime(props.startTime) && isValidTime(props.endTime)) {
+    // 设置音频的播放时间从startTime开始
+    audioPlayer.value.currentTime = props.startTime;
+    audioPlayer.value.addEventListener('timeupdate', handleTimeUpdate);
+  } else {
+    // 否则播放完整音频
+    audioPlayer.value.addEventListener('timeupdate', handleFullPlayback);
+  }
+};
+
+const handleTimeUpdate = () => {
+  if (audioPlayer.value.currentTime >= props.endTime) {
+    audioPlayer.value.pause();
+    isPausedAtEndTime = true;
+  }
+};
+
+const handleFullPlayback = () => {
+  // 如果播放到音频结束,则移除事件监听
+  if (audioPlayer.value.currentTime >= audioPlayer.value.duration) {
+    audioPlayer.value.removeEventListener('timeupdate', handleFullPlayback);
+  }
+};
+
+onMounted(() => {
+  // 如果传入的startTime合法,则从startTime开始播放
+  if (isValidTime(props.startTime)) {
+    audioPlayer.value.currentTime = props.startTime;
+  }
+});
+
+// 清理事件监听
+onUnmounted(() => {
+  audioPlayer.value?.removeEventListener('timeupdate', handleTimeUpdate);
+  audioPlayer.value?.removeEventListener('timeupdate', handleFullPlayback);
 });
 </script>
-<style scoped lang="css"></style>

+ 6 - 1
src/modules/Home/ItemDetail.vue

@@ -3,18 +3,23 @@
     <template #title>
       <div class="title">详情信息</div>
     </template>
-    <div class="content">{{ detail_content }}</div>
+    <div class="content" v-html="format_content"></div>
     <a-empty v-if="!detail_content.length"></a-empty>
   </a-card>
 </template>
 
 <script setup>
+import { computed } from 'vue';
+import { formatContent } from '../../utils/format-content';
 const props = defineProps({
   detail_content: {
     type: String,
     default: ''
   }
 });
+const format_content = computed(() => {
+  return formatContent(props.detail_content);
+});
 </script>
 <style scoped>
 .arco-card {

+ 191 - 15
src/modules/Home/ItemList.vue

@@ -1,49 +1,164 @@
 <template>
-  <div class="item-list">
+  <div class="item-list" ref="container">
     <a-table
       :data="audio_list"
       :pagination="{ pageSize: PAGE_SIZE }"
       stripe
       :scroll="scroll"
-      :scrollbar="scrollbar"
-      @change="handleTableChange"
+      :expandable="expandable"
+      @change="handleTableChange_Father"
     >
+      <template #expand-icon="{ expanded, record }">
+        <icon-up v-if="expanded" />
+        <icon-down v-else />
+      </template>
+      <template #expand-row="{ record }">
+        <a-table
+          :data="record.expandRow"
+          :show-header="false"
+          :expandable="expandable"
+          stripe
+          :pagination="{ pageSize: 5 }"
+          @change="handleTableChange_Son"
+        >
+          <template #expand-icon="{ expanded, record }"> </template>
+          <template #columns>
+            <a-table-column title="音频列表" data-index="name" width="180">
+              <template #cell="{ record }">
+                <a-tooltip :content="record.name">
+                  <div class="tip-content tip-name">{{ record.name }}</div>
+                </a-tooltip>
+              </template>
+            </a-table-column>
+            <a-table-column title="开始时间" data-index="start_time">
+              <template #cell="{ record }">
+                {{ formatSeconds(record.start) || '-' }}
+              </template>
+            </a-table-column>
+            <a-table-column title="结束时间" data-index="end_time">
+              <template #cell="{ record }">
+                {{ formatSeconds(record.end) || '-' }}
+              </template>
+            </a-table-column>
+            <a-table-column title="情感标签" data-index="emotion">
+              <template #cell="{ record }">
+                <!-- {{ record.emotion }} -->
+                <!-- <div class="emotion-tags"> -->
+                <a-space>
+                  <!-- <a-tag v-for="(tag, index) of emotions.slice(0, 2)" :key="index" :color="'#168cff'">{{ tag }}</a-tag>
+                  <a-tag v-if="emotions.length > 2" :color="'#168cff'" @click="handleToDetail" style="cursor: pointer"
+                    >...</a-tag
+                  > -->
+                </a-space>
+                <!-- </div> -->
+              </template>
+            </a-table-column>
+            <a-table-column title="路径" data-index="file_path" width="220">
+              <template #cell="{ record }">
+                <a-tooltip :content="record.file_path">
+                  <div class="tip-content">{{ record.file_path }}</div>
+                </a-tooltip>
+              </template>
+            </a-table-column>
+            <a-table-column title="操作" data-index="operation" width="450">
+              <template #cell="{ record }">
+                <AudioPlayer
+                  v-if="!record.loading_path"
+                  :audioSrc="record.audio_src"
+                  :startTime="record.start"
+                  :endTime="record.end"
+                  :content="record.content"
+                  @playingContent="playingContent"
+                ></AudioPlayer>
+                <div v-else class="loading-tips">音频解析中 <a-spin> </a-spin></div>
+              </template>
+            </a-table-column>
+          </template>
+        </a-table>
+      </template>
       <template #columns>
-        <a-table-column title="音频列表" data-index="name" width="320">
+        <a-table-column title="音频列表" data-index="name" width="150">
           <template #cell="{ record }">
             <a-tooltip :content="record.name">
               <div class="tip-content tip-name">{{ record.name }}</div>
             </a-tooltip>
           </template>
         </a-table-column>
-        <a-table-column title="路径" data-index="file_path" width="300">
+        <a-table-column title="开始时间" data-index="start_time">
+          <template #cell="{ record }">
+            {{ formatSeconds(record.start) || '-' }}
+          </template>
+        </a-table-column>
+        <a-table-column title="结束时间" data-index="end_time">
+          <template #cell="{ record }">
+            {{ formatSeconds(record.end) || '-' }}
+          </template>
+        </a-table-column>
+        <a-table-column title="情感标签" data-index="emotion">
+          <template #cell="{ record }">
+            <!-- {{ record.emotion }} -->
+            <!-- <div class="emotion-tags"> -->
+            <a-space>
+              <a-tag v-for="(tag, index) of emotions.slice(0, 1)" :key="index" :color="'#168cff'">{{
+                tag.emotion
+              }}</a-tag>
+              <a-tag v-if="emotions.length > 2" :color="'#168cff'" @click="handleToDetail" style="cursor: pointer"
+                >...</a-tag
+              >
+            </a-space>
+            <!-- </div> -->
+          </template>
+        </a-table-column>
+        <a-table-column title="路径" data-index="file_path" width="220">
           <template #cell="{ record }">
             <a-tooltip :content="record.file_path">
               <div class="tip-content">{{ record.file_path }}</div>
             </a-tooltip>
           </template>
         </a-table-column>
-        <a-table-column title="操作" data-index="operation" width="550">
+        <a-table-column title="操作" data-index="operation" width="450">
           <template #cell="{ record }">
-            <AudioPlayer v-if="!record.loading_path" :audioSrc="record.audio_src"></AudioPlayer>
+            <AudioPlayer
+              v-if="!record.loading_path"
+              :audioSrc="record.audio_src"
+              :startTime="record.start"
+              :endTime="record.end"
+              :content="record.content"
+              @playingContent="playingContent"
+            ></AudioPlayer>
             <div v-else class="loading-tips">音频解析中 <a-spin> </a-spin></div>
           </template>
         </a-table-column>
       </template>
     </a-table>
   </div>
+  <TagsDetail
+    :visible.sync="show_tags_detail"
+    :detail_audio_emotion_list="detail_audio_emotion_list"
+    @cancel="handleDetailCancel"
+  ></TagsDetail>
 </template>
 
 <script setup>
 import { ref, computed, onMounted } from 'vue';
 import AudioPlayer from './AudioPlayer.vue';
+import TagsDetail from './TagsDetail.vue';
 import { downloadWar } from '../../apis/home';
+import { formatSeconds } from '../../utils/format-content';
 
 const props = defineProps({
   detail_audio_list: {
     type: Array,
     default: () => []
   },
+  detail_audio_split_list: {
+    type: Array,
+    default: () => []
+  },
+  detail_audio_emotion_list: {
+    type: Array,
+    default: () => []
+  },
   file_id: {
     type: String,
     default: ''
@@ -53,14 +168,43 @@ const props = defineProps({
     default: 7
   }
 });
+const emit = defineEmits(['playing-content']);
 
+const container = ref(null);
 const scroll = { x: '100%', y: '' };
-const scrollbar = ref(true);
+const expandable = {
+  title: '',
+  width: 80
+};
+const show_tags_detail = ref(false);
 
 const audio_list = computed(() => {
-  return props.detail_audio_list;
+  const table_data = props.detail_audio_list;
+  /**
+   * 这里所有的音频共用同一个detail_audio_split_list
+   *
+   * */
+  if (props.detail_audio_split_list.length) {
+    table_data.forEach((item) => {
+      const expand = props.detail_audio_split_list.map((it) => {
+        return {
+          ...it,
+          audio_src: item.audio_src,
+          name: item.name,
+          file_path: item.file_path,
+          loading_path: item.loading_path
+        };
+      });
+      item.expandRow = [...expand];
+    });
+  }
+  return table_data;
 });
-const handleTableChange = (current_page_data, filters) => {
+const emotions = computed(() => {
+  return props.detail_audio_emotion_list;
+});
+
+const handleTableChange_Father = (current_page_data, filters) => {
   current_page_data.forEach(async (item) => {
     if (item.audio_src === undefined) {
       item.loading_path = true;
@@ -69,6 +213,16 @@ const handleTableChange = (current_page_data, filters) => {
     }
   });
 };
+const handleTableChange_Son = (current_page_data, filters) => {
+  current_page_data.forEach((item) => {
+    item.loading_path = true;
+  });
+  setTimeout(() => {
+    current_page_data.forEach((item) => {
+      item.loading_path = false;
+    });
+  }, 0);
+};
 const toDownloadWar = async (file_id, audio_id) => {
   try {
     const res = await downloadWar(file_id, audio_id);
@@ -78,7 +232,20 @@ const toDownloadWar = async (file_id, audio_id) => {
     console.log(error);
   }
 };
-onMounted(() => {});
+
+const handleToDetail = () => {
+  show_tags_detail.value = true;
+};
+const handleDetailCancel = () => {
+  show_tags_detail.value = false;
+};
+
+const playingContent = (content) => {
+  emit('playing-content', content);
+};
+onMounted(() => {
+  scroll.y = container.value.offsetHeight - 100;
+});
 </script>
 <style scoped>
 .item-list {
@@ -86,19 +253,28 @@ onMounted(() => {});
   background-color: #fff;
 }
 .tip-name {
-  width: 320px;
+  width: 180px;
 }
 .tip-content {
-  width: 300px;
+  width: 180px;
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
 }
+.emotion-tags {
+  display: -webkit-box;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  line-height: 1.5;
+  max-height: 3em;
+}
 .loading-tips {
   display: flex;
   align-items: center;
   gap: 10px;
-  width: 500px;
+  width: 450px;
   color: #3d68e1;
 }
 
@@ -111,7 +287,7 @@ onMounted(() => {});
 :deep(.arco-table-tr .arco-table-th:first-child) {
   border-radius: 10px 0 0 0;
 }
-:deep(.arco-table-tr .arco-table-th:nth-child(3)) {
+:deep(.arco-table-tr .arco-table-th:last-child) {
   border-radius: 0 10px 0 0;
 }
 </style>

+ 23 - 4
src/modules/Home/MessageBody.vue

@@ -13,7 +13,14 @@
     </div>
     <div class="right-detail">
       <ItemDetail :detail_content="detail_content" />
-      <ItemList :detail_audio_list="detail_audio_list" :file_id="detail_file_id" :PAGE_SIZE="PAGE_SIZE" />
+      <ItemList
+        :detail_audio_list="detail_audio_list"
+        :file_id="detail_file_id"
+        :detail_audio_split_list="detail_audio_split_list"
+        :detail_audio_emotion_list="detail_audio_emotion_list"
+        :PAGE_SIZE="PAGE_SIZE"
+        @playingContent="playingContent"
+      />
     </div>
   </div>
 </template>
@@ -37,7 +44,9 @@ const search_total = ref(0);
 const search_set_loading = ref(false);
 
 const detail_content = ref('');
-const detail_audio_list = ref([]);
+const detail_audio_list = ref([]); // 音频列表
+const detail_audio_split_list = ref([]); // 切割后的音频列表
+const detail_audio_emotion_list = ref([]);
 const detail_file_id = ref('');
 const PAGE_SIZE = 7;
 
@@ -65,12 +74,18 @@ const loadMore = async () => {
 
 const checkDetail = async (item) => {
   try {
+    detail_audio_list.value = [];
+    detail_audio_split_list.value = [];
+    detail_audio_emotion_list.value = [];
     const res = await getAudioDetail(item.id);
     if (res.code == 200) {
       detail_content.value = res.data.content;
-      detail_audio_list.value = res.data.audioPath;
+      detail_audio_list.value = res.data.audioPath || [];
+      detail_audio_split_list.value = res.data.contentList || [];
+      detail_audio_emotion_list.value = res.data.emotionList || [];
       detail_audio_list.value.forEach((item) => {
         item.loading_path = true;
+        item.content = res.data.content;
       });
       detail_file_id.value = res.data.id;
       const first_list = detail_audio_list.value.slice(0, PAGE_SIZE);
@@ -97,6 +112,10 @@ const toDownloadWar = async (file_id, audio_id) => {
   }
 };
 
+const playingContent = (content) => {
+  detail_content.value = content;
+};
+
 bus_search.on((keyword) => {
   current_keyword.value = keyword;
   search_list.value = [];
@@ -110,7 +129,7 @@ bus_search.on((keyword) => {
 <style scoped lang="css">
 .main-container {
   display: grid;
-  grid-template-columns: 1fr 2fr;
+  grid-template-columns: 1fr 3fr;
   gap: 10px;
   min-height: calc(100vh - 150px);
   background-color: #fff;

+ 12 - 1
src/modules/Home/SearchList.vue

@@ -25,7 +25,7 @@
         :key="item.id"
         @click="checkDetail(item)"
       >
-        <div v-html="item.content"></div>
+        <div v-html="item.content" class="clamped-text"></div>
       </a-list-item>
     </a-list>
     <a-spin class="loading" v-if="is_loading" />
@@ -111,6 +111,17 @@ onMounted(() => {
 .title {
   color: #fff;
 }
+
+.clamped-text {
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  line-height: 1.5;
+  max-height: 3em;
+}
+
 :deep(.search-result-high) {
   color: #f00;
 }

+ 67 - 0
src/modules/Home/TagsDetail.vue

@@ -0,0 +1,67 @@
+<template>
+  <a-modal v-model:visible="visible" :footer="false" @cancel="handleCancel">
+    <template #title>情感标签详情</template>
+    <div>
+      <a-table :columns="columns" :data="detail_data" :pagination="pagination" />
+    </div>
+  </a-modal>
+</template>
+
+<script setup>
+import { toRefs, reactive, computed } from 'vue';
+import { formatSeconds } from '../../utils/format-content';
+const props = defineProps({
+  visible: {
+    type: Boolean,
+    default: false
+  },
+  detail_audio_emotion_list: {
+    type: Array,
+    default: () => []
+  }
+});
+const emit = defineEmits(['cancel']);
+
+const { visible } = toRefs(props);
+
+const columns = [
+  {
+    title: '开始时间 — 结束时间',
+    dataIndex: 'times',
+    key: 'times'
+  },
+  {
+    title: '情感标签',
+    dataIndex: 'tags',
+    key: 'tags'
+  }
+];
+const pagination = { pageSize: 5 };
+
+const detail_data = computed(() => {
+  return props.detail_audio_emotion_list.map((item) => {
+    return {
+      times: `${formatSeconds(item.start)} — ${formatSeconds(item.end)}`,
+      tags: item.emotion
+    };
+  });
+});
+
+const handleCancel = () => {
+  emit('cancel');
+};
+</script>
+<style scoped lang="css">
+:deep(.arco-table-tr .arco-table-th) {
+  height: 46px;
+  background: #7786bc;
+  color: #fff;
+  font-size: 16px;
+}
+:deep(.arco-table-tr .arco-table-th:first-child) {
+  border-radius: 10px 0 0 0;
+}
+:deep(.arco-table-tr .arco-table-th:last-child) {
+  border-radius: 0 10px 0 0;
+}
+</style>

+ 0 - 1
src/modules/Home/TopSetting.vue

@@ -17,7 +17,6 @@ import { bus_search } from '../../base/event-bus';
 
 const keyword = ref('');
 const toSearchData = () => {
-  console.log(keyword.value);
   if (keyword.value.trim().length) {
     bus_search.emit(keyword.value);
   }

+ 21 - 0
src/utils/format-content.js

@@ -0,0 +1,21 @@
+export const formatContent = (content) => {
+  return content.replace(/\n/g, '<br/>');
+};
+export const formatSeconds = (seconds) => {
+  if (!seconds) return undefined;
+  // 获取小时数
+  const hours = Math.floor(seconds / 3600);
+
+  // 获取分钟数
+  const minutes = Math.floor((seconds % 3600) / 60);
+
+  // 获取秒数
+  const remainingSeconds = Math.floor(seconds % 60);
+
+  // 使用 padStart 确保每个部分是两位数
+  const formattedHours = String(hours).padStart(2, '0');
+  const formattedMinutes = String(minutes).padStart(2, '0');
+  const formattedSeconds = String(remainingSeconds).padStart(2, '0');
+
+  return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
+};