Quellcode durchsuchen

提交视频组件部分

丸子 vor 8 Monaten
Ursprung
Commit
26a531cced

+ 897 - 0
src/components/videopage/DialogComponents.vue

@@ -0,0 +1,897 @@
+<template>
+  <div>
+    <!-- 试题弹框 -->
+    <transition name="fade-scale">
+      <div
+        v-show="questionDialogVisible"
+        class="child-dialog-wrapper"
+        @click.self="handleCloseQuestionDialog"
+      >
+        <div class="child-dialog">
+          <div class="question-title">
+            <span class="question-icon">?</span>
+            <span v-html="currentQuestion.ccQuestContent"></span>
+          </div>
+          <!-- 选项区域 -->
+          <div
+            v-if="currentQuestion.ccQuestOption && currentQuestion.ccQuestOption.length > 0"
+            class="options-container"
+          >
+            <div
+              v-for="(option, index) in currentQuestion.ccQuestOption.split(',')"
+              :key="index"
+              class="question-option"
+            >
+              <el-radio
+                v-model="selectedOption"
+                :label="option"
+                :value="option"
+              >
+                <span>{{ option }}</span>
+              </el-radio>
+            </div>
+          </div>
+          <div v-else class="no-options">
+            <!-- 暂无选项 -->
+          </div>
+          <!-- 底部按钮 -->
+          <div class="dialog-footer">
+            <el-button
+              class="child-button confirm"
+              @click="handleSubmitAnswer"
+              >确定</el-button
+            >
+          </div>
+          <!-- 右侧小图标 -->
+          <div
+            v-if="currentQuestion.ccAiAnswer !== null"
+            class="ai-icon-container"
+            @click="handleAIClick"
+          >
+            <img
+              src="@/assets/images/xiaozhi.png"
+              alt="AI对话"
+              class="ai-icon"
+            />
+            <span class="ai-text">小智智能助手</span>
+          </div>
+        </div>
+      </div>
+    </transition>
+
+    <!-- AI对话弹框 -->
+    <div
+      v-show="showAIDialog"
+      class="ai-dialog-wrapper"
+      @click.self="showAIDialog = false"
+    >
+      <div class="ai-dialog">
+        <div class="ai-dialog-header">
+          <h3>
+            <img :src="auto" alt="" />
+            小智智能助手
+          </h3>
+          <el-button @click="showAIDialog = false" class="close-btn"
+            >×</el-button
+          >
+        </div>
+        <div class="ai-dialog-content">
+          <div class="ai-message-history">
+            <div
+              v-for="(message, index) in messageList"
+              :key="index"
+              :class="['message', message.type]"
+            >
+              <img
+                v-if="message.type === 'user'"
+                src="@/assets/images/user.png"
+                class="avatar user"
+              />
+              <img v-else src="@/assets/images/xiaozhi.png" class="avatar" />
+              <div
+                class="message-content"
+                v-if="message.type === 'user'"
+                v-html="message.content"
+              ></div>
+              <div class="message-content" v-else>
+                <MarkdownView class="left-text" :content="message.content" />
+              </div>
+            </div>
+          </div>
+          <!-- 弹框默认消息 -->
+          <DefaultMessage
+            class="default-messages"
+            :category="'ai_develop'"
+            :questTip="currentQuestion.ccAiQuestTip || ''"
+            @select-message="handleSelectMessage"
+          />
+          <el-input
+            v-model="prompt"
+            placeholder="输入问题..."
+            class="user-input"
+            @keyup.enter="handleSendByKeydown"
+          >
+            <template #append>
+              <el-button @click="handleSendByButton" size="large" round
+                >发送</el-button
+              >
+            </template>
+          </el-input>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, defineProps, defineEmits, onMounted,watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { CreateDialogue, sendChatMessageStream } from '@/api/questions.js'
+import { teacherList } from '@/api/teachers.js'
+import DefaultMessage from '@/components/DefaultMessage/index.vue'
+import MarkdownView from '@/components/MarkdownView/index.vue'
+import { saveRecord } from '@/api/personalized/index.js'
+
+// 导入图标
+import auto from '@/assets/icon/auto_awesome.png'
+
+// 定义props
+const props = defineProps({
+  questionDialogVisible: { type: Boolean, default: false },
+  currentQuestion: { type: Object, default: () => ({}) },
+  gradeId: { type: String, default: '' },
+  typeId: { type: String, default: '' },
+  courseId: { type: String, default: '' }
+})
+
+// 定义emits
+const emits = defineEmits(['closeQuestionDialog', 'submitAnswer'])
+
+// 内部状态
+const showAIDialog = ref(false)
+const selectedOption = ref(null)
+const messageList = ref([])
+const prompt = ref('')
+const aiQuestionCount = ref(0)
+const xZAiData = ref({})
+const activeConversationId = ref(null)
+const conversationInProgress = ref(false)
+const conversationInAbortController = ref()
+const receiveMessageFullText = ref('')
+const isComposing = ref(false)
+const inputTimeout = ref()
+const enableContext = ref(true)
+
+// 处理选择的默认消息
+const handleSelectMessage = message => {
+  prompt.value = message
+}
+
+// 关闭试题弹框
+const handleCloseQuestionDialog = () => {
+  emits('closeQuestionDialog')
+}
+
+// 提交答案
+const handleSubmitAnswer = () => {
+  if (!selectedOption.value) {
+    ElMessage.warning('请选择一个选项')
+    return
+  }
+
+  emits('submitAnswer', { selectedOption: selectedOption.value })
+  selectedOption.value = null
+}
+
+// 处理 AI 助手点击事件
+const handleAIClick = () => {
+  // 清空输入框
+  messageList.value = []
+  if (props.currentQuestion.ccQuestContent) {
+    prompt.value = props.currentQuestion.ccQuestContent
+    sendMessage()
+    prompt.value = ''
+  }
+  showAIDialog.value = true
+
+  //创建对话
+  createAiChart()
+}
+
+// 数字人接口
+const getXzAi = async () => {
+  try {
+    const grade = localStorage.getItem('selectedGrade') || ''
+
+    // 获取AI数据
+    const juniorAIRes = await teacherList({ category: grade + 'AI' })
+    const aiPerson = juniorAIRes.data.list.find(
+      person => person.name === '小智'
+    )
+    if (aiPerson) {
+      xZAiData.value = {
+        id: aiPerson.id,
+        name: aiPerson.name,
+        image: aiPerson.model2dPath,
+        message: aiPerson.systemMessage,
+        default: aiPerson.questTip
+      }
+    } else {
+      console.warn('未找到名为小智的数据')
+    }
+  } catch (error) {
+    console.error('获取年级AI数据失败:', error)
+  }
+}
+
+//创建对话
+const createAiChart = async () => {
+  // 先获取数字人接口
+  await getXzAi()
+
+  // 智能问答
+  CreateDialogue({ roleId: xZAiData.value.id })
+    .then(res => {
+      activeConversationId.value = res.data
+    })
+    .catch(error => {
+      console.error('请求出错:', error)
+    })
+}
+
+// 发送消息
+const sendMessage = async () => {
+  if (prompt.value.trim()) {
+    // 添加用户消息到历史记录
+    messageList.value.push({
+      type: 'user',
+      content: prompt.value
+    })
+
+    // 增加问答次数
+    aiQuestionCount.value++
+
+    // 保存AI问答次数
+    try {
+      await saveRecord({
+        brpNjId: props.gradeId,
+        brpType: 'aiCount',
+        brpProgress: aiQuestionCount.value
+      })
+    } catch (error) {
+      console.error('保存AI问答次数失败:', error)
+    }
+
+    // 模拟 AI 回复
+    const aiResponse = await simulateAIResponse(prompt.value)
+    messageList.value.push({
+      type: 'ai',
+      content: aiResponse
+    })
+
+    // 清空输入框
+    prompt.value = ''
+  }
+}
+
+// 模拟 AI 回复
+const simulateAIResponse = question => {
+  return new Promise(resolve => {
+    setTimeout(() => {
+      if (props.currentQuestion.ccAiAnswer) {
+        resolve(props.currentQuestion.ccAiAnswer)
+        return
+      }
+      // 若未匹配到自定义回复,给出默认回复
+      resolve(`您的问题是:${question},这是 AI 的回复示例。`)
+    }, 1000)
+  })
+}
+
+/** 处理来自 keydown 的发送消息 */
+const handleSendByKeydown = async event => {
+  // 判断用户是否在输入
+  if (isComposing.value) {
+    return
+  }
+  // 进行中不允许发送
+  if (conversationInProgress.value) {
+    return
+  }
+  const content = prompt.value?.trim()
+
+  if (event.key === 'Enter') {
+    if (event.shiftKey) {
+      // 插入换行
+      prompt.value += '\r\n'
+      event.preventDefault() // 防止默认的换行行为
+    } else {
+      // 发送消息
+      await doSendMessage(content)
+      event.preventDefault() // 防止默认的提交行为
+    }
+  }
+}
+
+/** 处理来自【发送】按钮的发送消息 */
+const handleSendByButton = () => {
+  doSendMessage(prompt.value?.trim())
+}
+
+/** 真正执行【发送】消息操作 */
+const doSendMessage = async content => {
+  // 校验
+  if (content.length < 1) {
+    console.error('发送失败,原因:内容为空!')
+    return
+  }
+
+  if (activeConversationId.value == null) {
+    console.error('还没创建对话,不能发送!')
+    return
+  }
+
+  // 清空输入框
+  prompt.value = ''
+  // 执行发送
+  await doSendMessageStream({
+    conversationId: activeConversationId.value,
+    content: content
+  })
+}
+
+/** 真正执行【发送】消息操作 */
+const doSendMessageStream = async userMessage => {
+  // 创建 AbortController 实例,以便中止请求
+  conversationInAbortController.value = new AbortController()
+  // 标记对话进行中
+  conversationInProgress.value = true
+  // 设置为空
+  receiveMessageFullText.value = ''
+
+  try {
+    // 1.1 先添加两个假数据,等 stream 返回再替换
+    messageList.value.push({
+      id: -1,
+      conversationId: activeConversationId.value,
+      type: 'user',
+      content: userMessage.content,
+      createTime: new Date()
+    })
+    messageList.value.push({
+      id: -2,
+      conversationId: activeConversationId.value,
+      type: 'assistant',
+      content: '思考中...',
+      createTime: new Date()
+    })
+
+    // 2. 发送 event stream
+    let isFirstChunk = true // 是否是第一个 chunk 消息段
+
+    await sendChatMessageStream(
+      userMessage.conversationId,
+      userMessage.content,
+      conversationInAbortController.value,
+      enableContext.value,
+      async res => {
+        const { code, data, msg } = JSON.parse(res.data)
+        if (code !== 0) {
+          console.log(`对话异常! ${msg}`)
+          return
+        }
+        receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
+        // 首次返回需要添加一个 message 到页面,后面的都是更新
+        if (isFirstChunk) {
+          isFirstChunk = false
+          // 弹出两个假数据
+          messageList.value.pop()
+          messageList.value.pop()
+          // 更新返回的数据
+          messageList.value.push(data.send)
+          messageList.value.push(data.receive)
+        } else {
+          // 更新最后一条消息
+          if (messageList.value.length > 0) {
+            const lastMessage = messageList.value[messageList.value.length - 1]
+            if (lastMessage.id === data.receive.id) {
+              lastMessage.content = receiveMessageFullText.value
+            }
+          }
+        }
+      },
+      error => {
+        console.log(`对话异常! ${error}`)
+        stopStream()
+        // 需要抛出异常,禁止重试
+        throw error
+      },
+      () => {
+        stopStream()
+      }
+    )
+  } catch (error) {
+    console.error('发送消息失败:', error)
+    stopStream()
+  }
+}
+
+/** 停止 stream 流式调用 */
+const stopStream = async () => {
+  // 如果 stream 进行中的 message,就需要调用 controller 结束
+  if (conversationInAbortController.value) {
+    conversationInAbortController.value.abort()
+  }
+  // 设置为 false
+  conversationInProgress.value = false
+}
+
+/** 处理 prompt 输入变化 */
+const handlePromptInput = event => {
+  // 非输入法 输入设置为 true
+  if (!isComposing.value) {
+    // 回车 event data 是 null
+    if (event.data == null) {
+      return
+    }
+    isComposing.value = true
+  }
+  // 清理定时器
+  if (inputTimeout.value) {
+    clearTimeout(inputTimeout.value)
+  }
+  // 重置定时器
+  inputTimeout.value = setTimeout(() => {
+    isComposing.value = false
+  }, 400)
+}
+
+const onCompositionstart = () => {
+  isComposing.value = true
+}
+
+const onCompositionend = () => {
+  setTimeout(() => {
+    isComposing.value = false
+  }, 200)
+}
+
+// 监听props变化
+watch(() => props.questionDialogVisible, (newVal) => {
+  if (newVal && props.currentQuestion) {
+    // 重置选项
+    selectedOption.value = null
+  }
+})
+
+onMounted(() => {
+  // 初始化
+})
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+@use 'sass:color'; // 引入 color 模块
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+// 定义儿童风格的蓝紫色调
+$primary-color: rgba(106, 90, 205, 0.52); // 主色调:蓝紫色
+$secondary-color: rgba(147, 112, 219, 0.66); // 辅助色:亮蓝紫色
+$accent-color: rgb(133, 89, 220); // 强调色:暗蓝紫色
+$light-color: #ffffff; // 浅色背景:淡紫色
+$text-color: #483d8b; // 文本颜色:靛蓝色
+
+// 儿童风格试题弹框样式
+.child-dialog {
+  .el-dialog__header {
+    display: none; // 隐藏原有的标题栏
+  }
+
+  .el-dialog__body {
+    padding: rpx(20);
+    position: relative;
+  }
+
+  .el-dialog__footer {
+    border-top: none;
+    padding: rpx(10) rpx(20);
+    text-align: center;
+    margin-top: auto; // 使底部按钮位于底部
+  }
+
+  .el-dialog__wrapper {
+    // 修改半透明背景色
+    background-color: rgba(0, 0, 0, 0.6);
+  }
+
+  .el-dialog {
+    border: none;
+    border-radius: rpx(20);
+    background: linear-gradient(
+      135deg,
+      $light-color,
+      #d8bfd8
+    ); // 柔和的蓝紫色渐变
+    overflow: hidden;
+    display: flex; // 添加 flex 布局
+    flex-direction: column; // 设置垂直布局
+    min-height: 0; // 防止子元素溢出
+    // 添加装饰元素
+    &::before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: rpx(10);
+      background: linear-gradient(90deg, $secondary-color, $accent-color);
+    }
+  }
+}
+
+// 问题标题样式
+.question-title {
+  padding: rpx(15);
+  border-radius: rpx(12);
+  margin-bottom: rpx(20);
+  color: #483d8b;
+  font-weight: bold;
+  font-size: rpx(12);
+  position: relative;
+  display: flex;
+
+  .question-icon {
+    background-color: $accent-color;
+    color: white;
+    width: rpx(24);
+    height: rpx(24);
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: rpx(10);
+    font-weight: bold;
+    box-shadow: 0 rpx(2) rpx(5) rgba($accent-color, 0.3);
+  }
+}
+
+// 选项容器样式
+.options-container {
+  margin-bottom: rpx(20);
+}
+
+// 问题选项样式
+.question-option {
+  margin: rpx(8) 0;
+  padding: rpx(10) rpx(15);
+  border-radius: rpx(12);
+  border: rpx(1) solid rgba($primary-color, 0.3);
+  transition: all 0.3s ease;
+  display: flex;
+  align-items: center;
+  background-color: white;
+  box-shadow: 0 rpx(2) rpx(5) rgba($primary-color, 0.05);
+
+  ::v-deep(.el-radio__label) {
+    color: $text-color;
+    margin-left: rpx(8);
+    flex: 1;
+    text-align: left;
+    // 增大字体大小
+    font-size: rpx(12);
+  }
+
+  // 选中时的样式变化
+  .el-radio__input.is-checked + .el-radio__label {
+    font-weight: bold;
+    color: $accent-color;
+  }
+
+  &:hover {
+    background-color: rgba($primary-color, 0.05);
+    border-color: rgba($primary-color, 0.5);
+    transform: translateY(-rpx(1));
+  }
+}
+
+// 暂无选项样式
+.no-options {
+  color: rgba($text-color, 0.7);
+  text-align: center;
+  padding: rpx(20);
+  font-size: rpx(12);
+}
+
+// 底部按钮样式
+.child-button {
+  min-width: rpx(80);
+  height: rpx(30);
+  border-radius: rpx(8);
+  font-size: rpx(12);
+  font-weight: 500;
+  transition: all 0.3s ease;
+  box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.1);
+
+  &.confirm {
+    background: linear-gradient(to bottom, #ab81ff, #8559dc);
+    border: none;
+    border-right: 15px;
+    color: white;
+
+    &:hover {
+      background: linear-gradient(
+        to bottom,
+        color.adjust(#ab81ff, $lightness: -5%),
+        color.adjust(#8559dc, $lightness: -5%)
+      );
+      transform: translateY(-rpx(1));
+      color: white;
+    }
+  }
+}
+
+// AI对话图标样式
+.ai-icon-container {
+  position: absolute;
+  bottom: rpx(20);
+  right: rpx(20);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  cursor: pointer;
+  transition: all 0.3s ease;
+
+  &:hover {
+    transform: translateY(-rpx(2));
+  }
+
+  .ai-icon {
+    width: rpx(30);
+    height: rpx(30);
+    margin-bottom: rpx(0);
+    filter: drop-shadow(0 rpx(2) rpx(4) rgba($primary-color, 0.3));
+    // 添加过渡动画
+    transition: transform 0.3s ease;
+  }
+
+  // 悬浮时放大效果
+  .ai-icon:hover {
+    transform: scale(1.5);
+  }
+
+  .ai-text {
+    color: $text-color;
+    font-size: rpx(8);
+    background-color: rgba(255, 255, 255, 0.7);
+    padding: rpx(2) rpx(5);
+    border-radius: rpx(5);
+  }
+}
+
+// AI对话弹框样式
+.ai-dialog-wrapper {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  z-index: 1001;
+  pointer-events: none;
+}
+
+.ai-dialog {
+  border: none;
+  border-radius: rpx(15);
+  background: rgb(255, 255, 255, 0.8);
+  overflow: hidden;
+  padding: rpx(20);
+  width: 30%;
+  // 增加高度
+  height: 80%;
+  margin-right: rpx(50);
+  pointer-events: auto;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: rpx(10);
+  }
+}
+
+.ai-dialog-header {
+  position: relative;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-bottom: rpx(15);
+  margin-top: rpx(-10);
+
+  img {
+    width: rpx(15);
+  }
+
+  h3 {
+    color: black;
+    font-size: rpx(12);
+  }
+
+  .close-btn {
+    padding: 0;
+    width: rpx(20);
+    height: rpx(20);
+    font-size: rpx(16);
+    line-height: 1;
+    position: absolute;
+    background-color: transparent;
+    border: none;
+    margin-left: rpx(200);
+  }
+}
+
+.ai-dialog-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.ai-message-history {
+  flex: 1;
+  // 当内容超出容器高度时,显示垂直滚动条
+  overflow-y: auto;
+  margin-bottom: rpx(15);
+  // 可以根据实际情况调整最大高度
+  font-size: rpx(5);
+  max-height: 50vh;
+
+  .message {
+    display: flex;
+    align-items: flex-start;
+    margin-bottom: rpx(10);
+
+    &.user {
+      flex-direction: row-reverse;
+    }
+
+    .avatar {
+      width: rpx(30);
+      height: rpx(30);
+      border-radius: 50%;
+      margin: 0 rpx(10);
+    }
+
+    .user {
+      width: 15px;
+      height: 15px;
+    }
+
+    .message-content {
+      background-color: #ffffff;
+      font-size: rpx(5);
+      max-width: 80%;
+      color: black;
+      box-shadow: 0 rpx(1) rpx(3) rgba(0, 0, 0, 0.05);
+    }
+  }
+  // 滚动条整体样式
+  &::-webkit-scrollbar {
+    width: rpx(4); // 滚动条宽度
+  }
+
+  // 滚动条滑块样式
+  &::-webkit-scrollbar-thumb {
+    background-color: $primary-color; // 滑块颜色
+    border-radius: rpx(4); // 滑块圆角
+  }
+
+  // 滚动条轨道样式
+  &::-webkit-scrollbar-track {
+    background-color: rgba($primary-color, 0.2); // 轨道颜色
+    border-radius: rpx(4); // 轨道圆角
+  }
+
+  .message {
+    display: flex;
+    align-items: flex-start;
+    margin-bottom: rpx(10);
+
+    &.user {
+      flex-direction: row-reverse;
+    }
+
+    .avatar {
+      width: rpx(30);
+      height: rpx(30);
+      border-radius: 50%;
+      margin: 0 rpx(10);
+    }
+
+    .message-content {
+      background-color: white;
+      padding: rpx(8) rpx(12);
+      border-radius: rpx(5);
+      font-size: rpx(8);
+      color: black;
+      max-width: 80%;
+      box-shadow: 0 rpx(1) rpx(3) rgba(0, 0, 0, 0.05);
+    }
+  }
+}
+
+// 用户输入框样式
+.user-input {
+  ::v-deep(.el-input__wrapper) {
+    height: rpx(23);
+    border-top-left-radius: rpx(5);
+    border-bottom-left-radius: rpx(5);
+    border-color: rgba($primary-color, 0.3);
+
+    &:focus-within {
+      box-shadow: 0 0 0 rpx(1) rgba($primary-color, 0.5);
+    }
+  }
+
+  ::v-deep(.el-input__inner) {
+    font-size: rpx(10);
+    text-indent: 1em;
+  }
+  ::v-deep(.el-input-group__append, .el-input-group__prepend) {
+    background: linear-gradient(to bottom, #ab81ff, #8559dc);
+    border-top-right-radius: rpx(5);
+    border-bottom-right-radius: rpx(5);
+    color: white;
+    font-size: rpx(9);
+  }
+}
+
+/* 定义淡入和缩放动画 */
+.fade-scale-enter-active,
+.fade-scale-leave-active {
+  transition: all 0.5s ease;
+}
+
+.fade-scale-enter-from,
+.fade-scale-leave-to {
+  opacity: 0.1;
+  transform: scale(0.9);
+}
+
+// 自定义试题弹框背景
+.child-dialog-wrapper {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.6); // 半透明背景
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 1000;
+}
+
+.child-dialog {
+  border: none;
+  border-radius: rpx(15);
+  background: rgb(255, 255, 255, 0.8); // 柔和的蓝紫色渐变
+  overflow: hidden;
+  padding: rpx(5);
+  position: relative;
+}
+
+.default-messages {
+  margin-top: rpx(-10);
+  margin-bottom: rpx(5);
+}
+</style>

+ 351 - 0
src/components/videopage/VideoPlayer.vue

@@ -0,0 +1,351 @@
+<template>
+  <div class="video-container">
+    <div class="box-video">
+      <video
+        class="full-box-video"
+        ref="videoRef"
+        :controls="true"
+        @timeupdate="handleTimeUpdate"
+        @seeked="handleSeeked"
+        @ended="handleVideoEnded"
+      ></video>
+    </div>
+
+    <!-- 视频切换按钮 -->
+    <div class="video-switch">
+      <div class="caret-left" @click="playPreviousVideo">
+        <el-button type="warning" round>
+          <img :src="leftImg" alt="Left" />
+          上一节</el-button
+        >
+      </div>
+      <div class="caret-right" @click="playNextVideo">
+        <el-button type="warning" round
+          >下一节
+          <img :src="rightImg" alt="Right" />
+        </el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount, defineProps, defineEmits, watch } from 'vue'
+
+import { videoPlay as Vue3VideoPlay } from 'vue3-video-play'
+// import 'vue3-video-play/dist/style.css'
+import Hls from 'hls.js'
+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'
+
+// 定义props
+const props = defineProps({
+  videoPath: { type: String, required: true },
+  courseId: { type: String, required: true },
+  typeId: { type: String, required: true }, // 添加typeId 
+  courseConfigList: { type: Array, default: () => [] },
+  allIndices: { type: Array, default: () => [] },
+  currentIndex: { type: String, required: true }
+})
+
+// 定义emits
+const emits = defineEmits(['timeUpdate', 'videoEnded', 'switchVideo'])
+
+// 视频引用
+const videoRef = ref(null)
+// HLS实例
+const hlsRef = ref(null)
+// 记录已经暂停过的时间点索引
+const pausedIndices = ref([])
+// 记录已经保存的进度百分比
+const savedProgress = ref([])
+// 节流时间间隔(毫秒)
+const THROTTLE_TIME = 3000
+// 上次播放进度
+const lastPlayProgress = ref(0)
+// 定义进度数组
+const targetProgresses = [10, 50, 100]
+
+// 定义节流函数
+const throttle = (fn, delay) => {
+  let lastCall = 0
+  return function(...args) {
+    const now = Date.now()
+    if (now - lastCall >= delay) {
+      lastCall = now
+      return fn.apply(this, args)
+    }
+  }
+}
+
+// 保存进度(带节流)
+const saveProgress = throttle(async (progress, currentTime) => {
+  try {
+    // 保存到localStorage,下次加载视频续播
+    localStorage.setItem(`videoProgress_${props.courseId}`, JSON.stringify({
+      progress: progress,
+      currentTime: currentTime,
+      timestamp: Date.now()
+    }))
+
+    // 保存视频进度接口
+    await saveRecord({
+      brpNjId: globalState.getGradeId(),
+      brpCtId: props.typeId,
+      brpCourseId: props.courseId,
+      brpType: 'course',
+      brpProgress: progress
+    })
+    savedProgress.value.push(progress)
+  } catch (error) {
+    console.error(`保存进度失败:`, error)
+  }
+}, THROTTLE_TIME)
+
+// 处理视频时间更新事件
+const handleTimeUpdate = (ev) => {
+  if (!videoRef.value) return
+  const currentTime = parseInt(ev.target.currentTime)
+  const duration = videoRef.value.duration || 0
+  const progressPercentage = duration > 0 ? Math.round((currentTime / duration) * 100) : 0
+
+  // 更新最后播放进度
+  lastPlayProgress.value = progressPercentage
+
+  // 检查是否达到目标进度点且尚未保存
+  targetProgresses.some(target => {
+    const isNearTarget = Math.abs(progressPercentage - target) <= 2
+    const isNotSaved = !savedProgress.value.includes(target)
+    if (isNearTarget && isNotSaved) {
+      // 保存目标进度
+      saveProgress(target, currentTime)
+      return true
+    }
+    return false
+  })
+
+  // 使用节流保存进度
+  saveProgress(progressPercentage, currentTime)
+
+  // 触发父组件的时间更新事件
+  emits('timeUpdate', { currentTime, progressPercentage })
+
+  if (!props.courseConfigList.length) return
+  props.courseConfigList.forEach(courseCofig => {
+    //暂停时间
+    let time = courseCofig.ccTime
+    // 检查是否到达时间点且还未暂停过
+    if (currentTime === time && !pausedIndices.value.includes(time)) {
+      videoRef.value.pause()
+      // 记录暂停时间
+      pausedIndices.value.push(currentTime)
+      // 触发父组件显示试题
+      emits('timeUpdate', { currentTime, progressPercentage, courseConfig: courseCofig })
+    }
+  })
+}
+
+// 视频完成拖动进度条时触发的方法
+const handleSeeked = () => {
+  pausedIndices.value = []
+}
+
+// 添加视频结束事件处理
+const handleVideoEnded = () => {
+  // 视频结束时保存100%进度
+  if (!savedProgress.value.includes(100)) {
+    saveProgress(100, videoRef.value.duration)
+  }
+  emits('videoEnded')
+}
+
+// 播放下一个视频
+const playNextVideo = () => {
+  const currentIndexInList = props.allIndices.indexOf(props.currentIndex)
+  if (currentIndexInList !== -1 && currentIndexInList < props.allIndices.length - 1) {
+    const nextIndex = props.allIndices[currentIndexInList + 1]
+    emits('switchVideo', nextIndex)
+    // 重置暂停索引
+    pausedIndices.value = []
+  }
+}
+
+// 播放上一个视频
+const playPreviousVideo = () => {
+  const currentIndexInList = props.allIndices.indexOf(props.currentIndex)
+  if (currentIndexInList > 0) {
+    const previousIndex = props.allIndices[currentIndexInList - 1]
+    emits('switchVideo', previousIndex)
+    // 重置暂停索引
+    pausedIndices.value = []
+  }
+}
+
+// 初始化视频播放器
+const initVideoPlayer = () => {
+  if (!videoRef.value) {
+    console.error('视频元素未找到')
+    return
+  }
+
+  // 清理之前的HLS实例
+  if (hlsRef.value) {
+    hlsRef.value.destroy()
+    hlsRef.value = null
+  }
+
+  // 检查视频路径是否是m3u8格式
+  if (props.videoPath && props.videoPath.toLowerCase().endsWith('.m3u8')) {
+    // 使用HLS播放
+    if (Hls.isSupported()) {
+      hlsRef.value = new Hls()
+      hlsRef.value.loadSource(props.videoPath)
+      hlsRef.value.attachMedia(videoRef.value)
+      hlsRef.value.on(Hls.Events.MANIFEST_PARSED, () => {
+        tryPlayVideo()
+      })
+      hlsRef.value.on(Hls.Events.ERROR, (event, data) => {
+        console.error('HLS错误:', data)
+        ElMessage.error('视频加载失败,请稍后重试')
+      })
+    } else if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
+      // 对于不支持HLS但支持原生m3u8的浏览器
+      videoRef.value.src = props.videoPath
+      tryPlayVideo()
+    } else {
+      ElMessage.error('您的浏览器不支持播放m3u8格式视频')
+    }
+  } else {
+    // 普通视频播放
+    videoRef.value.src = props.videoPath
+    tryPlayVideo()
+  }
+}
+
+// 尝试播放视频,处理浏览器自动播放限制
+const tryPlayVideo = () => {
+  // 确保videoRef存在
+  if (!videoRef.value) {
+    console.error('视频元素未找到')
+    return
+  }
+
+  // 在视频加载完成后设置上次播放进度
+  setTimeout(() => {
+    setLastPlayPosition()
+  }, 1000)
+
+  // const playPromise = videoRef.value.play()
+  // if (playPromise !== undefined) {
+  //   playPromise.catch(error => {
+  //     console.error('视频播放失败,可能是浏览器自动播放限制:', error)
+  //   })
+  // }
+}
+
+// 在视频加载完成后设置上次播放进度
+const setLastPlayPosition = () => {
+  if (!videoRef.value) return
+  try {
+    const savedData = localStorage.getItem(`videoProgress_${props.courseId}`)
+    if (savedData) {
+      const { currentTime, progress } = JSON.parse(savedData)
+      if (currentTime && !isNaN(currentTime)) {
+        videoRef.value.currentTime = currentTime
+        lastPlayProgress.value = progress
+        // 检查是否已有保存的进度点
+        if (progress >= 10) savedProgress.value.push(10)
+        if (progress >= 50) savedProgress.value.push(50)
+        if (progress >= 100) savedProgress.value.push(100)
+      }
+    }
+  } catch (error) {
+    console.error('读取上次播放进度失败:', error)
+  }
+}
+
+// 组件挂载时
+onMounted(() => {
+  initVideoPlayer()
+})
+
+// 监听videoPath变化
+watch(() => props.videoPath, () => {
+  initVideoPlayer()
+})
+
+// 组件卸载时
+onBeforeUnmount(() => {
+  if (hlsRef.value) {
+    hlsRef.value.destroy()
+    hlsRef.value = null
+  }
+})
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.box-video {
+  width: 100%;
+  height: rpx(300);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  .d-player-wrap{
+    height: rpx(289);
+    width: 68.5%;
+    border-radius: rpx(12);
+    object-fit: cover;
+  }
+}
+.full-box-video {
+  width: 70%;
+  object-fit: cover;
+  border-radius: rpx(12);
+}
+/* 隐藏 Chrome 视频控件的渐变背景等默认样式 */
+video::-webkit-media-controls-panel {
+  background: transparent !important; /* 去掉背景渐变,设为透明 */
+}
+
+.video-switch {
+  width: 100%;
+  display: flex;
+  margin-top: rpx(5);
+  margin-bottom: rpx(15);
+}
+
+.caret-right,
+.caret-left {
+  width: rpx(50);
+  margin: auto;
+  display: flex;
+}
+
+.caret-left ::v-deep(.el-button.is-round),
+.caret-right ::v-deep(.el-button.is-round) {
+  width: rpx(50);
+  height: rpx(15);
+  color: white;
+  font-size: rpx(7);
+  border-radius: none;
+  border: 1px white solid;
+  background-color: rgb(255, 255, 255, 0.5);
+  box-shadow: 0 4px 8px rgba(202, 52, 52, 0.1);
+}
+
+.caret-right img,
+.caret-left img {
+  width: rpx(12);
+}
+</style>

+ 0 - 5
src/router/index.js

@@ -51,11 +51,6 @@ const routes = [
     path: '/ai-questions',
     component: () => import('../views/AIQuestions.vue')
   },
-  // AI初体验
-  {
-    path: '/ai-initial-experience',
-    component: () => import('../views/AIInitialExperience.vue')
-  },
   // 发展历程
   {
     path: '/ai-develop',

Datei-Diff unterdrückt, da er zu groß ist
+ 167 - 1010
src/views/AIDevelop.vue


Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.