Просмотр исходного кода

1、ai实验室功能抽出组件调用

liyanbo 9 месяцев назад
Родитель
Сommit
76e1ac02aa

+ 635 - 0
src/components/ai/image/ImageToImage.vue

@@ -0,0 +1,635 @@
+<template>
+  <!-- 图生图 -->
+  <div class="number-people">
+    <div class="content-box">
+      <!-- AI对话框 -->
+      <div class="chat-dialog">
+        <!-- 对话消息列表 -->
+        <div class="message-list">
+          <div v-if="imageAllList.length > 0">
+              <div  v-for="(item, index) in imageAllList" :key="index">
+            <!-- 用户消息 -->
+            <div class="user-message" v-if="item.type === 'user'">
+              {{ item.content }}
+              <div class="user-image-list" v-if="item.imageUrl">
+                <el-image
+                  style="width: fit-content; height: 180px; margin: 10px;"
+                  :src="item.imageUrl"
+                  :preview-src-list="[item.imageUrl]"
+                  fit="cover"
+                  show-progress
+                >
+                  <template
+                    #toolbar="{ actions, prev, next, reset, activeIndex, setActiveItem }"
+                  >
+                    <el-icon @click="prev"><Back /></el-icon>
+                    <el-icon @click="next"><Right /></el-icon>
+                    <el-icon @click="setActiveItem(item.imageList.length - 1)">
+                      <DArrowRight />
+                    </el-icon>
+                    <el-icon @click="actions('zoomOut')"><ZoomOut /></el-icon>
+                    <el-icon @click="actions('zoomIn', { enableTransition: false, zoomRate: 2 })"><ZoomIn /></el-icon>
+                    <el-icon @click="actions('clockwise', { rotateDeg: 180, enableTransition: false })"><RefreshRight /></el-icon>
+                    <el-icon @click="actions('anticlockwise')"><RefreshLeft /></el-icon>
+                    <el-icon @click="reset"><Refresh /></el-icon>
+                    <el-icon @click="download(activeIndex)"><Download /></el-icon>
+                  </template>
+                </el-image>
+              </div>
+            </div>
+            <!-- AI生成图片对话框 -->
+            <div class="ai-message" v-if="item.type !== 'user'">
+              {{ item.content }}
+              <span v-if="item.loading" class="loading-dots">
+                <span class="dot"></span>
+                <span class="dot"></span>
+                <span class="dot"></span>
+              </span>
+              <div class="image-list" v-if="item.imageList">
+                <el-image
+                    v-for="(image, index) in item.imageList"
+                    :key="index"
+                    style=" width: fit-content; height: 220px; margin: 10px;"
+                    :src="image"
+                    :preview-src-list="item.imageList"
+                    fit="cover"
+                    show-progress
+                >
+                  <template
+                      #toolbar="{ actions,  reset, activeIndex}"
+                  >
+                    <el-icon @click="actions('zoomOut')"><ZoomOut /></el-icon>
+                    <el-icon
+                        @click="actions('zoomIn', { enableTransition: false, zoomRate: 2 })">
+                      <ZoomIn />
+                    </el-icon>
+                    <el-icon
+                        @click="actions('clockwise', { rotateDeg: 180, enableTransition: false })">
+                      <RefreshRight />
+                    </el-icon>
+                    <el-icon @click="actions('anticlockwise')"><RefreshLeft /></el-icon>
+                    <el-icon @click="reset"><Refresh /></el-icon>
+                    <el-icon @click="download(activeIndex)"><Download /></el-icon>
+                  </template>
+                </el-image>
+              </div>
+            </div>
+             </div>
+          </div>
+        </div>
+
+        <!-- 输入框和发送按钮 -->
+        <div class="input-section">
+          <input
+            type="text"
+            v-model="inputMessage"
+            placeholder="描述任何画面..."
+            @keyup.enter="sendMessage"
+            style="flex: 1; margin-right: 8px;"
+          />
+          <!-- 参考图 -->
+           <ImageUpload v-model="uploadedImage" ref="imageUploadRef"/>
+          <!-- 语音输入按钮 -->
+          <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>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted,onUnmounted} from 'vue'
+import {AiImageStatusEnum, CreatePainting, PaintingGetMys} from '@/api/questions.js'
+import { useRouter, useRoute } from 'vue-router'
+import {
+  Document,
+  Menu as IconMenu,
+  Location,
+  Setting,
+  ArrowLeftBold,
+  Fold,
+  Expand,
+  ChatLineRound,
+  Picture,
+  MagicStick,
+  Tickets,
+  User
+} from '@element-plus/icons-vue'
+
+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";
+
+// 图生图
+import ImageUpload from '@/components/ImageUpload/index.vue';
+
+// 导入getModelIdByType接口
+import { getModelIdByType } from '@/api/teachers.js'
+import { ModelTypeEnum } from '@/api/teachers.js'
+
+// 存储上传的图片
+const uploadedImage = ref('');
+
+const imageUploadRef = ref(null);
+
+// 语音输入响应式变量
+const isRecording = ref(false); // 录音状态
+const recognition = ref(null); // 语音识别实例
+const countdown = ref(0); // 倒计时剩余秒数
+const countdownTimer = ref(null); // 倒计时定
+// 对话状态变量
+const conversationInProgress = ref(false); // 对话是否正在进行中
+const conversationInAbortController = ref(); // 对话进行中 abort 控制器
+
+// tts 语音
+import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
+
+// 添加抽屉显示状态
+const drawerVisible = ref(true)
+
+  // 年级ID相关
+const gradeId = ref('')
+// 添加消息计数器变量
+const messageCount = ref(0)
+// modelId响应式变量
+const modelId = ref(0)
+// 保存记录
+onMounted(async () => {
+    // 从全局状态初始化年级ID
+  gradeId.value = globalState.initGradeId()
+  try{
+    const res = await saveRecord({
+        brpNjId: gradeId.value,
+        brpType: "aiCount",
+        brpProgress: 1
+      });
+      // 获取modelId
+    const modelRes = await getModelIdByType({ type: ModelTypeEnum.IMAGE_TO_IMAGE, platform: "DouBao" })
+    modelId.value = modelRes.data
+  }catch(error){
+    console.error('保存记录失败:', error);
+  }
+});
+
+// 消息列表和输入内容的响应式变量
+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 (uploadedImage.value) {
+    // 创建 AbortController 实例,以便中止请求
+    conversationInAbortController.value = new AbortController();
+    // 标记对话进行中
+    conversationInProgress.value = true;
+    // 先保存内容 再置空输入框
+    let content = inputMessage.value;
+    inputMessage.value = '';
+    
+
+    // 创建用户消息对象,包含可能的图片
+    const userMessage = {
+      type: 'user',
+      content: content,
+    };
+    
+    // 如果有上传的图片,添加到用户消息中
+    if (uploadedImage.value) {
+      userMessage.imageUrl = uploadedImage.value;
+      // 清空上传的图片
+      // uploadedImage.value = '';
+    }
+    
+    imageAllList.value.push(userMessage);
+    imageAllList.value.push({
+      type: 'ai',
+      content: "正在为您生成图片,请稍等",
+      loading: true
+    })
+
+    // 递增消息计数器
+    messageCount.value++
+    // 发送saveRecord请求 保存消息次数
+    try{
+      await saveRecord({
+         brpNjId: gradeId.value,
+         brpType: "aiCount",
+         brpProgress: messageCount.value
+       });
+       console.log('保存记录成功,消息次数:', messageCount.value);
+   }catch(error){
+     console.error('保存记录失败:', error);
+     conversationInProgress.value = false;
+   }
+
+    try {
+      CreatePainting({
+        "modelId": modelId.value,
+        "prompt":content,
+        "width":1024,
+        "height":1024,
+        "promptImage":uploadedImage.value
+      }).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;
+    }
+  } else {
+    // 如果没有上传图片,显示提示信息
+    Message().error('请先上传参考图!', true);
+  }
+  // 调用子组件的方法清除预览图
+  imageUploadRef.value?.clearPreview();
+};
+
+
+// 生成图片
+import { ElIcon } from 'element-plus'
+import {
+  Back,
+  DArrowRight,
+  Download,
+  Refresh,
+  RefreshLeft,
+  RefreshRight,
+  Right,
+  ZoomIn,
+  ZoomOut,
+} from '@element-plus/icons-vue'
+
+
+const imageAllList = ref([]) // 对话的消息列表
+// 图片轮询相关的参数(正在生成中的)
+const inProgressImageMap = ref({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 image
+const inProgressTimer = ref() // 生成中的 image 定时器,轮询生成进展
+
+
+/** 轮询生成中的 image 列表 */
+const refreshWatchImages = async () => {
+  const imageIds = Object.keys(inProgressImageMap.value).map(Number)
+  if (imageIds.length === 0) {
+    return
+  }
+  const list = await PaintingGetMys(imageIds)
+  const newWatchImages = {}
+  list.data.forEach((image) => {
+    
+    if (image.status === AiImageStatusEnum.IN_PROGRESS) {
+      newWatchImages[image.id] = image
+    } else {
+      imageAllList.value.pop();
+      console.log('AI生成的图片地址:', image.picUrl);
+      imageAllList.value.push({
+        type: 'ai',
+        content: "已为您生成图片:",
+        imageList: [image.picUrl],
+      })
+    }
+  })
+  inProgressImageMap.value = newWatchImages
+  if (newWatchImages.size === 0) {
+    inProgressTimerFun()
+  }
+}
+
+
+/** 组件挂在的时候 */
+onMounted(async () => {
+  refreshWatchImagesFun()
+})
+
+/** 组件取消挂在的时候 */
+onUnmounted(async () => {
+  inProgressTimerFun()
+})
+
+// 自动刷新 image 列表
+const refreshWatchImagesFun = () => {
+  inProgressTimer.value = setInterval(async () => {
+    await refreshWatchImages()
+  }, 1000 * 3)
+}
+
+// 停止刷新image列表
+const inProgressTimerFun = () => {
+  if (inProgressTimer.value) {
+    clearInterval(inProgressTimer.value)
+  }
+}
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+// 用户图片列表样式
+.user-image-list {
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: rpx(5);
+}
+//===========================================全局除了删除侧边栏内容唯一改动的地方
+.number-people {
+  flex: 1;
+  height: 100%;
+  display: flex;
+  //background-color: #ece9fd;
+}
+
+.content-box {
+  flex: 1;
+  margin-top: rpx(10);
+  margin-bottom: rpx(10);
+  margin-right: rpx(10);
+  border-radius: rpx(15);
+  background: rgba($color: #ffffff, $alpha: 0.5);
+  overflow-y: auto;
+}
+
+// 对话框
+.chat-dialog {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+.message-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: rpx(15);
+}
+/* 自定义滚动条样式 */
+.message-list::-webkit-scrollbar {
+  width: rpx(2); /* 滚动条宽度 */
+}
+.message-list::-webkit-scrollbar-track {
+  background: #f1effd; /* 滚动条轨道背景色 */
+  border-radius: rpx(4);
+}
+.message-list::-webkit-scrollbar-thumb {
+  background: #e2ddfc; /* 滚动条滑块颜色 */
+  border-radius: rpx(4);
+}
+.message-list::-webkit-scrollbar-thumb:hover {
+  background: #e2ddfc; /* 滚动条滑块 hover 状态颜色 */
+}
+
+.message-list .user-message {
+  background-color: #ffffff;
+  margin-left: auto; // 消息靠右显示
+  margin-right: 0; // 重置右边距
+  max-width: rpx(400);
+  font-size: rpx(8);
+  width: fit-content; // 宽度随文字内容变化
+  border-radius: rpx(5);
+  padding: rpx(5);
+  text-align: left; // 文字左对齐
+}
+
+.message-list .ai-message {
+  background-color: #ffdd55;
+  margin-left: 0; // 消息靠左显示
+  margin-right: auto; // 重置右边距
+  margin-bottom: rpx(10);
+  width: fit-content;
+  max-width: rpx(400);
+  padding: rpx(5);
+  font-size: rpx(8);
+  border-radius: rpx(5);
+  text-align: left; // 文字左对齐
+}
+
+// 加载动画效果
+.loading-dots {
+  display: inline-block;
+  margin-left: rpx(5);
+}
+.loading-dots .dot {
+  display: inline-block;
+  width: rpx(3);
+  height: rpx(3);
+  border-radius: 50%;
+  background-color: #333;
+  margin: 0 rpx(1);
+  animation: loading-dot 1.4s infinite ease-in-out both;
+}
+.loading-dots .dot:nth-child(1) {
+  animation-delay: -0.32s;
+}
+.loading-dots .dot:nth-child(2) {
+  animation-delay: -0.16s;
+}
+@keyframes loading-dot {
+  0%, 80%, 100% {
+    transform: scale(0);
+  }
+  40% {
+    transform: scale(1);
+  }
+}
+
+.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(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;
+  padding: rpx(5);
+  font-size: rpx(7);
+  border: 1px solid #ccc;
+  border-radius: rpx(5);
+}
+.input-section button {
+  padding: rpx(5) rpx(15);
+  background: linear-gradient(
+    to bottom,
+    #fee78a,
+    #ffce1b
+  ); /* 设置悬停、聚焦、点击状态下的背景色 */
+  color: black;
+  border: none;
+  font-size: rpx(7);
+  border-radius: rpx(5);
+  cursor: pointer;
+    box-shadow: 0 0px 2px rgba(0, 0, 0, 0.3);
+
+}
+
+.image-upload-section {
+  padding: rpx(10);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+</style>

+ 653 - 0
src/components/ai/image/TextToImage.vue

@@ -0,0 +1,653 @@
+<template>
+  <!-- 右侧AI问答 -->
+  <div class="number-people">
+    <div class="content-box">
+      <!-- AI对话框 -->
+      <div class="chat-dialog">
+        <!-- 对话消息列表 -->
+        <div class="message-list">
+          <div v-if="imageAllList.length > 0">
+            <div v-for="(item, index) in imageAllList" :key="index">
+            <!-- 用户消息 -->
+            <div class="user-message" v-if="item.type === 'user'">
+              {{ item.content }}
+            </div>
+            <!-- AI生成图片对话框 -->
+            <div class="ai-message" v-if="item.type !== 'user'">
+              {{ item.content }}
+              <span v-if="item.loading" class="loading-dots">
+                <span class="dot"></span>
+                <span class="dot"></span>
+                <span class="dot"></span>
+              </span>
+              <div class="image-list" v-if="item.imageList">
+                <el-image
+                    v-for="(image, index) in item.imageList"
+                    :key="index"
+                    style=" width: fit-content; height: 220px; margin: 10px;"
+                    :src="image"
+                    :preview-src-list="item.imageList"
+                    fit="cover"
+                    show-progress
+                >
+                  <template
+                      #toolbar="{ actions, prev, next, reset, activeIndex, setActiveItem }"
+                  >
+                    <el-icon @click="prev"><Back /></el-icon>
+                    <el-icon @click="next"><Right /></el-icon>
+                    <el-icon @click="setActiveItem(item.imageList.length - 1)">
+                      <DArrowRight />
+                    </el-icon>
+                    <el-icon @click="actions('zoomOut')"><ZoomOut /></el-icon>
+                    <el-icon
+                        @click="actions('zoomIn', { enableTransition: false, zoomRate: 2 })">
+                      <ZoomIn />
+                    </el-icon>
+                    <el-icon
+                        @click="actions('clockwise', { rotateDeg: 180, enableTransition: false })">
+                      <RefreshRight />
+                    </el-icon>
+                    <el-icon @click="actions('anticlockwise')"><RefreshLeft /></el-icon>
+                    <el-icon @click="reset"><Refresh /></el-icon>
+                    <el-icon @click="download(activeIndex)"><Download /></el-icon>
+                  </template>
+                </el-image>
+              </div>
+            </div>
+          </div>
+          </div>
+
+
+          <div v-else class="content-demo">
+            <h3>请参考示例:</h3>
+            <!-- 用户消息 -->
+            <div class="user-message">
+              生成粉色的会飞的猪
+            </div>
+            <!-- AI生成图片对话框 -->
+            <div class="ai-message" >
+              为您生成图片:
+              <div class="image-list" v-if="demoImageList">
+                <el-image
+                    v-for="(image, index) in demoImageList"
+                    :key="index"
+                    style=" width: fit-content; height: 180px; margin: 10px;"
+                    :src="image"
+                    :preview-src-list="demoImageList"
+                    fit="cover"
+                    show-progress
+                >
+                  <template
+                      #toolbar="{ actions, prev, next, reset, activeIndex, setActiveItem }"
+                  >
+                    <el-icon @click="prev"><Back /></el-icon>
+                    <el-icon @click="next"><Right /></el-icon>
+                    <el-icon @click="setActiveItem(demoImageList.length - 1)">
+                      <DArrowRight />
+                    </el-icon>
+                    <el-icon @click="actions('zoomOut')"><ZoomOut /></el-icon>
+                    <el-icon
+                        @click="actions('zoomIn', { enableTransition: false, zoomRate: 2 })">
+                      <ZoomIn />
+                    </el-icon>
+                    <el-icon
+                        @click="actions('clockwise', { rotateDeg: 180, enableTransition: false })">
+                      <RefreshRight />
+                    </el-icon>
+                    <el-icon @click="actions('anticlockwise')"><RefreshLeft /></el-icon>
+                    <el-icon @click="reset"><Refresh /></el-icon>
+                    <el-icon @click="download(activeIndex)"><Download /></el-icon>
+                  </template>
+                </el-image>
+              </div>
+            </div>
+          </div>
+        </div>
+        <!-- 输入框和发送按钮 -->
+        <div class="input-section">
+          <input
+            type="text"
+            v-model="inputMessage"
+            placeholder="描述任何画面..."
+            @keyup.enter="sendMessage"
+          />
+          <!-- 语音输入按钮 -->
+          <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>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted,onUnmounted} from 'vue'
+import {AiImageStatusEnum, CreatePainting, PaintingGetMys} from '@/api/questions.js'
+import { useRouter, useRoute } from 'vue-router'
+import demo1 from '@/assets/images/ai-demo/ai-image-demo1.png'
+import demo2 from '@/assets/images/ai-demo/ai-image-demo2.png'
+import demo3 from '@/assets/images/ai-demo/ai-image-demo3.png'
+import demo4 from '@/assets/images/ai-demo/ai-image-demo4.png'
+import {
+  Document,
+  Menu as IconMenu,
+  Location,
+  Setting,
+  ArrowLeftBold,
+  Fold,
+  Expand,
+  ChatLineRound,
+  Picture,
+  MagicStick,
+  Tickets,
+  User
+} from '@element-plus/icons-vue'
+
+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";
+
+// 导入getModelIdByType接口
+import { getModelIdByType } from '@/api/teachers.js'
+import { ModelTypeEnum } from '@/api/teachers.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 = () => {
+  router.push('/ai-laboratory')
+}
+const router = useRouter()
+const route = useRoute()
+
+// 导入图片
+import question from '@/assets/icon/question.png'
+import painting from '@/assets/icon/painting.png'
+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)
+// 添加切换抽屉显示状态的函数
+const toggleDrawer = () => {
+  drawerVisible.value = !drawerVisible.value
+}
+
+const demoImageList = [demo1, demo2, demo3, demo4]
+
+  // 年级ID相关
+const gradeId = ref('')
+// 添加消息计数器变量
+const messageCount = ref(0)
+// modelId响应式变量
+const modelId = ref(0)
+
+// 保存记录
+onMounted(async () => {
+    // 从全局状态初始化年级ID
+  gradeId.value = globalState.initGradeId()
+  try{
+    const res = await saveRecord({
+        brpNjId: gradeId.value,
+        brpType: "aiCount",
+        brpProgress: 1
+      });
+      // 获取modelId
+    const modelRes = await getModelIdByType({ type: ModelTypeEnum.TEXT_TO_IMAGE, platform: "DouBao" })
+    modelId.value = modelRes.data
+    
+  }catch(error){
+    console.error('保存记录失败:', error);
+  }
+});
+
+// 消息列表和输入内容的响应式变量
+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;
+    inputMessage.value = ''
+    imageAllList.value.push({
+      type: 'user',
+      content: content,
+    })
+    imageAllList.value.push({
+      type: 'ai',
+      content: "正在为您生成图片,请稍等",
+      loading: true
+    })
+
+    // 递增消息计数器
+    messageCount.value++
+    // 发送saveRecord请求 保存消息次数
+     try{
+       await saveRecord({
+          brpNjId: gradeId.value,
+          brpType: "aiCount",
+          brpProgress: messageCount.value
+        });
+        console.log('保存记录成功,消息次数:', messageCount.value);
+    }catch(error){
+      console.error('保存记录失败:', error);
+      conversationInProgress.value = false;
+    }
+
+    try {
+      CreatePainting({
+        "modelId": modelId.value,
+        "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 {
+  Back,
+  DArrowRight,
+  Download,
+  Refresh,
+  RefreshLeft,
+  RefreshRight,
+  Right,
+  ZoomIn,
+  ZoomOut,
+} from '@element-plus/icons-vue'
+
+
+const imageAllList = ref([]) // 对话的消息列表
+const imageList = ref([]) // image 列表
+// 图片轮询相关的参数(正在生成中的)
+const inProgressImageMap = ref({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 image
+const inProgressTimer = ref() // 生成中的 image 定时器,轮询生成进展
+
+
+/** 轮询生成中的 image 列表 */
+const refreshWatchImages = async () => {
+  const imageIds = Object.keys(inProgressImageMap.value).map(Number)
+  if (imageIds.length === 0) {
+    return
+  }
+  const list = await PaintingGetMys(imageIds)
+  const newWatchImages = {}
+  list.data.forEach((image) => {
+    if (image.status === AiImageStatusEnum.IN_PROGRESS) {
+      newWatchImages[image.id] = image
+    } else {
+      imageAllList.value.pop();
+      imageAllList.value.push({
+        type: 'ai',
+        content: "已为您生成图片:",
+        imageList: [image.picUrl],
+      })
+    }
+  })
+  inProgressImageMap.value = newWatchImages
+  if (newWatchImages.size === 0) {
+    inProgressTimerFun()
+  }
+}
+
+
+/** 组件挂在的时候 */
+onMounted(async () => {
+  refreshWatchImagesFun()
+})
+
+/** 组件取消挂在的时候 */
+onUnmounted(async () => {
+  inProgressTimerFun()
+})
+
+// 自动刷新 image 列表
+const refreshWatchImagesFun = () => {
+  inProgressTimer.value = setInterval(async () => {
+    await refreshWatchImages()
+  }, 1000 * 3)
+}
+
+// 停止刷新image列表
+const inProgressTimerFun = () => {
+  if (inProgressTimer.value) {
+    clearInterval(inProgressTimer.value)
+  }
+}
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+//===========================================全局除了删除侧边栏内容唯一改动的地方
+.number-people {
+  //flex: 1;
+  height: 80%;
+  display: flex;
+  //background-color: #ece9fd;
+}
+
+.content-box {
+  flex: 1;
+  margin-top: rpx(10);
+  margin-bottom: rpx(10);
+  margin-right: rpx(10);
+  border-radius: rpx(15);
+  background: rgba($color: #ffffff, $alpha: 0.5);
+  overflow-y: auto;
+
+}
+
+//左侧展览区图标
+.img-box {
+  margin-top: rpx(50);
+  color: #a39dce;
+}
+// 对话框
+.chat-dialog {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+.message-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: rpx(15);
+}
+/* 自定义滚动条样式 */
+.message-list::-webkit-scrollbar {
+  width: rpx(2); /* 滚动条宽度 */
+}
+.message-list::-webkit-scrollbar-track {
+  background: #f1effd; /* 滚动条轨道背景色 */
+  border-radius: rpx(4);
+}
+.message-list::-webkit-scrollbar-thumb {
+  background: #e2ddfc; /* 滚动条滑块颜色 */
+  border-radius: rpx(4);
+}
+.message-list::-webkit-scrollbar-thumb:hover {
+  background: #e2ddfc; /* 滚动条滑块 hover 状态颜色 */
+}
+
+.message-list .user-message {
+  background-color: #ffffff;
+  margin-left: auto; // 消息靠右显示
+  margin-right: 0; // 重置右边距
+  max-width: rpx(400);
+  font-size: rpx(8);
+  width: fit-content; // 宽度随文字内容变化
+  border-radius: rpx(5);
+  padding: rpx(5);
+  text-align: left; // 文字左对齐
+}
+
+.message-list .ai-message {
+  background-color: #ffdd55;
+  margin-left: 0; // 消息靠左显示
+  margin-right: auto; // 重置右边距
+  margin-bottom: rpx(10);
+  width: fit-content;
+  max-width: rpx(400);
+  padding: rpx(5);
+  font-size: rpx(8);
+  border-radius: rpx(5);
+  text-align: left; // 文字左对齐
+}
+
+// 加载动画效果
+.loading-dots {
+  display: inline-block;
+  margin-left: rpx(5);
+}
+.loading-dots .dot {
+  display: inline-block;
+  width: rpx(3);
+  height: rpx(3);
+  border-radius: 50%;
+  background-color: #333;
+  margin: 0 rpx(1);
+  animation: loading-dot 1.4s infinite ease-in-out both;
+}
+.loading-dots .dot:nth-child(1) {
+  animation-delay: -0.32s;
+}
+.loading-dots .dot:nth-child(2) {
+  animation-delay: -0.16s;
+}
+@keyframes loading-dot {
+  0%, 80%, 100% {
+    transform: scale(0);
+  }
+  40% {
+    transform: scale(1);
+  }
+}
+
+
+.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(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;
+  padding: rpx(5);
+  font-size: rpx(7);
+  border: 1px solid #ccc;
+  border-radius: rpx(5);
+}
+.input-section button {
+  padding: rpx(5) rpx(15);
+  background: linear-gradient(
+    to bottom,
+    #fee78a,
+    #ffce1b
+  ); /* 设置悬停、聚焦、点击状态下的背景色 */
+  color: black;
+  border: none;
+  font-size: rpx(7);
+  border-radius: rpx(5);
+  cursor: pointer;
+    box-shadow: 0 0px 2px rgba(0, 0, 0, 0.3);
+
+}
+</style>

+ 915 - 0
src/components/ai/text/TextToText.vue

@@ -0,0 +1,915 @@
+<template>
+  <!-- 原左侧折叠面板和右侧AI问答 -->
+  <div class="content-wrapper">
+      <div class="left-group2">
+        <div class="selected-image">
+          <img :src="selectedImage" alt="" />
+        </div>
+      </div>
+      <!-- 右侧AI问答 -->
+      <div class="number-people">
+        <div class="content-box">
+          <!-- AI对话框 -->
+          <div class="chat-dialog">
+            <!-- 对话消息列表 -->
+            <div class="message-list" ref="messageListRef" @scroll="handleScroll">
+              <div v-for="(item, index) in messageList" :key="index">
+                <!-- AI消息 -->
+                <div class="ai-message" v-if="item.type !== 'user'">
+                  <MarkdownView class="left-text" :content="item.content" />
+                  <!-- {{item.content}} -->
+                </div>
+
+                <!-- 用户消息 -->
+                <div class="user-message" v-if="item.type === 'user'">
+                  {{ item.content }}
+                </div>
+              </div>
+            </div>
+            <!-- 默认消息 -->
+            <DefaultMessage
+              v-if="showDefaultMessages"
+              @select-message="handleDefaultMessageSelect"
+              :category="route.query.category"
+              :quest-tip="route.query.default"
+            />
+            <!-- 输入框和发送按钮 -->
+            <div class="input-section">
+              <input
+                type="text"
+                v-model="prompt"
+                placeholder="问我任何问题..."
+                @keyup.enter="handleSendByKeydown"
+              />
+              <!-- 语音输入按钮 -->
+              <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="handleSendByButton"
+              >
+                发送
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+</template>
+
+<script setup>
+import { ref, onMounted,onUnmounted, computed, watch, nextTick } from "vue";
+import { CreateDialogue, sendChatMessageStream } from "@/api/questions.js";
+import { useRouter, useRoute } from "vue-router";
+import { saveRecord } from "@/api/personalized/index.js";
+import { teacherList } from '@/api/teachers.js'
+// 导入全局状态
+import { globalState } from "@/utils/globalState.js";
+
+// 终止按钮
+import stopicon from "@/assets/icon/stopicon.png";
+
+import MarkdownView from "@/components/MarkdownView/index.vue";
+import {
+  Document,
+  Menu as IconMenu,
+  Location,
+  Setting,
+  ArrowLeftBold,
+  MagicStick,
+  ChatLineRound,
+  Fold,
+  Expand,
+  Picture,
+  Tickets,
+  User,
+  Search, // 使用Search图标作为替代
+} from "@element-plus/icons-vue";
+
+import DefaultMessage from "@/components/DefaultMessage/index.vue";
+
+
+// 语音图标
+import { Microphone, Mute } from "@element-plus/icons-vue";
+
+import LeftPanel from "@/components/LeftPanel.vue";
+const leftPanelRef = ref(null);
+
+// 定义props
+const props = defineProps({
+  personId: { type: Number},
+  personName: { type: String },
+  personImage: { type: String},
+  personIntroduce: { type: String},
+})
+
+// 语音输入响应式变量
+const isRecording = ref(false); // 录音状态
+const recognition = ref(null); // 语音识别实例
+const countdown = ref(0); // 倒计时剩余秒数
+const countdownTimer = ref(null); // 倒计时定时器
+
+// 默认消息控制
+const showDefaultMessages = ref(true);
+const handleDefaultMessageSelect = (message) => {
+  prompt.value = message;
+  handleSendByButton();
+  showDefaultMessages.value = false;
+};
+
+// 抽屉显示状态
+const drawerVisible = ref(true);
+// 添加切换抽屉显示状态的函数
+const toggleDrawer = () => {
+  drawerVisible.value = !drawerVisible.value;
+};
+
+// 处理菜单展开和关闭
+const handleOpen = () => {};
+const handleClose = () => {};
+
+// 返回上一页
+const goBack = () => {
+  // 停止语音播放
+  stopPlayback();
+  router.push("/ai-laboratory");
+};
+const router = useRouter();
+const route = useRoute();
+
+const personId = ref(props.personId);
+const personName = ref(props.personName);
+const personImage = ref(props.personImage);
+const personIntroduce = ref(props.personIntroduce);
+
+// 渲染实验室携带的人物形象图片
+const selectedImage = ref("");
+onMounted(() => {
+  const image = route.query.image;
+  if (image) {
+    selectedImage.value = image;
+  }
+});
+
+// 聊天对话
+const activeConversationModelPath = ref(null); // 选中的对话编号
+const activeConversationId = ref(null); // 选中的对话编号
+const activeConversation = ref(null); // 选中的 Conversation
+const conversationInProgress = ref(false); // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作,导致 stream 中断
+
+// 消息列表
+const messageRef = ref();
+const activeMessageList = ref([]); // 选中对话的消息列表
+const activeMessageListLoading = ref(false); // activeMessageList 是否正在加载中
+const activeMessageListLoadingTimer = ref(); // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中
+// 消息滚动
+const textSpeed = ref(50); // Typing speed in milliseconds
+const textRoleRunning = ref(false); // Typing speed in milliseconds
+
+// 发送消息输入框
+const isComposing = ref(false); // 判断用户是否在输入
+const conversationInAbortController = ref(); // 对话进行中 abort 控制器(控制 stream 对话)
+const inputTimeout = ref(); // 处理输入中回车的定时器
+const prompt = ref(""); // prompt
+const enableContext = ref(true); // 是否开启上下文
+// 接收 Stream 消息
+const receiveMessageFullText = ref("");
+const receiveMessageDisplayedText = ref("");
+const messageListRef = ref(null);
+const userScrolled = ref(false)//是否用户手动滚动
+
+// =========== 【聊天对话】相关 ===========
+
+/** 获取对话信息 */
+const getConversation = async (id) => {
+  if (!id) {
+    return;
+  }
+  const conversation = ref({});
+  if (!conversation) {
+    return;
+  }
+  conversation.systemMessage = personIntroduce.value;
+  activeConversation.value = conversation;
+  // activeConversationId.value = personId.value
+  activeConversationModelPath.value = personImage.value;
+};
+
+// =========== 【语音录入】相关 ===========
+// 初始化语音识别
+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]) {
+      prompt.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;
+      });
+  }
+};
+
+// =========== 【聊天对话】相关 ===========
+
+/** 处理来自 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());
+};
+
+/** 处理 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);
+};
+// TODO注:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
+const onCompositionstart = () => {
+  isComposing.value = true;
+};
+const onCompositionend = () => {
+  setTimeout(() => {
+    isComposing.value = false;
+  }, 200);
+};
+
+// 保存记录
+// 年级ID相关
+const gradeId = ref("");
+// 添加消息计数器变量
+const messageCount = ref(0);
+onMounted(() => {
+  // 从全局状态初始化年级ID
+  gradeId.value = globalState.initGradeId();
+});
+
+/** 真正执行【发送】消息操作 */
+const doSendMessage = async (content) => {
+  // 校验
+  if (content.length < 1) {
+    console.error("发送失败,原因:内容为空!");
+    return;
+  }
+  if (activeConversationId.value == null) {
+    console.error("还没创建对话,不能发送!");
+    return;
+  }
+  // 递增消息计数器
+  messageCount.value++;
+  // 发送saveRecord请求 保存消息次数
+  try {
+    await saveRecord({
+      brpNjId: gradeId.value,
+      brpType: "aiCount",
+      brpProgress: messageCount.value,
+    });
+  } catch (error) {
+    console.error("保存记录失败:", error);
+  }
+  // 清空输入框
+  prompt.value = "";
+  // 执行发送
+  await doSendMessageStream({
+    conversationId: activeConversationId.value,
+    content: content,
+  });
+};
+
+
+import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
+import {Message} from "@/utils/message/Message.js";
+
+// 解构 stopPlayback 方法
+const { playAudioChunk,stopPlayback } = useAudioPlayer();
+
+/** 真正执行【发送】消息操作 */
+const doSendMessageStream = async (userMessage) => {
+  // 创建 AbortController 实例,以便中止请求
+  conversationInAbortController.value = new AbortController();
+  // 标记对话进行中
+  conversationInProgress.value = true;
+  // 设置为空
+  receiveMessageFullText.value = "";
+
+  try {
+    // 1.1 先添加两个假数据,等 stream 返回再替换
+    activeMessageList.value.push({
+      id: -1,
+      conversationId: activeConversationId.value,
+      type: "user",
+      content: userMessage.content,
+      createTime: new Date(),
+    });
+    activeMessageList.value.push({
+      id: -2,
+      conversationId: activeConversationId.value,
+      type: "assistant",
+      content: "思考中...",
+      createTime: new Date(),
+    });
+
+    // 1.2 开始滚动
+    textRoll();
+
+    // 2. 发送 event stream
+    let isFirstChunk = true; // 是否是第一个 chunk 消息段
+
+    // 销毁语音读取
+    stopPlayback();
+
+    await sendChatMessageStream(
+      userMessage.conversationId,
+      userMessage.content, null,
+      conversationInAbortController.value,
+      enableContext.value,
+      async (res) => {
+        const { code, data, msg } = JSON.parse(res.data);
+        if (code !== 0) {
+          console.log(`对话异常! ${msg}`);
+          return;
+        }
+
+        // 根据事件类型处理
+        if (data.eventType === "TEXT") {
+          // 如果内容为空,就不处理。
+          if (data.receive?.content === "") {
+            return;
+          }
+
+          // 处理文本消息
+          receiveMessageFullText.value += data.receive.content;
+
+          // 首次返回需要添加一个 message 到页面,后面的都是更新
+          if (isFirstChunk) {
+            isFirstChunk = false;
+            // 弹出两个假数据
+            activeMessageList.value.pop();
+            activeMessageList.value.pop();
+            // 更新返回的数据
+            activeMessageList.value.push(data.send);
+            activeMessageList.value.push(data.receive);
+          }
+        } else if (data.eventType === "AUDIO") {
+          // 处理音频消息
+          await playAudioChunk(data.audioData);
+        }
+      },
+      (error) => {
+        console.log(`对话异常! ${error}`);
+        stopStream();
+        // 需要抛出异常,禁止重试
+        throw error;
+      },
+      () => {
+        console.log(`结束对话! `)
+        stopStream();
+      }
+    );
+  } catch (error) {
+    console.error('发送消息失败:', error)
+    stopStream()
+  }
+};
+
+/** 停止 stream 流式调用 */
+const stopStream = async () => {
+  // tip:如果 stream 进行中的 message,就需要调用 controller 结束
+  if (conversationInAbortController.value) {
+    conversationInAbortController.value.abort();
+  }
+  // 设置为 false
+  conversationInProgress.value = false;
+};
+
+/**
+ * 消息列表
+ *
+ * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去
+ */
+const messageList = computed(() => {
+  if (activeMessageList.value.length > 0) {
+    return activeMessageList.value;
+  }
+
+  // 没有消息时,如果有 systemMessage 则展示它
+  if (activeConversation.value?.systemMessage) {
+    let systemMessage = {
+      id: 0,
+      type: "system",
+      content: activeConversation.value.systemMessage,
+    };
+    activeMessageList.value.push(systemMessage);
+    return [systemMessage];
+  }
+  return [];
+});
+
+// ============== 【消息滚动】相关 =============
+
+//处理滚动事件,判断用户是否手动滚动
+const handleScroll = () => {
+  if (messageListRef.value) {
+    const { scrollTop, scrollHeight, clientHeight } = messageListRef.value
+    // 当用户滚动距离底部超过50px时,认为是手动滚动
+    userScrolled.value = scrollTop + clientHeight < scrollHeight - 50
+  }
+}
+
+/** 滚动到 message 底部 */
+const scrollToBottom = async (isIgnore = false) => {
+  // 如果用户手动滚动过,不自动滚动
+  if (userScrolled.value) return
+
+  await nextTick();
+  if (messageListRef.value) {
+    requestAnimationFrame(() => {
+      messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
+    });
+  }
+};
+
+/** 自提滚动效果 */
+const textRoll = async () => {
+
+  let index = 0;
+  try {
+    // 只能执行一次
+    if (textRoleRunning.value) {
+      return;
+    }
+    // 设置状态
+    textRoleRunning.value = true;
+    receiveMessageDisplayedText.value = "";
+    const task = async () => {
+      // 调整速度
+      const diff =
+        (receiveMessageFullText.value.length -
+          receiveMessageDisplayedText.value.length) /
+        10;
+      if (diff > 5) {
+        textSpeed.value = 10;
+      } else if (diff > 2) {
+        textSpeed.value = 30;
+      } else if (diff > 1.5) {
+        textSpeed.value = 50;
+      } else {
+        textSpeed.value = 100;
+      }
+      // 对话结束,就按 30 的速度
+      if (!conversationInProgress.value) {
+        textSpeed.value = 10;
+      }
+
+      if (index < receiveMessageFullText.value.length) {
+        receiveMessageDisplayedText.value +=
+          receiveMessageFullText.value[index];
+        index++;
+
+        // 更新 message
+        const lastMessage =
+          activeMessageList.value[activeMessageList.value.length - 1];
+        lastMessage.content = receiveMessageDisplayedText.value;
+
+        // 滚动到住下面
+        await scrollToBottom();
+        // 重新设置任务
+        timer = setTimeout(task, textSpeed.value);
+      } else {
+        // 不是对话中可以结束
+        if (!conversationInProgress.value) {
+          textRoleRunning.value = false;
+          clearTimeout(timer);
+        } else {
+          // 重新设置任务
+          timer = setTimeout(task, textSpeed.value);
+        }
+      }
+    };
+    let timer = setTimeout(task, textSpeed.value);
+  } catch {}
+};
+
+
+// 监听消息列表变化,自动滚动到底部
+watch(
+    () => messageList.value,
+    () => {
+      scrollToBottom();
+    },
+    { deep: true }
+);
+
+/** 初始化 **/
+onMounted(async () => {
+
+  //默认加载没传递数字人表示的话---默认加载小智
+  if (!personId.value) {
+    const grade = route.query.grade || localStorage.getItem('selectedGrade')
+    // 获取小学低年级AI数据
+    const juniorAIRes = await teacherList({category: grade + 'AI'})
+    const aiPerson = juniorAIRes.data.list.find(
+        person => person.name === '小智'
+    )
+    if (aiPerson) {
+      personId.value = aiPerson.id;
+      personName.value = aiPerson.name;
+      personImage.value = aiPerson.model2dPath;
+      personIntroduce.value = aiPerson.systemMessage;
+    }
+  }
+
+  // 智能问答
+  CreateDialogue({ roleId: personId.value })
+      .then((res) => {
+        console.log("创建会话:", res);
+        activeConversationId.value = res.data;
+      })
+      .catch((error) => {
+        console.error("请求出错:", error);
+      });
+  await getConversation(personId.value);
+  // 获取列表数据
+  // activeMessageListLoading.value = true
+});
+
+// 路由参数变化监听
+watch(
+  () => route.query,
+  (newQuery, oldQuery) => {
+    // 只有当id变化时才更新数据,避免不必要的刷新
+    if (newQuery.id && newQuery.id !== oldQuery?.id) {
+       // 停止语音播放
+      stopPlayback();
+      // 更新相关数据
+      personId.value = newQuery.id;
+      personName.value = newQuery.name;
+      personIntroduce.value = newQuery.message;
+      personImage.value = newQuery.image;
+      selectedImage.value = newQuery.image;
+
+      // 重新初始化对话
+      CreateDialogue({ roleId: newQuery.id })
+        .then((res) => {
+          activeConversationId.value = res.data;
+        })
+        .catch((error) => {
+          console.error("请求出错:", error);
+        });
+
+      getConversation(newQuery.id);
+
+      // 重置消息列表和默认消息显示状态
+      activeMessageList.value = [];
+      showDefaultMessages.value = true;
+    }
+  },
+  { immediate: true, deep: true }
+);
+// 组件卸载时清理语音资源
+onUnmounted(() => {
+  stopPlayback();
+});
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+.content-wrapper {
+  display: flex;
+  flex: 1;
+}
+.left-group {
+  width: rpx(135);
+  height: 100%;
+  background: linear-gradient(to bottom, #001169, #8a78d0);
+}
+.mb-2 {
+  color: black;
+  margin-top: rpx(1);
+}
+.tac ::v-deep(.el-menu) {
+  background-color: transparent;
+  border: none;
+  width: 100%;
+  margin-top: rpx(55);
+  margin-left: rpx(10);
+}
+.el-menu-item {
+  width: rpx(115);
+  height: rpx(25);
+  margin-bottom: rpx(5);
+  border-radius: rpx(6);
+  color: white;
+  font-size: rpx(8);
+}
+.el-menu-item .el-icon svg {
+  font-size: rpx(15);
+  color: white;
+}
+
+.el-menu ::v-deep(.el-menu-item:hover),
+.el-menu ::v-deep(.el-menu-item:focus),
+.el-menu ::v-deep(.el-menu-item:active) {
+  background: linear-gradient(
+    to bottom,
+    #ffefb0,
+    #ffcc00
+  ); /* 设置悬停、聚焦、点击状态下的背景色 */
+  box-shadow: 0 8px 8px rgb(0, 0, 0, 0.3);
+  color: black;
+  font-size: rpx(8);
+}
+.el-menu .el-menu-item.is-active {
+  background: linear-gradient(to bottom, #fee78a, #ffce1b);
+  color: black;
+  font-size: rpx(8);
+  box-shadow: 0 4px 8px rgba(3, 3, 3, 0.3);
+}
+// 侧边栏
+.left-group2 {
+  width: rpx(150);
+  height: 100%;
+  display: flex;
+  background-color: #ece9fd;
+}
+.left-group2 img {
+  width: rpx(120);
+  // height: auto;
+}
+.selected-image {
+  flex: 1;
+  margin: auto;
+  margin-left: rpx(-60);
+}
+.title-box {
+  height: rpx(50);
+}
+.box-icon {
+  width: 100%;
+  height: 100%;
+  flex: 1;
+  display: flex; // 添加 flex 布局
+  align-items: center; // 垂直居中
+  color: black; // 设置图标颜色为白色
+  padding-left: rpx(15);
+  font-size: rpx(10); // 设置图标大小,可按需调整
+  cursor: pointer; // 添加鼠标指针样式
+}
+.box-icon .left-icon {
+  margin-left: rpx(10);
+  margin-right: rpx(5); // 设置图标和文字之间的间距 ;
+}
+
+.number-people {
+  flex: 1;
+  height: 100%;
+  display: flex;
+  background-color: #ece9fd;
+}
+.content-box {
+  flex: 1;
+  margin-top: rpx(10);
+  margin-bottom: rpx(10);
+  margin-right: rpx(10);
+  border-radius: rpx(15);
+  background: rgba($color: #ffffff, $alpha: 0.5);
+}
+
+// 对话框
+.chat-dialog {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+.message-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: rpx(15);
+}
+/* 自定义滚动条样式 */
+.message-list::-webkit-scrollbar {
+  width: rpx(2); /* 滚动条宽度 */
+}
+.message-list::-webkit-scrollbar-track {
+  background: #f1effd; /* 滚动条轨道背景色 */
+  border-radius: rpx(4);
+}
+.message-list::-webkit-scrollbar-thumb {
+  background: #e2ddfc; /* 滚动条滑块颜色 */
+  border-radius: rpx(4);
+}
+.message-list::-webkit-scrollbar-thumb:hover {
+  background: #e2ddfc; /* 滚动条滑块 hover 状态颜色 */
+}
+.message-list .user-message {
+  background-color: #ffffff;
+  margin-left: auto; // 消息靠右显示
+  margin-right: 0; // 重置右边距
+  margin-bottom: rpx(10);
+  max-width: rpx(400);
+  font-size: rpx(8);
+  width: fit-content; // 宽度随文字内容变化
+  border-radius: rpx(5);
+  padding: rpx(5);
+  text-align: left; // 文字左对齐
+}
+.message-list .ai-message {
+  background-color: #ffdd55;
+  margin-left: 0; // 消息靠左显示
+  margin-right: auto; // 重置右边距
+  margin-bottom: rpx(10);
+  width: fit-content;
+  max-width: rpx(400);
+  padding: rpx(5);
+  font-size: rpx(8);
+  border-radius: rpx(5);
+  text-align: left; // 文字左对齐
+}
+.input-section {
+  display: flex;
+  padding: 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;
+  padding: rpx(5);
+  font-size: rpx(7);
+  border: 1px solid #ccc;
+  border-radius: rpx(5);
+}
+.input-section button {
+  padding: rpx(5) rpx(15);
+  background: linear-gradient(
+    to bottom,
+    #fee78a,
+    #ffce1b
+  ); /* 设置悬停、聚焦、点击状态下的背景色 */
+  color: black;
+  border: none;
+  font-size: rpx(7);
+  border-radius: rpx(5);
+  cursor: pointer;
+  box-shadow: 0 0px 2px rgba(0, 0, 0, 0.3);
+}
+</style>

+ 652 - 0
src/components/ai/video/ImageToVideo.vue

@@ -0,0 +1,652 @@
+<template>
+  <!-- 右侧AI问答 -->
+  <div class="number-people">
+      <div class="content-box">
+        <!-- AI对话框 -->
+        <div class="chat-dialog">
+          <!-- 对话消息列表 -->
+          <div class="message-list">
+            <div v-if="imageAllList.length > 0">
+                <div  v-for="(item, index) in imageAllList" :key="index">
+              <!-- 用户消息 -->
+              <div class="user-message" v-if="item.type === 'user'">
+                {{ item.content }}
+                <div class="user-image-list" v-if="item.imageUrl">
+                  <el-image
+                    style="width: fit-content; height: 180px; margin: 10px;"
+                    :src="item.imageUrl"
+                    :preview-src-list="[item.imageUrl]"
+                    fit="cover"
+                    show-progress
+                  >
+                    <template
+                      #toolbar="{ actions, prev, next, reset, activeIndex, setActiveItem }"
+                    >
+                      <el-icon @click="prev"><Back /></el-icon>
+                      <el-icon @click="next"><Right /></el-icon>
+                      <el-icon @click="setActiveItem(item.imageList.length - 1)">
+                        <DArrowRight />
+                      </el-icon>
+                      <el-icon @click="actions('zoomOut')"><ZoomOut /></el-icon>
+                      <el-icon @click="actions('zoomIn', { enableTransition: false, zoomRate: 2 })"><ZoomIn /></el-icon>
+                      <el-icon @click="actions('clockwise', { rotateDeg: 180, enableTransition: false })"><RefreshRight /></el-icon>
+                      <el-icon @click="actions('anticlockwise')"><RefreshLeft /></el-icon>
+                      <el-icon @click="reset"><Refresh /></el-icon>
+                      <el-icon @click="download(activeIndex)"><Download /></el-icon>
+                    </template>
+                  </el-image>
+                </div>
+              </div>
+                  <!-- AI生成图片对话框 -->
+                 <div class="ai-message" v-if="item.type !== 'user'">
+                {{ item.content }}
+                  <span v-if="item.loading" class="loading-dots">
+                    <span class="dot"></span>
+                    <span class="dot"></span>
+                    <span class="dot"></span>
+                  </span>
+                  <div class="image-list" v-if="item.imageList && item.imageList.length > 0">
+                      <video
+                        v-for="(video, index) in item.imageList"
+                        :key="index"
+                        style="width: 60%; max-width: 600px; height: auto; margin: 10px;border-radius: 2%;"
+                        :src="video"
+                        controls
+                        playsinline
+                      >
+                        您的浏览器不支持视频播放
+                    </video>
+                  </div>
+              </div>
+                
+            </div>
+            </div>
+          </div>
+
+          <!-- 输入框和发送按钮 -->
+          <div class="input-section">
+            <input
+              type="text"
+              v-model="inputMessage"
+              placeholder="描述任何画面..."
+              @keyup.enter="sendMessage"
+              style="flex: 1; margin-right: 8px;"
+            />
+             <ImageUpload v-model="uploadedImage" ref="imageUploadRef"/>
+            <!-- 语音输入按钮 -->
+            <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>
+    </div>
+</template>
+
+<script setup>
+import { ref, onMounted,onUnmounted} from 'vue'
+import {AiImageStatusEnum, CreatePainting, PaintingGetMys,CreateVideo, VideoGetMys} from '@/api/questions.js'
+import { useRouter, useRoute } from 'vue-router'
+import {
+  Document,
+  Menu as IconMenu,
+  Location,
+  Setting,
+  ArrowLeftBold,
+  Fold,
+  Expand,
+  ChatLineRound,
+  Picture,
+  MagicStick,
+  Tickets,
+  User
+} from '@element-plus/icons-vue'
+
+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";
+
+// 上传参考图
+import ImageUpload from '@/components/ImageUpload/index.vue';
+// 导入getModelIdByType接口
+import { getModelIdByType } from '@/api/teachers.js'
+import { ModelTypeEnum } from '@/api/teachers.js'
+
+// 存储上传的图片
+const uploadedImage = ref('');
+const imageUploadRef = ref(null);
+
+// 语音输入响应式变量
+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 = () => {
+  router.push('/ai-laboratory')
+}
+const router = useRouter()
+const route = useRoute()
+
+// 导入图片
+import question from '@/assets/icon/question.png'
+import painting from '@/assets/icon/painting.png'
+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)
+// 添加切换抽屉显示状态的函数
+const toggleDrawer = () => {
+  drawerVisible.value = !drawerVisible.value
+}
+
+
+  // 年级ID相关
+const gradeId = ref('')
+// 添加消息计数器变量
+const messageCount = ref(0)
+// modelId响应式变量
+const modelId = ref(0)
+// 保存记录
+onMounted(async () => {
+    // 从全局状态初始化年级ID
+  gradeId.value = globalState.initGradeId()
+  try{
+    const res = await saveRecord({
+        brpNjId: gradeId.value,
+        brpType: "aiCount",
+        brpProgress: 1
+      });
+      // 获取modelId
+    const modelRes = await getModelIdByType({ type: ModelTypeEnum.IMAGE_TO_VIDEO, platform: "DouBao" })
+    modelId.value = modelRes.data
+  }catch(error){
+    console.error('保存记录失败:', error);
+  }
+});
+
+// 消息列表和输入内容的响应式变量
+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 (uploadedImage.value) {
+    // 创建 AbortController 实例,以便中止请求
+    conversationInAbortController.value = new AbortController();
+    // 标记对话进行中
+    conversationInProgress.value = true;
+    // messages.value.push(inputMessage.value.trim())
+    // 先保存内容 再置空输入框
+    let content = inputMessage.value;
+    inputMessage.value = ''
+    // 创建用户消息对象,包含可能的图片
+    const userMessage = {
+      type: 'user',
+      content: content,
+    };
+    
+    // 如果有上传的图片,添加到用户消息中
+    if (uploadedImage.value) {
+      userMessage.imageUrl = uploadedImage.value;
+      // 清空上传的图片
+      // uploadedImage.value = '';
+    }
+    // 添加用户消息到消息列表
+    imageAllList.value.push(userMessage);
+    imageAllList.value.push({
+      type: 'ai',
+      content: "正在为您生成视频,请稍等",
+      loading: true
+    })
+
+    // 递增消息计数器
+    messageCount.value++
+    // 发送saveRecord请求 保存消息次数
+     try{
+       await saveRecord({
+          brpNjId: gradeId.value,
+          brpType: "aiCount",
+          brpProgress: messageCount.value
+        });
+        console.log('保存记录成功,消息次数:', messageCount.value);
+    }catch(error){
+      console.error('保存记录失败:', error);
+      conversationInProgress.value = false;
+    }
+
+    try {
+      CreateVideo({
+        "modelId": modelId.value,
+        "prompt":content,
+        "duration":4,
+        "resolution":"1080P",
+        "promptImage":[uploadedImage.value]
+      }).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;
+    }
+  } else {
+    // 如果没有上传图片,显示提示信息
+    Message().error('请先上传参考图!', true);
+  }
+  // 调用子组件的方法清除预览图
+  imageUploadRef.value?.clearPreview();
+};
+
+
+// 生成图片
+import { ElIcon } from 'element-plus'
+import {
+  Back,
+  DArrowRight,
+  Download,
+  Refresh,
+  RefreshLeft,
+  RefreshRight,
+  Right,
+  ZoomIn,
+  ZoomOut,
+} from '@element-plus/icons-vue'
+
+
+const imageAllList = ref([]) // 对话的消息列表
+const imageList = ref([]) // image 列表
+// 图片轮询相关的参数(正在生成中的)
+const inProgressImageMap = ref({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 image
+const inProgressTimer = ref() // 生成中的 image 定时器,轮询生成进展
+
+
+/** 轮询生成中的 image 列表 */
+const refreshWatchImages = async () => {
+  const imageIds = Object.keys(inProgressImageMap.value).map(Number)
+  if (imageIds.length === 0) {
+    return
+  }
+  const list = await VideoGetMys(imageIds)
+  
+      console.log('AI生成的视频地址2222222222:', list,imageIds);
+  const newWatchImages = {}
+  list?.data.forEach((image) => {
+    if (image.status === AiImageStatusEnum.IN_PROGRESS) {
+      newWatchImages[image.id] = image
+    } else {
+      imageAllList.value.pop();
+      console.log('AI生成的视频地址:', image.videoUrl);
+      imageAllList.value.push({
+        type: 'ai',
+        content: "已为您生成视频:",
+        imageList: [image.videoUrl],
+      })
+    }
+  })
+  inProgressImageMap.value = newWatchImages
+  if (newWatchImages.size === 0) {
+    inProgressTimerFun()
+  }
+}
+
+
+/** 组件挂在的时候 */
+onMounted(async () => {
+  refreshWatchImagesFun()
+})
+
+/** 组件取消挂在的时候 */
+onUnmounted(async () => {
+  inProgressTimerFun()
+})
+
+// 自动刷新 image 列表
+const refreshWatchImagesFun = () => {
+  inProgressTimer.value = setInterval(async () => {
+    await refreshWatchImages()
+  }, 1000 * 3)
+}
+
+// 停止刷新image列表
+const inProgressTimerFun = () => {
+  if (inProgressTimer.value) {
+    clearInterval(inProgressTimer.value)
+  }
+}
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+// 用户图片列表样式
+.user-image-list {
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: rpx(5);
+}
+//===========================================全局除了删除侧边栏内容唯一改动的地方
+.number-people {
+  //flex: 1;
+  height: 100%;
+  display: flex;
+  //background-color: #ece9fd;
+}
+
+.content-box {
+  flex: 1;
+  margin-top: rpx(10);
+  margin-bottom: rpx(10);
+  margin-right: rpx(10);
+  border-radius: rpx(15);
+  background: rgba($color: #ffffff, $alpha: 0.5);
+  overflow-y: auto;
+}
+
+//左侧展览区图标
+.img-box {
+  margin-top: rpx(50);
+  color: #a39dce;
+}
+// 对话框
+.chat-dialog {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+.message-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: rpx(15);
+}
+/* 自定义滚动条样式 */
+.message-list::-webkit-scrollbar {
+  width: rpx(2); /* 滚动条宽度 */
+}
+.message-list::-webkit-scrollbar-track {
+  background: #f1effd; /* 滚动条轨道背景色 */
+  border-radius: rpx(4);
+}
+.message-list::-webkit-scrollbar-thumb {
+  background: #e2ddfc; /* 滚动条滑块颜色 */
+  border-radius: rpx(4);
+}
+.message-list::-webkit-scrollbar-thumb:hover {
+  background: #e2ddfc; /* 滚动条滑块 hover 状态颜色 */
+}
+
+.message-list .user-message {
+  background-color: #ffffff;
+  margin-left: auto; // 消息靠右显示
+  margin-right: 0; // 重置右边距
+  max-width: rpx(400);
+  font-size: rpx(8);
+  width: fit-content; // 宽度随文字内容变化
+  border-radius: rpx(5);
+  padding: rpx(5);
+  text-align: left; // 文字左对齐
+}
+
+.message-list .ai-message {
+  background-color: #ffdd55;
+  margin-left: 0; // 消息靠左显示
+  margin-right: auto; // 重置右边距
+  margin-bottom: rpx(10);
+  width: fit-content;
+  max-width: rpx(400);
+  padding: rpx(5);
+  font-size: rpx(8);
+  border-radius: rpx(5);
+  text-align: left; // 文字左对齐
+}
+
+// 加载动画效果
+.loading-dots {
+  display: inline-block;
+  margin-left: rpx(5);
+}
+
+.loading-dots .dot {
+  display: inline-block;
+  width: rpx(3);
+  height: rpx(3);
+  border-radius: 50%;
+  background-color: #333;
+  margin: 0 rpx(1);
+  animation: loading-dot 1.4s infinite ease-in-out both;
+}
+
+.loading-dots .dot:nth-child(1) {
+  animation-delay: -0.32s;
+}
+
+.loading-dots .dot:nth-child(2) {
+  animation-delay: -0.16s;
+}
+
+@keyframes loading-dot {
+  0%, 80%, 100% {
+    transform: scale(0);
+  }
+  40% {
+    transform: scale(1);
+  }
+}
+
+
+.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(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;
+  padding: rpx(5);
+  font-size: rpx(7);
+  border: 1px solid #ccc;
+  border-radius: rpx(5);
+}
+.input-section button {
+  padding: rpx(5) rpx(15);
+  background: linear-gradient(
+    to bottom,
+    #fee78a,
+    #ffce1b
+  ); /* 设置悬停、聚焦、点击状态下的背景色 */
+  color: black;
+  border: none;
+  font-size: rpx(7);
+  border-radius: rpx(5);
+  cursor: pointer;
+    box-shadow: 0 0px 2px rgba(0, 0, 0, 0.3);
+
+}
+
+.image-upload-section {
+  padding: rpx(10);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+</style>

+ 28 - 4
src/views/AIDevelop.vue

@@ -104,9 +104,9 @@
         <div class="small-title">
         <div class="small-title">
           <span>{{ course.courseName }}</span>
           <span>{{ course.courseName }}</span>
         </div>
         </div>
-        
+
         <!-- 视频组件 -->
         <!-- 视频组件 -->
-        <VideoPlayer v-if="course.courseContentType === 'video'"
+        <VideoPlayer v-if="course.courseContentType === 'video2'"
           :contentType="course.courseContentType"
           :contentType="course.courseContentType"
           :videoPath="course.courseVideoPath"
           :videoPath="course.courseVideoPath"
           :courseId="course.id || ''"
           :courseId="course.id || ''"
@@ -120,10 +120,22 @@
         />
         />
         <!-- 图片 -->
         <!-- 图片 -->
         <ImageView v-if="course.courseContentType === 'image'" :imagePath="course.courseImagePath" altText="课程图片"></ImageView>
         <ImageView v-if="course.courseContentType === 'image'" :imagePath="course.courseImagePath" altText="课程图片"></ImageView>
-        
+
         <!-- PPT -->
         <!-- PPT -->
         <PptView v-if="course.courseContentType === 'ppt'" :pptPath="course.pptPath" ref="pptRef"></PptView>
         <PptView v-if="course.courseContentType === 'ppt'" :pptPath="course.pptPath" ref="pptRef"></PptView>
-      
+
+        <!--文生图-->
+        <TextToText class="contentClass" v-if="course.courseContentType === 'aiTextToText'" ref="aiTextToText"></TextToText>
+
+        <!--文生图-->
+        <TextToImage class="contentClass" v-if="course.courseContentType === 'aiTextToImage'" ref="aiTextToImage"></TextToImage>
+
+        <!--图生图-->
+        <ImageToImage class="contentClass" v-if="course.courseContentType === 'aiImageToImage'" ref="aiImagetoImage"></ImageToImage>
+
+        <!--图生视频-->
+        <ImageToVideo class="contentClass" v-if="course.courseContentType === 'aiImageToVideo'" ref="aiImageToVideo"></ImageToVideo>
+
         <!-- 视频切换按钮 - 始终显示 -->
         <!-- 视频切换按钮 - 始终显示 -->
         <div class="video-switch">
         <div class="video-switch">
           <div class="caret-left" @click="playPreviousVideo">
           <div class="caret-left" @click="playPreviousVideo">
@@ -180,6 +192,11 @@ import DialogComponents from '@/components/videopage/DialogComponents.vue'
 import PptView from "@/components/PPT/PptView.vue";
 import PptView from "@/components/PPT/PptView.vue";
 import ImageView from '@/components/Image/ImageView.vue'
 import ImageView from '@/components/Image/ImageView.vue'
 
 
+import TextToText from "@/components/ai/text/TextToText.vue";
+import TextToImage from "@/components/ai/image/TextToImage.vue";
+import ImageToImage from "@/components/ai/image/ImageToImage.vue";
+import ImageToVideo from "@/components/ai/video/ImageToVideo.vue";
+
 const router = useRouter() // 获取当前路由对象
 const router = useRouter() // 获取当前路由对象
 const route = useRoute()
 const route = useRoute()
 
 
@@ -1422,4 +1439,11 @@ $text-color: #483d8b; // 文本颜色:靛蓝色
   margin-top: rpx(-10);
   margin-top: rpx(-10);
   margin-bottom: rpx(5);
   margin-bottom: rpx(5);
 }
 }
+
+
+.contentClass{
+  width: 70%;
+  height: 80%;
+  margin: 0 auto;
+}
 </style>
 </style>