Преглед изворни кода

Merge branch 'wanzi'

# Conflicts:
#	src/views/AIPainting.vue
liyanbo пре 7 месеци
родитељ
комит
a499306ceb

+ 1 - 1
src/components/videopage/DialogComponents.vue

@@ -553,7 +553,7 @@ const doSendMessageStream = async userMessage => {
           await playAudioChunk(data.audioData);
         }
 
-        // 添加此行确保触发滚动
+        // 确保触发滚动
         scrollToBottom()
       },
       error => {

+ 133 - 19
src/components/videopage/VideoPlayer.vue

@@ -3,14 +3,29 @@
     <div class="box-video">
        <!-- 视频 -->
       <template v-if="contentType === 'video'">
-        <video
-          class="full-box-video"
-          ref="videoRef"
-          :controls="true"
-          @timeupdate="handleTimeUpdate"
-          @seeked="handleSeeked"
-          @ended="handleVideoEnded"
-        ></video>
+        <div class="video-wrapper">
+          <video
+            class="full-box-video"
+            ref="videoRef"
+            :controls="true"
+            @timeupdate="handleTimeUpdate"
+            @seeked="handleSeeked"
+            @ended="handleVideoEnded"
+            @loadedmetadata="handleLoadedMetadata"
+          ></video>
+          <!-- 自定义进度条上的章节要点小圆点 
+              :title="marker.title || `章节要点 (${marker.time}s)`" 
+          -->
+          <div class="progress-markers" v-if="chapterMarkers.length > 0">
+            <div 
+              v-for="marker in chapterMarkers" 
+              :key="marker.time"
+              class="progress-marker"
+              :style="{ left: marker.position + '%' }"
+              @click="seekToTime(marker.time)"
+            ></div>
+          </div>
+        </div>
       </template>
     </div>
   </div>
@@ -33,8 +48,6 @@ import { ElMessage } from 'element-plus'
 // 导入全局年级id
 import { globalState } from '@/utils/globalState.js'
 // 导入图标
-import leftImg from '@/assets/icon/backward.png'
-import rightImg from '@/assets/icon/f-backward.png'
 import { saveRecord } from '@/api/personalized/index.js'
 
 
@@ -66,6 +79,8 @@ const THROTTLE_TIME = 3000
 const lastPlayProgress = ref(0)
 // 定义进度数组
 const targetProgresses = [10, 50, 100]
+// 章节要点标记
+const chapterMarkers = ref([])
 
 // 定义节流函数
 const throttle = (fn, delay) => {
@@ -105,11 +120,37 @@ const saveProgress = throttle(async (progress, currentTime) => {
   }
 }, THROTTLE_TIME)
 
+// 处理视频元数据加载完成
+const handleLoadedMetadata = () => {
+  if (!videoRef.value || !props.courseConfigList.length) return
+  const duration = videoRef.value.duration
+  if (duration) {
+    // 根据courseConfigList生成章节标记
+    chapterMarkers.value = props.courseConfigList.map(config => ({
+      time: config.ccTime,
+      position: (config.ccTime / duration) * 100
+    }))
+  }
+}
+
+// 跳转到指定时间点
+const seekToTime = (time) => {
+  if (videoRef.value) {
+    videoRef.value.currentTime = time
+    // 清除已暂停索引,确保用户点击后可以再次触发暂停
+    pausedIndices.value = pausedIndices.value.filter(t => t !== time)
+  }
+}
 // 处理视频时间更新事件
 const handleTimeUpdate = ev => {
   if (!videoRef.value) return
   const currentTime = parseInt(ev.target.currentTime)
   const duration = videoRef.value.duration || 0
+  // 如果章节标记为空且有课程配置,尝试生成标记
+  if (chapterMarkers.value.length === 0 && props.courseConfigList.length && duration > 0) {
+    handleLoadedMetadata()
+  }
+
   const progressPercentage =
     duration > 0 ? Math.round((currentTime / duration) * 100) : 0
 
@@ -136,7 +177,7 @@ const handleTimeUpdate = ev => {
 
   if (!props.courseConfigList.length) return
   props.courseConfigList.forEach(courseCofig => {
-    //暂停时间
+    // 暂停时间
     let time = courseCofig.ccTime
     // 检查是否到达时间点且还未暂停过
     if (currentTime === time && !pausedIndices.value.includes(time)) {
@@ -219,7 +260,10 @@ const initVideoPlayer = () => {
         })
         hlsRef.value.on(Hls.Events.ERROR, (event, data) => {
           console.error('HLS错误:', data)
+          // 只对致命错误显示错误提示
+        if (data.fatal) {
           ElMessage.error('视频加载失败,请稍后重试')
+        }
         })
       } else if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
         // 对于不支持HLS但支持原生m3u8的浏览器
@@ -250,11 +294,32 @@ const tryPlayVideo = () => {
   }, 1000)
 }
 
-
-
+// 处理空格键控制播放/暂停
+const handleKeyPress = (event) => {
+  // 检查是否是空格键且视频元素存在
+  if (event.code === 'Space' && videoRef.value) {
+    event.preventDefault(); // 防止空格键默认行为
+    if (videoRef.value.paused) {
+      videoRef.value.play();
+    } else {
+      videoRef.value.pause();
+    }
+  }
+}
 // 组件挂载时
 onMounted(() => {
   initVideoPlayer()
+ // 键盘事件监听
+  document.addEventListener('keydown', handleKeyPress);
+})
+// 组件卸载时
+onBeforeUnmount(() => {
+  if (hlsRef.value) {
+    hlsRef.value.destroy()
+    hlsRef.value = null
+  }
+  // 移除键盘事件监听
+  document.removeEventListener('keydown', handleKeyPress);
 })
 
 // 监听contentType和videoPath变化
@@ -281,7 +346,6 @@ onBeforeUnmount(() => {
 @function rpx($px) {
   @return math.div($px, 750) * 100vw;
 }
-
 .box-video {
   width: 100%;
   height: rpx(300);
@@ -295,9 +359,17 @@ onBeforeUnmount(() => {
     object-fit: cover;
   }
 }
-.full-box-video {
+.video-wrapper {
+  position: relative;
   width: 70%;
   height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.full-box-video {
+  width: 100%;
+  height: 100%;
   object-fit: cover;
   border-radius: rpx(12);
 }
@@ -327,20 +399,17 @@ onBeforeUnmount(() => {
   border-radius: rpx(12);
   overflow: hidden; 
 }
-
 .ppt-container ::v-deep(.pptx-preview-wrapper) {
   // 滚动条整体样式
   &::-webkit-scrollbar {
     width: rpx(0); // 滚动条宽度
     height: rpx(0);
   }
-
   // 滚动条滑块样式
   &::-webkit-scrollbar-thumb {
     background-color: #e2ddfc; // 滑块颜色
     border-radius: rpx(4); // 滑块圆角
   }
-
   // 滚动条轨道样式
   &::-webkit-scrollbar-track {
     background-color: rgba(143, 116, 255, 0.2); // 轨道颜色
@@ -384,8 +453,53 @@ onBeforeUnmount(() => {
 }
 
 
-/* 隐藏 Chrome 视频控件的渐变背景等默认样式 */
+/* 章节要点标记样式 */
+.progress-markers {
+  position: absolute;
+  bottom: 26px; 
+  left: 15px; 
+  right: 15px;
+  height: 10px;
+  pointer-events: none;
+  z-index: 10;
+  opacity: 0; /* 默认隐藏 */
+  transition: opacity 0.3s ease; /* 过渡效果 */
+}
+.video-wrapper:hover .progress-markers {
+  opacity: 1; /* 鼠标悬停在视频上时显示 */
+}
+/* 当视频控件可见时显示标记 */
+video::-webkit-media-controls-panel:not([hidden]) ~ .progress-markers {
+  opacity: 1;
+}
+.progress-marker {position: absolute;
+  width: rpx(4);
+  height: rpx(4);
+  background-color: orange;
+  border-radius: 50%;
+  transform: translateX(-50%);
+  cursor: pointer;
+  transition: all 0.3s ease;
+  pointer-events: all;
+}
+
+.progress-marker:hover {
+  width: rpx(6);
+  height: rpx(6);
+}
+
+/* 调整视频控件样式以适应我们的自定义标记 */
+video::-webkit-media-controls-timeline {
+  margin-bottom: 10px;
+}
+
+video::-webkit-media-controls {
+  overflow: visible !important;
+}
+
 video::-webkit-media-controls-panel {
+  width: calc(100% + 30px); /* 扩展控件面板宽度,确保与自定义标记对齐 */
   background: transparent !important; /* 去掉背景渐变,设为透明 */
 }
+
 </style>

+ 6 - 6
src/views/AIDevelop.vue

@@ -51,7 +51,7 @@
                   <el-menu-item-group v-if="item.children">
                     <template v-for="child in item.children" :key="child.key">
                       <el-menu-item :index="child.key"
-                        >• {{ child.title }}</el-menu-item
+                        >•{{ child.title }}</el-menu-item
                       >
                     </template>
                   </el-menu-item-group>
@@ -170,7 +170,7 @@ import { saveRecord } from '@/api/personalized/index.js'
 // 导入全局状态
 import { globalState } from '@/utils/globalState.js'
 
-// 导入图标 - 新增
+// 导入图标
 import leftImg from '@/assets/icon/backward.png'
 import rightImg from '@/assets/icon/f-backward.png'
 
@@ -463,10 +463,10 @@ onMounted(async () => {
 
         // 手动修改第一个课程为image类型用于测试
         // if (index === 0) {
-        //   courseTemp.courseContentType = 'ppt';
-        //   courseTemp.pptPath = 'http://59.110.91.129:8088/admin-api/infra/file/29/get/20250820/ppt_1755654972861.pptx';
-        //   // courseTemp.courseContentType = 'image';
-        //   // courseTemp.courseImagePath = 'http://59.110.91.129:8088/admin-api/infra/file/4/get/20250715/one_1752549934393.png,http://59.110.91.129:8088/admin-api/infra/file/29/get/20250722/666_1753151547130.png';
+        //   // courseTemp.courseContentType = 'ppt';
+        //   // courseTemp.pptPath = 'http://59.110.91.129:8088/admin-api/infra/file/29/get/20250820/ppt_1755654972861.pptx';
+        //   courseTemp.courseContentType = 'image';
+        //   courseTemp.courseImagePath = 'http://59.110.91.129:8088/admin-api/infra/file/4/get/20250715/one_1752549934393.png,http://59.110.91.129:8088/admin-api/infra/file/29/get/20250722/666_1753151547130.png';
         //   // 可选:修改课程名称以便识别
         //   courseTemp.courseName = '测试';
         // }

+ 11 - 17
src/views/AIGeneralCourse.vue

@@ -104,14 +104,11 @@
                 <el-icon class="el-input__icon"><search /></el-icon>
               </template>
               <!-- 下拉项模板 -->
-              <template #popper-append-to-body>
-                <el-option
-                class="scrollbar"
-                  v-for="item in filteredTitles"
-                  :key="item.id"
-                  :label="item.ctType"
-                  :value="item"
-                ></el-option>
+              <template #default="{ item }">
+                <div class="scrollbar">
+                  <!-- 序号和标题 -->
+                  {{ item.ctTypeSort }} {{ item.ctType }}
+                </div>
               </template>
             </el-autocomplete>
           </div>
@@ -204,12 +201,6 @@ const fetchClassOutline = async (classId) => {
 
 // 实操课
 const handleNewButtonClick = async() => {
-   // 检查是否有实操课数据
-  if (!showPracticalCourse.value && ClassOutlineScData.value.length === 0) {
-    // 实操课没有数据的时候显示提示
-    Message().notifyWarning('目前暂未开放此课程', true)
-    return
-  }
   // 切换状态
   showPracticalCourse.value = !showPracticalCourse.value
   // 保存状态到localStorage
@@ -289,7 +280,7 @@ const getCourseTitle = index => {
 const courseTitles = computed(() => {
   const data = showPracticalCourse.value ? ClassOutlineScData.value : classOutlineData.value
   return data.map(item => {
-    return `${item.ctTypeSort}${item.ctType}`;
+    return `${item.ctTypeSort} ${item.ctType}`;
   });
 })
 
@@ -317,7 +308,9 @@ const querySearch = (queryString, cb) => {
   const data = showPracticalCourse.value ? ClassOutlineScData.value : classOutlineData.value
   const results = queryString
     ? data.filter(item => {
-        return item.ctType.toLowerCase().includes(queryString.toLowerCase())
+        // 课程标题和序号查询
+        return item.ctType.toLowerCase().includes(queryString.toLowerCase()) ||
+               item.ctTypeSort.includes(queryString)
       })
     : data
   cb(results)
@@ -335,7 +328,8 @@ const filteredTitles = computed(() => {
     return data
   }
   return data.filter(title =>
-    title.ctType.toLowerCase().includes(SearchInput.value.toLowerCase())
+    title.ctType.toLowerCase().includes(SearchInput.value.toLowerCase()) ||
+    title.ctTypeSort.includes(SearchInput.value)
   )
 })
 

+ 206 - 67
src/views/AIPainting.vue

@@ -1,7 +1,6 @@
 <template>
   <!-- 智能绘画 -->
   <div class="home-container">
-
      <!-- 展开收起侧边栏 -->
     <div
       class="icon-expand"
@@ -146,7 +145,28 @@
               placeholder="描述任何画面..."
               @keyup.enter="sendMessage"
             />
-            <button @click="sendMessage">发送</button>
+            <!-- 语音输入按钮 -->
+            <button
+                @click="toggleSpeechInput"
+                class="speech-btn"
+                :class="{ 'recording': isRecording }"
+            >
+              <el-icon v-if="!isRecording"><Microphone /></el-icon>
+              <el-icon v-else><Mute /></el-icon>
+              <!-- 显示倒计时(仅录音时显示) -->
+              <span v-if="isRecording" class="countdown-text">{{ countdown }}s</span>
+            </button>
+            <!-- 终止按钮 -->
+            <div
+              v-if="conversationInProgress"
+              @click="stopStream"
+              class="stop-btn"
+              title="终止问答"
+            >
+              <img :src="stopicon" alt="停止" />
+            </div>
+            <button v-if="!conversationInProgress"
+              @click="sendMessage">发送</button>
           </div>
         </div>
       </div>
@@ -182,6 +202,22 @@ import { saveRecord } from '@/api/personalized/index.js'
 
 // 导入全局状态
 import { globalState } from '@/utils/globalState.js'
+// 语音图标
+import { Microphone, Mute } from "@element-plus/icons-vue";
+// 终止按钮
+import stopicon from "@/assets/icon/stopicon.png";
+// 消息组件
+import {Message} from "@/utils/message/Message.js";
+
+// 语音输入响应式变量
+const isRecording = ref(false); // 录音状态
+const recognition = ref(null); // 语音识别实例
+const countdown = ref(0); // 倒计时剩余秒数
+const countdownTimer = ref(null); // 倒计时定
+// 对话状态变量
+const conversationInProgress = ref(false); // 对话是否正在进行中
+const conversationInAbortController = ref(); // 对话进行中 abort 控制器
+
 
 // 返回上一页
 const goBack = () => {
@@ -198,6 +234,11 @@ import human from '@/assets/icon/human.png'
 import LeftPanel from '@/components/LeftPanel.vue'
 const leftPanelRef = ref(null)
 
+
+// tts 语音
+import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
+const { playAudioChunk } = useAudioPlayer();
+
 // 添加抽屉显示状态
 const drawerVisible = ref(true)
 // 添加切换抽屉显示状态的函数
@@ -205,45 +246,6 @@ const toggleDrawer = () => {
   drawerVisible.value = !drawerVisible.value
 }
 
-// 跳转智能问答
-const navigateToAI = (group) => {
-  if (group.title === "智能问答") {
-    let person = { id: 10, name: '小智', image: NumberPeople00, message: '您好,我是您的AI智能助手小智,我会尽力回答您的问题或提供有用的建议!!!!'  };
-    router.push({
-      // 跳转问答页面
-      path: '/ai-questions',
-      query: {  id: person.id, name: person.name, image: person.image, message: person.message }
-    });
-  }
-  if (group.title === "智能绘画") {
-    router.push('/ai-painting')
-  }
-  if (group.title === '数字人老师') {
-    router.push('/ai-laboratory') // 添加跳转到AI实验室的逻辑
-  }
-}
-
-// 渲染侧边栏
-const groupList = ref([
-  {
-    icon: question,
-    title: '智能问答'
-  },
-  {
-    icon: painting,
-    title: '智能绘画'
-  },
-  {
-    icon: human,
-    title: '数字人老师'
-  }
-])
-
-// 处理菜单展开和关闭事件
-const handleOpen = () => {}
-const handleClose = () => {}
-
-
 const demoImageList = [demo1, demo2, demo3, demo4]
 
   // 年级ID相关
@@ -267,11 +269,106 @@ onMounted(async () => {
 
 // 消息列表和输入内容的响应式变量
 const messages = ref([])
-
 const inputMessage = ref('')
+
+// 初始化语音识别
+const initSpeechRecognition = () => {
+  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+  if (!SpeechRecognition) {
+    alert("当前浏览器不支持语音输入功能");
+    return null;
+  }
+
+  const instance = new SpeechRecognition();
+  instance.lang = 'zh-CN';
+  instance.interimResults = false;
+
+  instance.onresult = (event) => {
+    if (event.results?.[0]?.[0]) {
+      inputMessage.value += event.results[0][0].transcript;
+    }
+  };
+
+  // 识别器结束时清除定时器
+  instance.onend = () => {
+    clearInterval(countdownTimer.value);
+    isRecording.value = false;
+    countdown.value = 0;
+  };
+
+  instance.onerror = (event) => {
+    console.error('语音识别错误:', event.error);
+    clearInterval(countdownTimer.value); // 出错时清除定时器
+    isRecording.value = false;
+    Message().error('语音输入失败,请重试!', true)
+    countdown.value = 0;
+  };
+
+  return instance;
+};
+
+// 切换录音状态
+const toggleSpeechInput = () => {
+  // 清除可能存在的旧定时器
+  clearInterval(countdownTimer.value);
+  countdownTimer.value = null;
+
+  if (isRecording.value) {
+    // 手动停止时重置状态
+    countdown.value = 0;
+    recognition.value?.stop();
+    isRecording.value = false;
+  } else {
+    // 初始化倒计时前再次清除定时器(防止快速点击)
+    clearInterval(countdownTimer.value);
+    countdown.value = 10; // 重置为10秒
+
+    recognition.value = initSpeechRecognition();
+    if (!recognition.value) return;
+
+    navigator.mediaDevices.getUserMedia({ audio: true })
+      .then(() => {
+        recognition.value.start();
+        isRecording.value = true;
+
+        // 启动新的倒计时定时器
+        countdownTimer.value = setInterval(() => {
+          countdown.value--;
+          if (countdown.value <= 0) {
+            clearInterval(countdownTimer.value); // 倒计时结束清除
+            recognition.value.stop();
+            isRecording.value = false;
+            countdown.value = 0;
+          }
+        }, 1000);
+      })
+      .catch((err) => {
+        console.error("麦克风权限获取失败:", err);
+        alert("请允许麦克风权限以使用语音输入");
+        // 出错时重置状态
+        isRecording.value = false;
+        countdown.value = 0;
+      });
+  }
+};
+
+// 停止操作函数
+const stopStream = async () => {
+  // tip:如果 stream 进行中的 message,就需要调用 controller 结束
+  if (conversationInAbortController.value) {
+    conversationInAbortController.value.abort();
+  }
+  // 设置为 false
+  conversationInProgress.value = false;
+};
+
 // 发送消息函数
 const sendMessage = async() => {
   if (inputMessage.value.trim()) {
+    // 创建 AbortController 实例,以便中止请求
+    conversationInAbortController.value = new AbortController();
+    // 标记对话进行中
+    conversationInProgress.value = true;
     // messages.value.push(inputMessage.value.trim())
     // 先保存内容 再置空输入框
     let content = inputMessage.value;
@@ -286,7 +383,7 @@ const sendMessage = async() => {
     })
 
     // 递增消息计数器
-  messageCount.value++
+    messageCount.value++
     // 发送saveRecord请求 保存消息次数
      try{
        await saveRecord({
@@ -297,21 +394,30 @@ const sendMessage = async() => {
         console.log('保存记录成功,消息次数:', messageCount.value);
     }catch(error){
       console.error('保存记录失败:', error);
+      conversationInProgress.value = false;
     }
 
-    CreatePainting({
-      "modelId": 56,
-      "prompt":content,
-      "width":1024,
-      "height":1024
-    }).then(res=>{
-      console.log("生成图片",res)
-      //目前写死调用已生成的图片,全部通了后再改
-      inProgressImageMap.value[res.data] = {id:res.data,status:AiImageStatusEnum.IN_PROGRESS}
-      // inProgressImageMap.value[260] = {id:260,status:AiImageStatusEnum.IN_PROGRESS}
-    })
+    try {
+      CreatePainting({
+        "modelId": 56,
+        "prompt":content,
+        "width":1024,
+        "height":1024
+      }).then(res=>{
+        console.log("生成图片",res)
+        //目前写死调用已生成的图片,全部通了后再改
+        inProgressImageMap.value[res.data] = {id:res.data,status:AiImageStatusEnum.IN_PROGRESS}
+        // inProgressImageMap.value[260] = {id:260,status:AiImageStatusEnum.IN_PROGRESS}
+      }).finally(() => {
+        // 图片生成请求完成后更新状态
+        conversationInProgress.value = false;
+      });
+    } catch (error) {
+      console.error('生成图片失败:', error);
+      conversationInProgress.value = false;
+    }
   }
-}
+};
 // 生成图片
 import { ElIcon } from 'element-plus'
 import {
@@ -593,10 +699,53 @@ const inProgressTimerFun = () => {
   border-radius: rpx(5);
   text-align: left; // 文字左对齐
 }
+.image-list {
+  display: flex;
+  flex-wrap: wrap;
+}
+
+
+.content-demo {
+  background-color: #f4f2fa;
+  border-radius: 15px;
+  padding: 30px 10px;
+}
+
 .input-section {
   display: flex;
   padding: rpx(10);
-  gap: rpx(10);
+  gap: rpx(5);
+  .speech-btn {
+    padding: rpx(5) rpx(10);
+    background: #fff;
+    border: 1px solid #ffce1b;
+    border-radius: rpx(5);
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    &.recording {
+      background: #ffeeba;
+      border-color: #ffc107;
+
+      .el-icon {
+        color: #dc3545;
+      }
+    }
+    .el-icon {
+      font-size: rpx(8);
+      color: #666;
+    }
+  }
+  // 终止按钮样式
+  .stop-btn {
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    img {
+      width: rpx(20);
+      height: rpx(20);
+    }
+  }
 }
 .input-section input {
   flex: 1;
@@ -617,17 +766,7 @@ const inProgressTimerFun = () => {
   font-size: rpx(7);
   border-radius: rpx(5);
   cursor: pointer;
-  box-shadow: 0 4px 8px rgba(202, 52, 52, 0.3);
-}
-.image-list {
-  display: flex;
-  flex-wrap: wrap;
-}
+    box-shadow: 0 0px 2px rgba(0, 0, 0, 0.3);
 
-
-.content-demo {
-  background-color: #f4f2fa;
-  border-radius: 15px;
-  padding: 30px 10px;
 }
 </style>

+ 1 - 3
src/views/AIQuestions.vue

@@ -70,7 +70,7 @@
                 placeholder="问我任何问题..."
                 @keyup.enter="handleSendByKeydown"
               />
-              <!-- 添加语音输入按钮 -->
+              <!-- 语音输入按钮 -->
               <button
                   @click="toggleSpeechInput"
                   class="speech-btn"
@@ -523,8 +523,6 @@ const stopStream = async () => {
   }
   // 设置为 false
   conversationInProgress.value = false;
-
-  console.log(`结束对话!更改状态: `,conversationInProgress.value)
 };
 
 /**