소스 검색

Merge branch 'wanzi'

liyanbo 7 달 전
부모
커밋
fb0a9dd287

+ 10 - 0
src/api/login/login.js

@@ -17,4 +17,14 @@ export function getTenantIdByName (name){
         url: "bjdxWeb/web/getTenantIdByName?name=" + name,
         method: 'get'
     })
+}
+
+
+// 退出登录
+export function logout (data){
+    return axios({
+        url: "system/auth/logout",
+        method: 'post',
+        data
+    })
 }

+ 19 - 0
src/api/questions.js

@@ -73,3 +73,22 @@ export async function PaintingGetMys(ids) {
   })
 }
 
+// 生成视频
+export function CreateVideo (data){
+  return axios({
+    url: "bjdxWeb/ai/create-video",
+    method: 'post',
+    data
+  })
+}
+
+
+// 获取视频结果
+export async function VideoGetMys(ids) {
+  return await axios({
+    url: "bjdxWeb/ai/video-get-mys",
+    method: 'get',
+    data: {ids: ids.join(',')}
+  })
+}
+

+ 17 - 0
src/api/teachers.js

@@ -8,3 +8,20 @@ export function teacherList (data) {
     data
   })
 }
+
+
+// 模型类型枚举
+export const ModelTypeEnum = {
+  IMAGE_TO_IMAGE: 7, // 图生图
+  IMAGE_TO_VIDEO: 4, // 图生视频
+  TEXT_TO_IMAGE: 2   // 文生图
+}
+
+// 获取模型ID
+export function getModelIdByType (data) {
+  return axios({
+    url: 'bjdxWeb/ai/getModelIdByType',
+    method: 'get',
+    data
+  })
+}

BIN
src/assets/icon/image2image.png


BIN
src/assets/icon/image2image02.png


BIN
src/assets/icon/video.png


BIN
src/assets/icon/video02.png


+ 44 - 22
src/components/HomePage.vue

@@ -38,7 +38,7 @@
             :class="{ 'is-active': selectedButton === 'AI写作课' }"
             @click="
               ;(selectedButton = 'AI通识课'),
-                Message().notifyWarning('演示版未开放此功能!!', true)
+                Message().notifyWarning('此版本未开放,敬请期待!', true)
             "
             >AI写作课</el-button
           >
@@ -48,7 +48,7 @@
             :class="{ 'is-active': selectedButton === 'AI艺术课' }"
             @click="
               ;(selectedButton = 'AI通识课'),
-                Message().notifyWarning('演示版未开放此功能!', true)
+                Message().notifyWarning('此版本未开放,敬请期待!', true)
             "
             >AI艺术课</el-button
           >
@@ -113,6 +113,8 @@ import studyImg from '@/assets/images/study.png'
 
 // 退出登录图标
 import logoutIcon from '@/assets/icon/logout.png'
+// 退出登录
+import { logout } from '@/api/login/login.js'
 import { Message } from '@/utils/message/Message.js'
 
 // 平台标题响应式变量
@@ -125,18 +127,32 @@ const updatePlatformTitle = () => {
 // 获取当前路由对象
 const router = useRouter()
 // 退出登录
-const LogoutClick = () => {
-  // 清空 token 和登录状态
-  localStorage.removeItem('token')
-  localStorage.removeItem('isLoggedIn')
-  localStorage.removeItem('maxCourseSections')
-  router.push({ path: '/login' })
+const LogoutClick = async () => {
+  try {
+    // 调用logout API 退出登录
+     const logoutRes = await logout()
+     console.log('退出登录:', logoutRes);
+    // 清空 token 和登录状态
+    localStorage.removeItem('token')
+    localStorage.removeItem('isLoggedIn')
+    localStorage.removeItem('maxCourseSections')
+    // 跳转到登录页面
+    router.push({ path: '/login' })
+  } catch (error) {
+    console.error('退出登录失败:', error)
+    // API调用失败,也清空本地存储
+    localStorage.removeItem('token')
+    localStorage.removeItem('isLoggedIn')
+    localStorage.removeItem('maxCourseSections')
+    Message().notifyError('退出登录失败,请重试', true)
+    router.push({ path: '/login' })
+  }
 }
 
 // 默认选中 AI 通识课
 const selectedButton = ref('AI通识课')
 
-// 添加图片路径
+// 图片路径
 const indexImages = ref([intelligenceImg, roomImg, testImg, studyImg])
 // AI初体验
 const goToAIGeneralCourse = title => {
@@ -331,7 +347,7 @@ window.updateTenantName = (newName) => {
 .top-sub-box,
 .bottom-sub-box {
   background-repeat: no-repeat;
-  background-size: 100% 100%;
+  background-size: cover;
   background-position: center;
 }
 .right-box-in-box2 {
@@ -369,18 +385,26 @@ window.updateTenantName = (newName) => {
 
 .left-box {
   position: relative;
-  justify-content: space-between;
-  align-items: flex-start;
-  flex: 1; // 设置左侧盒子占比为 2
-  // background-color: #fff;
+  justify-content: flex-start;
+  align-items: center;
+  display: flex;
+  flex: 1;
+  padding-left: rpx(30);
 }
 
 .left-box span {
-  position: absolute;
-  margin-top: rpx(20); // 调整上边距离
-  margin-left: rpx(30);
-  font-size: rpx(11); // 默认字体大小
+  position: static;
+  margin-top: 0;
+  margin-left: 0;
+  margin-right: rpx(10); // 与下拉菜单之间的间距
+  font-size: rpx(11);
   color: white;
+  max-width: rpx(200); // 最大宽度限制
+  // overflow: hidden;
+  // text-overflow: ellipsis;
+  white-space: normal; // 允许换行
+  line-height: rpx(16); // 行高
+  text-align: left;
 }
 
 .right-box {
@@ -416,12 +440,10 @@ window.updateTenantName = (newName) => {
   box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
 }
 .dropdown-box {
-  flex: 1;
   align-items: center; // 垂直居中;
-  margin-top: rpx(22);
+  margin-top: 0;
+  display: flex;
 }
-
-
 .dropdown-box .el-button {
   width: rpx(60); // 设置按钮宽度;
   height: rpx(15); // 设置按钮高度;

+ 269 - 0
src/components/ImageUpload/index.vue

@@ -0,0 +1,269 @@
+<template>
+  <div class="image-upload-container">
+    <input
+      type="file"
+      ref="fileInput"
+      accept="image/*"
+      style="display: none"
+      @change="handleFileSelect"
+    />
+    <button 
+      class="upload-btn"
+      @click="triggerFileSelect"
+      :disabled="isUploading"
+      title="上传参考图"
+    >
+    上传参考图
+      <el-icon><Picture /></el-icon>
+    </button>
+
+    <!-- 预览图显示区域 - 使用el-image组件 -->
+    <div v-if="previewImage && previewImage !== '/src/assets/images/default-preview.png'" class="preview-container">
+      <div class="image-wrapper">
+        <!-- 删除按钮 - 确保显示 -->
+        <button class="delete-btn" @click="removeImage">×</button>
+        <!-- 使用el-image组件实现预览功能 -->
+        <el-image
+          :src="previewImage"
+          :preview-src-list="[previewImage]"
+          fit="cover"
+          show-progress
+          class="preview-image"
+        >
+          <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>
+</template>
+
+<script setup>
+import { ref } from 'vue';
+import { Picture, ZoomOut, ZoomIn, RefreshRight, RefreshLeft, Refresh, Download } from '@element-plus/icons-vue';
+import { Message } from '@/utils/message/Message.js';
+// 导入axios用于文件上传
+import axios from 'axios';
+
+// 定义props(保留,但在内部写死默认值)
+const props = defineProps({
+  modelValue: {
+    type: String,
+    default: ''
+  }
+});
+
+// 定义emits
+const emit = defineEmits(['update:modelValue']);
+
+// 文件输入引用
+const fileInput = ref(null);
+// 上传状态
+const isUploading = ref(false);
+// 预览图片URL
+const previewImage = ref('');
+
+// 触发文件选择
+const triggerFileSelect = () => {
+  fileInput.value?.click();
+};
+
+// 处理文件选择
+const handleFileSelect = async (event) => {
+  const file = event.target.files[0];
+  if (!file) return;
+
+  // 验证文件类型
+  if (!file.type.match('image.*')) {
+    Message().error('请选择有效的图片文件!', true);
+    return;
+  }
+
+  // 验证文件大小
+  const maxSize = 50 * 1024 * 1024;
+  if (file.size > maxSize) {
+    Message().error('图片大小不能超过50MB!', true);
+    return;
+  }
+
+  try {
+    isUploading.value = true;
+    // 创建FormData对象
+    const formData = new FormData();
+    formData.append('file', file);
+    
+    
+    // 构建上传地址
+    const uploadUrl = import.meta.env.VITE_BASE_URL + '/infra/file/upload';
+    
+    // 发送文件上传请求
+    const response = await axios.post(uploadUrl, formData, {
+      headers: {
+        'Content-Type': 'multipart/form-data',
+        'Authorization': `Bearer ${localStorage.getItem('token')}`
+      },
+      timeout: 60000
+    });
+    
+    
+    let imageUrl = '';
+    if (response.data?.data) {
+      // 去除反引号和可能的空格
+      imageUrl = response.data.data.replace(/[`\s]/g, '');
+    }
+    
+    // 更新预览图
+    previewImage.value = imageUrl;
+    // 触发更新事件
+    emit('update:modelValue', imageUrl);
+    Message().success('图片上传成功!', true);
+    isUploading.value = false;
+  } catch (error) {
+    console.error('图片上传失败:', error);
+    Message().error('图片上传失败,请重试!', true);
+    isUploading.value = false;
+  } finally {
+    // 清空input值,允许重复选择同一文件
+    if (event.target) {
+      event.target.value = '';
+    }
+  }
+};
+
+// 清除预览图(供父组件调用)
+const clearPreview = () => {
+  previewImage.value = '/src/assets/images/default-preview.png';
+  emit('update:modelValue', '');
+};
+
+// 暴露方法给父组件
+defineExpose({
+  clearPreview
+});
+
+// 移除图片
+const removeImage = () => {
+  previewImage.value = '';
+  emit('update:modelValue', '');
+  Message().success('图片已移除!', true);
+};
+
+// 下载图片函数
+const download = (index) => {
+  const link = document.createElement('a');
+  link.href = [previewImage.value][index];
+  link.download = `image_${Date.now()}.png`;
+  document.body.appendChild(link);
+  link.click();
+  document.body.removeChild(link);
+};
+</script>
+
+<style scoped lang="scss">
+@use "sass:math";
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.image-upload-container {
+  position: relative;
+  display: inline-block;
+}
+
+.upload-btn {
+  padding: rpx(5) rpx(10);
+  background: #fff;
+  border: 1px solid #ffce1b;
+  box-shadow: 0 0px 2px rgba(0, 0, 0, 0.3);
+  border-radius: rpx(5);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: rpx(3);
+  font-size: rpx(6);
+
+  .el-icon {
+    font-size: rpx(8);
+    color: #666;
+  }
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+}
+
+// 预览图样式
+.preview-container {
+  position: absolute;
+  bottom: 100%;
+  margin-bottom: rpx(5);
+  padding: rpx(3);
+  background: #fff;
+  border: 1px solid #ddd;
+  border-radius: rpx(5);
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+  z-index: 1000;
+}
+
+.image-wrapper {
+  position: relative;
+  display: inline-block;
+}
+
+.preview-image {
+  width: rpx(90);
+   max-height: rpx(150);
+  object-fit: contain;
+  border-radius: rpx(3);
+}
+
+// 删除按钮样式 - 确保正确显示
+.delete-btn {
+  position: absolute;
+  top: rpx(3);
+  right: rpx(3);
+  background: black;
+  color: white;
+  border: none;
+  border-radius: 50%;
+  width: rpx(8);
+  height: rpx(8);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  opacity: 0.8;
+  transition: opacity 0.3s;
+  font-size: rpx(5);
+  font-weight: bold;
+  line-height: 1;
+  padding: 0;
+  margin: 0;
+  min-width: rpx(8);
+  z-index: 10;
+}
+
+.image-wrapper:hover .delete-btn {
+  opacity: 0.8;
+}
+
+.delete-btn:hover {
+  opacity: 1;
+}
+</style>

+ 48 - 12
src/components/LeftPanel.vue

@@ -42,20 +42,28 @@ import { useRouter, useRoute } from 'vue-router'
 import question from '@/assets/icon/question.png'
 import painting from '@/assets/icon/painting.png'
 import Human from '@/assets/icon/human.png'
+import image2image from '@/assets/icon/image2image.png'
+import video from '@/assets/icon/video.png'
 // 黑色
 import question02 from '@/assets/icon/question02.png'
 import painting02 from '@/assets/icon/painting02.png'
 import Human02 from '@/assets/icon/Human02.png'
+import image2image02 from '@/assets/icon/image2image02.png'
+import video02 from '@/assets/icon/video02.png'
+
+
 
 import { teacherList } from '@/api/teachers.js'
 
+ 
 const router = useRouter()
 const route = useRoute()
 
 // 添加抽屉显示状态
 const drawerVisible = ref(true)
-// 添加当前选中索引状态
+// 当前选中索引状态
 const currentActiveIndex = ref('2')
+
 // 渲染侧边栏
 const groupList = ref([
   {
@@ -64,30 +72,52 @@ const groupList = ref([
     title: '智能问答'
   },
   {
-    icon: painting, // 默认图片
-    hoverIcon: painting02, // 交互图片
+    icon: painting, 
+    hoverIcon: painting02,
     title: '智能绘画'
   },
   {
-    icon: Human, // 默认图片
-    hoverIcon: Human02, // 交互图片
+    icon: Human,
+    hoverIcon: Human02,
     title: '数字人老师'
+  },
+  {
+    icon: image2image,
+    hoverIcon: image2image02,
+    title: '图生图'
+  },
+  {
+    icon: video,
+    hoverIcon: video02,
+    title: '图生视频'
   }
 ])
 
 
 // 提取更新选中状态的逻辑为单独函数
+// 优化后的更新选中状态的方法
 const updateActiveIndex = () => {
   const path = route.path;
   const from = route.query.from;
+  // 使用映射表存储路径和索引的对应关系
+  const pathIndexMap = {
+    'ai-questions': '0', // 智能问答
+    'ai-painting': '1',  // 智能绘画
+    'ai-laboratory': '2', // 数字人老师
+    'ai-image': '3' ,     // 图生图
+    'ai-video': '4' ,     // 图生视频
+  };
+  // 从数字人老师页面进入智能问答页面
   if (path.includes('ai-questions') && from === 'ai-laboratory') {
-    currentActiveIndex.value = '2'; // 数字人老师
-  } else if (path.includes('ai-questions')) {
-    currentActiveIndex.value = '0'; // 智能问答
-  } else if (path.includes('ai-painting')) {
-    currentActiveIndex.value = '1'; // 智能绘画
-  } else if (path.includes('ai-laboratory')) {
-    currentActiveIndex.value = '2'; // 数字人老师
+    currentActiveIndex.value = '2'; // 保持选中数字人老师
+    return;
+  }
+  // 查找路径对应的索引
+  for (const [key, index] of Object.entries(pathIndexMap)) {
+    if (path.includes(key)) {
+      currentActiveIndex.value = index;
+      return;
+    }
   }
 };
 
@@ -143,6 +173,12 @@ const navigateToAI = async (group) => {
   if (group.title === '数字人老师') {
     router.push('/ai-laboratory')
   }
+  if (group.title === '图生图') {
+    router.push('/ai-image')
+  }
+  if (group.title === '图生视频') {
+    router.push('/ai-video')
+  }
 }
 
 // 处理菜单展开和关闭

+ 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: rpx(7);
+  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: 100%;
+  display: flex;
+  background-color: #ece9fd;
+}
+
+.content-box {
+  flex: 1;
+  // margin-top: rpx(10);
+  // margin-bottom: rpx(10);
+  margin: rpx(7);
+  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="people-image">
+        <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("");
+
+// 聊天对话
+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);
+
+/** 真正执行【发送】消息操作 */
+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 () => {
+
+  // 从全局状态初始化年级ID
+  gradeId.value = globalState.initGradeId();
+
+  //默认加载没传递数字人表示的话---默认加载小智
+  if (!personId.value) {
+    let grade = route.query.grade || localStorage.getItem('selectedGrade')
+    grade = "小学低年级"
+    // 获取小学低年级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;
+
+      selectedImage.value = personImage.value;
+    }
+  }
+
+  // 智能问答
+  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);
+}
+// 侧边栏
+.people-image {
+  width: rpx(130);
+  height: 100%;
+  display: flex;
+  background-color: #ece9fd;
+  overflow: hidden;
+}
+.people-image img {
+  width: rpx(120);
+  height: auto;
+  object-fit: contain;
+}
+.selected-image {
+  flex: 1;
+  margin: auto;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.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: rpx(7);
+  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: rpx(7);
+  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>

+ 18 - 12
src/components/videopage/VideoPlayer.vue

@@ -122,14 +122,19 @@ const saveProgress = throttle(async (progress, currentTime) => {
 
 // 处理视频元数据加载完成
 const handleLoadedMetadata = () => {
-  if (!videoRef.value || !props.courseConfigList.length) return
+  if (!videoRef.value) return
   const duration = videoRef.value.duration
-  if (duration) {
-    // 根据courseConfigList生成章节标记
-    chapterMarkers.value = props.courseConfigList.map(config => ({
-      time: config.ccTime,
-      position: (config.ccTime / duration) * 100
-    }))
+  if (duration && props.courseConfigList && props.courseConfigList.length) {
+    // 根据courseConfigList生成章节标记,过滤掉ccTime不存在或无效的配置项
+    chapterMarkers.value = props.courseConfigList
+      .filter(config => config.ccTime !== undefined && config.ccTime !== null && config.ccTime !== '')
+      .map(config => ({
+        time: config.ccTime,
+        position: (config.ccTime / duration) * 100
+      }))
+  } else {
+    // 确保在courseConfigList为空时清空标记
+    chapterMarkers.value = []
   }
 }
 
@@ -146,7 +151,7 @@ const handleTimeUpdate = ev => {
   if (!videoRef.value) return
   const currentTime = parseInt(ev.target.currentTime)
   const duration = videoRef.value.duration || 0
-  // 如果章节标记为空且有课程配置,尝试生成标记
+  // 如果章节标记为空且有课程配置,生成标记
   if (chapterMarkers.value.length === 0 && props.courseConfigList.length && duration > 0) {
     handleLoadedMetadata()
   }
@@ -174,18 +179,19 @@ const handleTimeUpdate = ev => {
 
   // 触发父组件的时间更新事件
   emits('timeUpdate', { currentTime, progressPercentage })
-
-  if (!props.courseConfigList.length) return
+    if (!props.courseConfigList.length) return
   props.courseConfigList.forEach(courseCofig => {
     // 暂停时间
     let time = courseCofig.ccTime
     // 检查是否到达时间点且还未暂停过
     if (currentTime === time && !pausedIndices.value.includes(time)) {
+      // 无论 ccQuestSource 是什么值,都暂停视频
       videoRef.value.pause()
       // 记录暂停时间
       pausedIndices.value.push(currentTime)
-      // 只有当存在问题内容时才触发弹窗
-      if (courseCofig.ccQuestContent) {
+      // 根据 ccQuestSource 的值决定是否显示问题弹框
+      // 当 ccQuestSource 为 1 时,显示问题弹框
+      if (courseCofig.ccQuestSource === '1' && courseCofig.ccQuestContent) {
         // 触发父组件显示试题
         emits('timeUpdate', {
           currentTime,

+ 12 - 0
src/router/index.js

@@ -6,6 +6,8 @@ const routes = [
 
   { path: '/', component: () => import('../views/Login.vue') },
   { path: '/login', component: () => import('../views/Login.vue') },
+  // 免登录
+  { path: '/quick-login', component: () => import('../views/QuickLogin.vue') },
   // 首页
   {
     path: '/home',
@@ -46,6 +48,16 @@ const routes = [
     path: '/ai-painting',
     component: () => import('../views/AIPainting.vue')
   },
+  // 图生图
+  {
+    path: '/ai-image',
+    component: () => import('../views/AIImageToImage.vue')
+  },
+  // 图生视频
+  {
+    path: '/ai-video',
+    component: () => import('../views/AIImageToVideo.vue')
+  },
   // 智能问答
   {
     path: '/ai-questions',

+ 5 - 5
src/style.css

@@ -1,12 +1,12 @@
 /* 添加字体声明 */
-/* @font-face {
-  font-family: 'SourceHanSansCN-Regular';
-  src: url('../assets/typeface/SourceHanSansCN-Regular_0.otf') format('opentype');
+@font-face {
+  font-family: 'SourceHanSansCN-Normal';
+  src: url('@/assets/typeface/SourceHanSansCN-Medium_0.otf') format('opentype');
   font-weight: normal;
   font-style: normal;
-} */
+}
 :root {
-  font-family: 'SemiBold';
+  font-family: 'SourceHanSansCN-Normal';
   /* font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; */
   line-height: 1.5;
   font-weight: normal;

+ 65 - 16
src/views/AIDevelop.vue

@@ -104,9 +104,10 @@
         <div class="small-title">
           <span>{{ course.courseName }}</span>
         </div>
-        
+
         <!-- 视频组件 -->
-        <VideoPlayer v-if="course.courseContentType === 'video'"
+         <VideoPlayer 
+          v-if="course.courseContentType === 'video'"
           :contentType="course.courseContentType"
           :videoPath="course.courseVideoPath"
           :courseId="course.id || ''"
@@ -120,13 +121,25 @@
         />
         <!-- 图片 -->
         <ImageView v-if="course.courseContentType === 'image'" :imagePath="course.courseImagePath" altText="课程图片"></ImageView>
-        
+
         <!-- PPT -->
         <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="caret-left" @click="playPreviousVideo">
+          <div class="caret-left" @click="playPreviousVideo"> 
             <el-button type="warning" round>
               <img :src="leftImg" alt="Left" />上一节</el-button
             >
@@ -147,7 +160,7 @@
       :currentQuestion="courseConfig"
       :gradeId="gradeId"
       :typeId="typeId"
-      :courseId="course.id || ''"
+      :courseId="course.id || ''" 
       @closeQuestionDialog="closeQuestionDialog"
       @submitAnswer="handleSubmitAnswer"
     />
@@ -180,6 +193,12 @@ import DialogComponents from '@/components/videopage/DialogComponents.vue'
 import PptView from "@/components/PPT/PptView.vue";
 import ImageView from '@/components/Image/ImageView.vue'
 
+// AI实验室
+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 route = useRoute()
 
@@ -211,6 +230,8 @@ const gradeId = ref('')
 const typeId = ref('')
 // 课程小节id
 const courseId = ref('')
+// 课程排序
+const typeSort = ref('')
 
 
 //课程小节字典
@@ -334,14 +355,17 @@ const disableVideo = (index = course.value.key) => {
         return true
       }
   }
-  // if (localStorage.getItem('userName') === 'aiDemo') {
-  //   let dis = Number(index.substring(index.indexOf("-") + 1));
-  //   if (dis > 4) {
-  //     //提示禁用
-  //     Message().notifyWarning('您的账号并未开放此课程!', true)
-  //     return true
-  //   }
-  // }
+
+  if (localStorage.getItem('userName') === "zdxyz") {
+    if (localStorage.getItem('selectedGradeId') !== "1" || typeSort.value !== "02") {
+      let dis = Number(index.substring(index.indexOf("-") + 1));
+      if (dis > 2) {
+        //提示禁用
+        Message().notifyWarning('您的账号并未开放此课程!', true)
+        return true
+      }
+    }
+  }
 
   return false
 }
@@ -444,7 +468,23 @@ onMounted(async () => {
     try {
       // 取接口课程数据
       const res = await ClassType(typeIdParam)
-      courseList.value = res.data
+      console.log(res);
+      // 对返回的课程数据进行处理,确保ccTime为有效秒数
+      const processedData = res.data.map(course => {
+        // 检查并处理courseConfigList
+        if (course.courseConfigList && Array.isArray(course.courseConfigList)) {
+          // 过滤掉ccTime为0的配置项
+          const validConfigList = course.courseConfigList.filter(config => 
+            config.ccTime !== undefined && config.ccTime !== null && config.ccTime > 0
+          )
+          return {
+            ...course,
+            courseConfigList: validConfigList
+          }
+        }
+        return course
+      })
+      courseList.value = processedData
       // 初始化已观看课程ID
       const savedWatchedIds = localStorage.getItem('watchedCourseIds')
       if (savedWatchedIds) {
@@ -508,7 +548,6 @@ onMounted(async () => {
           course.value = courseTemp
           courseId.value = courseTemp.id
         }
-
       })
     } catch (error) {
       console.error('获取课程数据失败:', error)
@@ -520,6 +559,7 @@ onMounted(async () => {
     boxIconTitle.value = String(title)
   }
 
+  typeSort.value = router.currentRoute.value.query.typeSort
   // 初始化年级ID
   gradeId.value = globalState.initGradeId()
 })
@@ -1401,4 +1441,13 @@ $text-color: #483d8b; // 文本颜色:靛蓝色
   margin-top: rpx(-10);
   margin-bottom: rpx(5);
 }
+
+
+.contentClass{
+  width: 70%;
+  height: 80%;
+  margin: 0 auto;
+  border-radius: rpx(15);
+  overflow: hidden;
+}
 </style>

+ 31 - 13
src/views/AIGeneralCourse.vue

@@ -168,6 +168,7 @@ const drawerVisible = ref(true)
 
 // 实操课
 const ClassOutlineScData = ref([])
+
 // 状态变量,跟踪当前显示的是通识课还是实操课
 const showPracticalCourse = ref(false)
 
@@ -205,6 +206,8 @@ const handleNewButtonClick = async() => {
   showPracticalCourse.value = !showPracticalCourse.value
   // 保存状态到localStorage
   localStorage.setItem('showPracticalCourse', showPracticalCourse.value.toString())
+  // 清空搜索框内容
+  SearchInput.value = ''
 }
 
 // 处理下拉菜单选择
@@ -214,24 +217,26 @@ const handleGradeSelect = command => {
   localStorage.setItem('selectedGrade', command)
   // 年级切换时重新加载数据的逻辑
   const selectedItem = classData.value.find(item => item.ctType === command)
+  // 清空搜索框内容
+  SearchInput.value = ''
   // 存储年级id
   if (selectedItem) {
     localStorage.setItem('selectedGradeId', selectedItem.id)
     // 获取课程大纲
     fetchClassOutline(selectedItem.id).then(() => {
       // 检查是否当前显示的是实操课但没有实操课数据
-      if (showPracticalCourse.value && ClassOutlineScData.value.length === 0) {
-        // 切换回通识课
-        showPracticalCourse.value = false
-        // 显示提示
-        Message().notifyWarning('目前暂未开放此课程', true)
-        // 更新localStorage中的状态
-        localStorage.setItem('showPracticalCourse', 'false')
-      }
+      // if (showPracticalCourse.value && ClassOutlineScData.value.length === 0) {
+      //   // 切换回通识课
+      //   showPracticalCourse.value = false
+      //   // 显示提示
+      //   Message().notifyWarning('此版本未开放,敬请期待!', true)
+      //   // 更新localStorage中的状态
+      //   localStorage.setItem('showPracticalCourse', 'false')
+      // }
     })
   }
 }
-// 添加切换抽屉显示状态的函数
+// 切换抽屉显示状态的函数
 const toggleDrawer = () => {
   drawerVisible.value = !drawerVisible.value
 }
@@ -280,12 +285,12 @@ const getCourseTitle = index => {
 const courseTitles = computed(() => {
   const data = showPracticalCourse.value ? ClassOutlineScData.value : classOutlineData.value
   return data.map(item => {
-    return `${item.ctTypeSort} ${item.ctType}`;
+    return `${item.ctTypeSort} ${item.ctType}`;  
   });
 })
 
 // 首页点击渲染后的页面title
-const pageTitle = ref('')
+const pageTitle = ref('AI智能课')
 onMounted(() => {
   fetchCtTypes()
   const title = router.currentRoute.value.query.title
@@ -315,12 +320,15 @@ const querySearch = (queryString, cb) => {
     : data
   cb(results)
 }
+
 // 搜索选择处理方法
 const handleSearchSelect = item => {
   goToAIExperience(item)
     // 清空输入框
   SearchInput.value = '';
 }
+
+
 // 修改过滤逻辑,直接使用classOutlineData
 const filteredTitles = computed(() => {
   const data = showPracticalCourse.value ? ClassOutlineScData.value : classOutlineData.value
@@ -343,13 +351,23 @@ const goBack = () => {
 const goToAIExperience = outlineData => {
   if (localStorage.getItem('userName') === "aiTest") {
     if (localStorage.getItem('selectedGradeId') !== "1" || outlineData.ctTypeSort !== "02") {
-      Message().notifyWarning('您的账号并未开放此课程!', true)
+      Message().notifyWarning('此版本未开放,敬请期待!', true)
+      return
+    }
+  }
+  // 检查是否是实操课的最后四节课
+  if (showPracticalCourse.value && ClassOutlineScData.value.length > 0) {
+    const totalLessons = ClassOutlineScData.value.length;
+    const currentIndex = parseInt(outlineData.ctTypeSort);
+    // 禁用最后四节课
+    if (currentIndex > totalLessons - 4) {
+      Message().notifyWarning('此课程暂未开放,敬请期待!', true)
       return
     }
   }
   router.push({
     path: '/ai-develop', // 跳转视频页面
-    query: { typeId: outlineData.id, typeName: outlineData.ctType }
+    query: { typeId: outlineData.id, typeName: outlineData.ctType, typeSort:outlineData.ctTypeSort }
   })
 }
 </script>

+ 160 - 0
src/views/AIImageToImage.vue

@@ -0,0 +1,160 @@
+<template>
+  <!-- 图生图 -->
+  <div class="home-container">
+    <!-- 展开收起侧边栏 -->
+    <div
+      class="icon-expand"
+      :style="{
+        backgroundColor: drawerVisible ? '#44449c' : '#7F70C840',
+        left: drawerVisible ? '18%' : '0'
+      }"
+      @click="toggleDrawer"
+    >
+      <span
+        class="vertical-lines"
+        :style="{
+          color: drawerVisible ? '#8a78d0' : 'white'
+        }"
+        >||</span
+      >
+    </div>
+
+    <!-- 左侧折叠面板 -->
+    <LeftPanel ref="leftPanelRef" v-if="drawerVisible"/>
+
+    <div class="left-group2">
+      <div class="title-box">
+        <div class="box-icon" @click="goBack">
+          <el-icon class="left-icon"><ArrowLeftBold /></el-icon>
+          图生图
+        </div>
+      </div>
+      <div class="img-box">
+        <p>
+          <img
+              style="width: fit-content; height: 180px; margin: 10px;"
+              src="@/assets/images/color.png"
+              class="avatar user"
+          />
+        </p>
+        <p>期待你的画作喔~</p>
+      </div>
+    </div>
+
+    <!-- 图生图 -->
+    <imageToImage />
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { ArrowLeftBold } from '@element-plus/icons-vue'
+import LeftPanel from '@/components/LeftPanel.vue'
+import imageToImage from '@/components/ai/image/imageToImage.vue'
+
+const router = useRouter()
+const leftPanelRef = ref(null)
+const drawerVisible = ref(true)
+
+// 切换抽屉显示状态
+const toggleDrawer = () => {
+  drawerVisible.value = !drawerVisible.value
+}
+
+// 返回上一页
+const goBack = () => {
+  router.push('/ai-laboratory')
+}
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+:deep(.el-image-viewer__wrapper) {
+  z-index: 10000 !important;
+}
+
+.icon-expand {
+  width: rpx(8);
+  height: rpx(35);
+  border-top-right-radius: rpx(5);
+  border-bottom-right-radius: rpx(5);
+  z-index: 9999;
+  position: absolute;
+  top: 50%;
+  left: 18%;
+  transform: translateY(-50%);
+  background-color: #44449c;
+  cursor: pointer; // 添加鼠标指针样式
+  clip-path: polygon(0 0, 100% 15%, 100% 85%, 0 100%);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  transition: all 0.3s ease;
+}
+
+.icon-expand .vertical-lines {
+  color: #8a78d0;
+  font-size: rpx(10);
+}
+
+.home-container {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  flex-direction: row;
+  gap: rpx(0);
+  background-color: #ece9fd;
+  // background: linear-gradient(
+  //   to bottom,
+  //   #e2ddfc,
+  //   #f1effd
+  // );
+}
+
+// 侧边栏
+.left-group2 {
+  width: rpx(150);
+  height: 100%;
+}
+
+.left-group2 img {
+  width: rpx(110);
+  height: auto;
+  margin-top: rpx(30);
+}
+
+.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); // 设置图标和文字之间的间距 ;
+}
+
+.img-box {
+  margin-top: rpx(50);
+  color: #a39dce;
+}
+</style>

+ 155 - 0
src/views/AIImageToVideo.vue

@@ -0,0 +1,155 @@
+<template>
+  <!-- 图生视频 -->
+  <div class="home-container">
+     <!-- 展开收起侧边栏 -->
+    <div
+      class="icon-expand"
+      :style="{
+        backgroundColor: drawerVisible ? '#44449c' : '#7F70C840',
+        left: drawerVisible ? '18%' : '0'
+      }"
+      @click="toggleDrawer"
+    >
+      <span
+        class="vertical-lines"
+        :style="{
+          color: drawerVisible ? '#8a78d0' : 'white'
+        }"
+        >||</span
+      >
+    </div>
+
+    <!-- 左侧折叠面板 -->
+    <LeftPanel ref="leftPanelRef" v-if="drawerVisible"/>
+
+    <div class="left-group2">
+      <div class="title-box">
+        <div class="box-icon" @click="goBack">
+          <el-icon class="left-icon"><ArrowLeftBold /></el-icon>
+          图生视频
+        </div>
+      </div>
+      <div class="img-box">
+        <p>
+          <img
+              style=" width: fit-content; height: 180px; margin: 10px;"
+              src="@/assets/images/color.png"
+              class="avatar user"
+          />
+        </p>
+        <p>期待你的画作喔~</p>
+      </div>
+    </div>
+
+    <!-- 右侧图生视频组件 -->
+    <ImageToVideo />
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { ArrowLeftBold } from '@element-plus/icons-vue'
+import LeftPanel from '@/components/LeftPanel.vue'
+import ImageToVideo from '@/components/ai/video/ImageToVideo.vue'
+
+const router = useRouter()
+const leftPanelRef = ref(null)
+const drawerVisible = ref(true)
+
+// 返回上一页
+const goBack = () => {
+  router.push('/ai-laboratory')
+}
+
+// 添加切换抽屉显示状态的函数
+const toggleDrawer = () => {
+  drawerVisible.value = !drawerVisible.value
+}
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.home-container {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  flex-direction: row;
+  gap: rpx(0);
+  background: linear-gradient(
+    to bottom,
+    #e2ddfc,
+    #f1effd
+  );
+}
+
+.icon-expand {
+  width: rpx(8);
+  height: rpx(35);
+  border-top-right-radius: rpx(5);
+  border-bottom-right-radius: rpx(5);
+  z-index: 9999;
+  position: absolute;
+  top: 50%;
+  left: 18%;
+  transform: translateY(-50%);
+  background-color: #44449c;
+  cursor: pointer; 
+  clip-path: polygon(0 0, 100% 15%, 100% 85%, 0 100%);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  transition: all 0.3s ease;
+}
+
+.icon-expand .vertical-lines {
+  color: #8a78d0;
+  font-size: rpx(10);
+}
+
+.left-group2 {
+  width: rpx(150);
+  height: 100%;
+  background-color: #ece9fd;
+}
+
+.left-group2 img {
+  width: rpx(110);
+  height: auto;
+  margin-top: rpx(30);
+}
+
+.title-box {
+  height: rpx(50);
+}
+
+.box-icon {
+  width: 100%;
+  height: 100%;
+  flex: 1;
+  display: 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);
+}
+
+.img-box {
+  margin-top: rpx(50);
+  color: #a39dce;
+}
+</style>

+ 27 - 5
src/views/AILaboratory.vue

@@ -118,7 +118,7 @@ const navigateToAIQuestions = person => {
     path: '/ai-questions',
     query: {
       ...person,
-      from: 'ai-laboratory',  // 添加来源标识
+      from: 'ai-laboratory', 
       category: grade.value
     },
   })
@@ -257,7 +257,7 @@ const groupList = ref([
 }
 // 标题样式
 .title-box {
-  height: rpx(50);
+  height: rpx(35);
 }
 .box-icon {
   width: 100%;
@@ -276,21 +276,43 @@ const groupList = ref([
 }
 // 内容样式
 .content-box {
+  // width: 100%;
+  box-sizing: border-box;
+  cursor: pointer; // 鼠标指针样式
   flex: 1;
   display: flex;
   flex-wrap: wrap;
+  overflow-y: auto;
+  // justify-content: center;
+}
+.content-box::-webkit-scrollbar {
+  width: rpx(2);
+}
+.content-box::-webkit-scrollbar-track {
+  background: transparent; // 设置滚动条轨道背景
+  border-radius: rpx(3); // 设置滚动条轨道圆角
+}
+.content-box::-webkit-scrollbar-thumb {
+  background: linear-gradient(to bottom, hsl(230, 100%, 21%), #8a78d0);
+  border-radius: rpx(3); // 设置滚动条滑块圆角
+}
+.content-box::-webkit-scrollbar-thumb:hover {
+  background: linear-gradient(to bottom, hsl(230, 100%, 21%), #8a78d0);
 }
 .small-box {
   flex: 0 0 calc(30% - rpx(10)); // 每个小盒子占三分之一宽度,减去间距
   // width: rpx(180);
   height: rpx(110);
-  margin-top: rpx(20);
-  margin-left: rpx(20);
+  margin-top: rpx(30);
+  margin-left: rpx(25);
   border-radius: rpx(6);
   border: 1px solid white;
   background: rgba($color: #ffffff, $alpha: 0.5);
   position: relative;
-  cursor: pointer; // 添加鼠标指针样式
+  cursor: pointer; // 鼠标指针样式
+  display: flex; // 此行,启用flex布局
+  flex-direction: column; // 此行,垂直排列子元素
+  align-items: center;
 }
 .people-box {
   position: absolute;

+ 13 - 556
src/views/AIPainting.vue

@@ -41,203 +41,25 @@
       </div>
     </div>
 
-    <!-- 右侧AI问答 -->
-    <div class="number-people">
-      <div class="content-box">
-        <!-- AI对话框 -->
-        <div class="chat-dialog">
-          <!-- 对话消息列表 -->
-          <div class="message-list">
-            <div v-if="imageAllList.length > 0" 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 }}
-                <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 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>
+    <!-- 右侧TextToImage组件 -->
+    <TextToImage />
   </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 NumberPeople00 from '@/assets/images/xiaozhi.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";
-
-// 语音输入响应式变量
-const isRecording = ref(false); // 录音状态
-const recognition = ref(null); // 语音识别实例
-const countdown = ref(0); // 倒计时剩余秒数
-const countdownTimer = ref(null); // 倒计时定
-// 对话状态变量
-const conversationInProgress = ref(false); // 对话是否正在进行中
-const conversationInAbortController = ref(); // 对话进行中 abort 控制器
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { ArrowLeftBold } from '@element-plus/icons-vue'
+import LeftPanel from '@/components/LeftPanel.vue'
+import TextToImage from '@/components/ai/image/TextToImage.vue'
 
+const router = useRouter()
+const leftPanelRef = ref(null)
 
 // 返回上一页
 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)
@@ -245,251 +67,6 @@ const drawerVisible = ref(true)
 const toggleDrawer = () => {
   drawerVisible.value = !drawerVisible.value
 }
-
-const demoImageList = [demo1, demo2, demo3, demo4]
-
-  // 年级ID相关
-const gradeId = ref('')
-// 添加消息计数器变量
-const messageCount = ref(0)
-// 保存记录
-onMounted(async () => {
-    // 从全局状态初始化年级ID
-  gradeId.value = globalState.initGradeId()
-  try{
-    const res = await saveRecord({
-        brpNjId: gradeId.value,
-        brpType: "aiCount",
-        brpProgress: 1
-      });
-  }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: "正在为您生成图片,请稍等...",
-    })
-
-    // 递增消息计数器
-    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": 56,
-        "prompt":content,
-        "width":1024,
-        "height":1024
-      }).then(res=>{
-        console.log("生成图片",res)
-        //目前写死调用已生成的图片,全部通了后再改
-        inProgressImageMap.value[res.data] = {id:res.data,status:AiImageStatusEnum.IN_PROGRESS}
-        // inProgressImageMap.value[260] = {id:260,status:AiImageStatusEnum.IN_PROGRESS}
-      }).finally(() => {
-        // 图片生成请求完成后更新状态
-        conversationInProgress.value = false;
-      });
-    } catch (error) {
-      console.error('生成图片失败:', error);
-      conversationInProgress.value = false;
-    }
-  }
-};
-// 生成图片
-import { ElIcon } from 'element-plus'
-import {
-  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">
@@ -508,6 +85,9 @@ const inProgressTimerFun = () => {
   transform: translateX(-100%);
   opacity: 0;
 }
+:deep(.el-image-viewer__wrapper) {
+  z-index: 10000 !important;
+}
 .icon-expand {
   width: rpx(8);
   height: rpx(35);
@@ -540,7 +120,6 @@ const inProgressTimerFun = () => {
   width: rpx(135);
   height: 100%;
   background: linear-gradient(to bottom, #001169, #8a78d0);
-
 }
 .home-container {
   position: fixed;
@@ -642,131 +221,9 @@ const inProgressTimerFun = () => {
   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);
-}
-
 //左侧展览区图标
 .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 .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; // 文字左对齐
-}
-.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>
+</style>

+ 29 - 830
src/views/AIQuestions.vue

@@ -22,7 +22,7 @@
     <!-- 左侧折叠面板 -->
     <LeftPanel ref="leftPanelRef" v-if="drawerVisible" />
 
-    <!-- 原左侧折叠面板和右侧AI问答 -->
+    <!-- 原左侧折叠面板和右侧AI问答 --> 
     <div class="content-wrapper">
       <div class="left-group2">
         <div class="title-box">
@@ -31,148 +31,40 @@
             {{ personName }}
           </div>
         </div>
-        <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>
+      
+      <TextToText
+        :personId="personId"
+        :personName="personName"
+        :personImage="personImage"
+        :personIntroduce="personIntroduce"
+      />
     </div>
   </div>
 </template>
 
 <script setup>
-import { ref, onMounted,onUnmounted, computed, watch, nextTick } from "vue";
-import { CreateDialogue, sendChatMessageStream } from "@/api/questions.js";
+import { ref, onMounted, watch } from "vue";
 import { useRouter, useRoute } from "vue-router";
-import { saveRecord } from "@/api/personalized/index.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);
+import { ArrowLeftBold } from "@element-plus/icons-vue";
+import TextToText from "@/components/ai/text/TextToText.vue";
 
-// 语音输入响应式变量
-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 leftPanelRef = ref(null);
 
 // 抽屉显示状态
 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();
 
@@ -181,523 +73,21 @@ const personName = ref(route.query.name);
 const personIntroduce = ref(route.query.message);
 const personImage = ref(route.query.image);
 
-// 渲染实验室携带的人物形象图片
-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) {
-    // 智能问答
-    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">
@@ -706,17 +96,7 @@ onUnmounted(() => {
 @function rpx($px) {
   @return math.div($px, 750) * 100vw;
 }
-/* 添加过渡样式 */
-.drawer-slide-enter-active,
-.drawer-slide-leave-active {
-  transition: all 0.3s ease;
-}
 
-.drawer-slide-enter-from,
-.drawer-slide-leave-to {
-  transform: translateX(-100%);
-  opacity: 0;
-}
 .home-container {
   position: fixed;
   top: 0;
@@ -740,232 +120,51 @@ onUnmounted(() => {
   left: 18%;
   transform: translateY(-50%);
   background-color: #44449c;
-  cursor: pointer; // 添加鼠标指针样式
+  cursor: pointer;
   clip-path: polygon(0 0, 100% 15%, 100% 85%, 0 100%);
   display: flex;
   justify-content: center;
   align-items: center;
   transition: all 0.3s ease;
 }
+
 .icon-expand .vertical-lines {
   color: #8a78d0;
   font-size: rpx(10);
 }
-.menu-icon {
-  width: rpx(11);
-  height: rpx(11);
-  margin-right: rpx(2);
-}
+
 .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);
+  // 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);
+  position: absolute;
 }
+
 .box-icon {
   width: 100%;
   height: 100%;
   flex: 1;
-  display: flex; // 添加 flex 布局
-  align-items: center; // 垂直居中
-  color: black; // 设置图标颜色为白色
+  display: flex;
+  align-items: center;
+  color: black;
   padding-left: rpx(15);
-  font-size: rpx(10); // 设置图标大小,可按需调整
-  cursor: pointer; // 添加鼠标指针样式
+  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);
+  margin-right: rpx(5);
 }
-</style>
+</style>

+ 37 - 9
src/views/Login.vue

@@ -9,7 +9,7 @@
     <!-- 登录输入框 -->
     <div class="login-wrapper">
       <div class="login-input">
-         <span>AI课程</span>
+         <span>人工智能通识课平台</span>
         <el-form
           ref="loginFormRef"
           :model="loginData.loginForm"
@@ -21,7 +21,7 @@
             <el-input
               v-model="loginData.loginForm.tenantName"
               :prefix-icon="HomeFilled"
-              placeholder="租户"
+              placeholder="学校"
             />
           </el-form-item>
           <el-form-item prop="username">
@@ -62,7 +62,7 @@
 </template>
 
 <script setup>
-import { ref, onMounted, computed } from 'vue'
+import { ref, onMounted, computed ,onUnmounted} from 'vue'
 import { useRouter } from 'vue-router'
 import { HomeFilled, Avatar, Lock } from '@element-plus/icons-vue'
 import { getTenantIdByName, login } from '@/api/login/login.js'
@@ -91,7 +91,7 @@ const isLoggedIn = ref(false)
 
 // 输入框校验
 const rules = {
-  tenantName: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
+  tenantName: [{ required: true, message: '请输入学校名称', trigger: 'blur' }],
   username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
   password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
 }
@@ -103,16 +103,19 @@ const getTenantId = async () => {
     if (res && res.data) {
       //记录租户id
       tenantId.value = res.data
+      return true; // 租户验证成功
     } else {
-      ElMessage.error('获取租户 ID 失败,请检查租户名称')
-      return
+      ElMessage.error('租户填写错误!')
+      return false; // 租户验证失败
     }
   } catch (error) {
-    ElMessage.error('获取租户 ID 出错,请重试')
+    ElMessage.error('租户填写错误!')
     console.error('获取租户 ID 错误:', error)
-    return
+    return false; // 租户验证失败
   }
 }
+
+
 // 登录
 const handleLogin = async params => {
   if (!loginFormRef.value) return
@@ -120,13 +123,21 @@ const handleLogin = async params => {
     if (valid) {
       loginLoading.value = true
       try {
-        await getTenantId()
+        // 先验证租户是否存在
+        const tenantValid = await getTenantId()
+        if (!tenantValid) {
+          // 租户验证失败,不执行登录
+          loginLoading.value = false
+          return
+        }
         const loginDataLoginForm = { ...loginData.value.loginForm }
         // 构建包含 headers 的请求参数
         const res = await login(
           { 'Tenant-Id': tenantId.value },
           loginDataLoginForm
         )
+        console.log(res);
+        
         if (!res) {
           return
         }
@@ -181,6 +192,8 @@ const handleLogin = async params => {
   })
 }
 
+
+
 // 在组件挂载时检查登录状态和恢复登录信息
 onMounted(() => {
   const storedStatus = localStorage.getItem('isLoggedIn') // isLoggedIn
@@ -205,6 +218,21 @@ onMounted(() => {
     isLoggedIn.value = true
     router.push('/home')
   }
+
+   const handleKeyPress = (event) => {
+    // 检查是否按下回车键(keyCode 13)
+    if (event.key === 'Enter' || event.keyCode === 13) {
+      handleLogin()
+    }
+  }
+  
+  document.addEventListener('keydown', handleKeyPress)
+  
+  // 在组件卸载时移除事件监听
+  onUnmounted(() => {
+    document.removeEventListener('keydown', handleKeyPress)
+  })
+
 })
 </script>
 

+ 147 - 0
src/views/QuickLogin.vue

@@ -0,0 +1,147 @@
+<template>
+  <div class="quick-login-container">
+    <div class="loading-overlay">
+      <div class="loading-content">
+        <el-loading-spinner class="loading-spinner"></el-loading-spinner>
+        <p class="loading-text">登录中...</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { getTenantIdByName, login } from '@/api/login/login.js'
+import { ElLoading, ElMessage } from 'element-plus'
+
+const router = useRouter()
+let loading = null
+
+// 测试账号信息
+const testAccount = {
+  tenantName: '人工智能通识课平台',
+  username: 'zdxyz',
+  password: 'zdxyz' 
+}
+
+// 获取租户 ID
+const getTenantId = async (tenantName) => {
+  try {
+    const res = await getTenantIdByName(tenantName)
+    if (res && res.data) {
+      return res.data
+    } else {
+      ElMessage.error('租户验证失败!')
+      return null
+    }
+  } catch (error) {
+    ElMessage.error('获取租户信息失败!')
+    console.error('获取租户 ID 错误:', error)
+    return null
+  }
+}
+
+// 自动登录函数
+const autoLogin = async () => {
+  try {
+    // 显示全局加载状态
+    loading = ElLoading.service({
+      lock: true,
+      text: '登录中...',
+      background: 'rgba(0, 0, 0, 0.7)'
+    })
+
+    // 获取租户ID
+    const tenantId = await getTenantId(testAccount.tenantName)
+    if (!tenantId) {
+      // 租户验证失败
+      return
+    }
+
+    // 执行登录
+    const res = await login(
+      { 'Tenant-Id': tenantId },
+      testAccount
+    )
+
+    if (res && res.code === 0) {
+      // 登录成功,保存登录状态
+      localStorage.setItem('isLoggedIn', 'true')
+      localStorage.setItem('token', res.data.accessToken)
+      localStorage.setItem('userName', testAccount.username)
+      localStorage.setItem('tenantName', testAccount.tenantName)
+      localStorage.setItem('password', testAccount.password)
+      localStorage.setItem('rememberMe', 'true')
+
+      // 根据账号类型设置可查看的课程小节数
+      if (testAccount.username === 'aiTest') {
+        localStorage.setItem('maxCourseSections', '5')
+      } else if (testAccount.username === 'aiAdmin') {
+        localStorage.setItem('maxCourseSections', 'all')
+      }
+
+      ElMessage.success('信息校验成功')
+      // 跳转到课程界面
+      router.push('/ai-general-course')
+    } else {
+      ElMessage.error(res?.message || '信息校验失败')
+      // 如果登录失败,跳转到正常登录页面
+      router.push('/login')
+    }
+  } catch (error) {
+    ElMessage.error('信息校验过程中发生错误')
+    console.error('信息校验错误:', error)
+    // 错误时跳转到登录页面
+    router.push('/login')
+  } finally {
+    // 关闭加载状态
+    if (loading) {
+      loading.close()
+    }
+  }
+}
+
+// 组件挂载时立即执行自动登录
+onMounted(() => {
+  autoLogin()
+})
+</script>
+
+<style scoped>
+.quick-login-container {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background: linear-gradient(to bottom, #001169, #8a78d0);
+}
+
+.loading-overlay {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+}
+
+.loading-content {
+  text-align: center;
+  color: white;
+}
+
+.loading-spinner {
+  width: 60px;
+  height: 60px;
+  margin-bottom: 20px;
+}
+
+.loading-text {
+  font-size: 18px;
+  font-weight: 500;
+}
+</style>