|
|
@@ -0,0 +1,1444 @@
|
|
|
+<template>
|
|
|
+ <div id="app">
|
|
|
+ <div class="main-container">
|
|
|
+ <!-- 信息区 -->
|
|
|
+ <div class="info-container">
|
|
|
+ <!-- 返回按钮 -->
|
|
|
+ <el-button class="back-button" type="text" @click="goBack">
|
|
|
+ <el-icon><Back /></el-icon>
|
|
|
+ </el-button>
|
|
|
+ <h2 class="task-name">{{ aiSpeakingAssignmentInfo.assignmentName }}</h2>
|
|
|
+ <div class="task-time">
|
|
|
+ <div class="start-time">开始时间:{{ formatDate(aiSpeakingAssignmentInfo.startTime) }}</div>
|
|
|
+ <div class="end-time">截止时间:{{ formatDate(aiSpeakingAssignmentInfo.endTime) }}</div>
|
|
|
+
|
|
|
+ </div>
|
|
|
+ <div class="data-item">
|
|
|
+ <span class="data-label">作业描述 </span>
|
|
|
+ <span class="data-value">{{ aiSpeakingAssignmentInfo.description !== null ? aiSpeakingAssignmentInfo.description : '未设置' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="data-item">
|
|
|
+ <span v-if="aiSpeakingAssignmentInfo.descriptionFile" class="data-label">作业附件</span>
|
|
|
+ <el-icon @click="openEditor(aiSpeakingAssignmentInfo.descriptionFile, aiSpeakingAssignmentInfo.assignmentName)"><Document /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="data-item">
|
|
|
+ <span class="data-label">最短交流时间 </span>
|
|
|
+ <span class="data-value">{{ aiSpeakingAssignmentInfo.minDuration !== null ? aiSpeakingAssignmentInfo.minDuration : '未设置' }} min</span>
|
|
|
+ </div>
|
|
|
+ <div class="data-item">
|
|
|
+ <span class="data-label">最少交互次数 </span>
|
|
|
+ <span class="data-value">{{ aiSpeakingAssignmentInfo.minInteraction !== null ? aiSpeakingAssignmentInfo.minInteraction : '未设置' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="data-item">
|
|
|
+ <span class="data-label">累计交流时长 </span>
|
|
|
+ <span class="data-value">{{ formatDuration(recordingDuration) }}</span>
|
|
|
+ </div>
|
|
|
+ <!-- 作业操作按钮组 -->
|
|
|
+ <div class="homework-button-group">
|
|
|
+ <el-button @click="startHomework" class="start-homework-button">
|
|
|
+ {{isHomeworkSubmitted ? "作业记录>" : (hasHomeworkSaved ? "继续作业" : "开始作业") }}
|
|
|
+ </el-button>
|
|
|
+ <el-button @click="saveHomework" class="save-homework-button" v-if="!isHomeworkSubmitted"
|
|
|
+ >暂存作业</el-button
|
|
|
+ >
|
|
|
+ <el-button @click="resetHomework" class="reset-homework-button" v-if="!isHomeworkSubmitted"
|
|
|
+ >重试作业</el-button
|
|
|
+ >
|
|
|
+ <el-button @click="submitHomework" class="submit-homework-button" v-if="!isHomeworkSubmitted"
|
|
|
+ >提交作业</el-button
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 问答区 -->
|
|
|
+ <div class="chat-container-wrapper">
|
|
|
+ <el-card class="chat-container">
|
|
|
+ <template #header>
|
|
|
+ <div class= "card-header-fixed">
|
|
|
+ <div class="card-wrapper">
|
|
|
+ <el-form-item label="voice:" style="margin-bottom: 0;">
|
|
|
+ <el-select
|
|
|
+ v-model="voiceCode"
|
|
|
+ placeholder="选择语音类型"
|
|
|
+ style="margin-right: 20px; width: 120px"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="voice in voiceOptions"
|
|
|
+ :key="voice.code"
|
|
|
+ :label="voice.name"
|
|
|
+ :value="voice.code"
|
|
|
+ >
|
|
|
+ <div style="display: flex; align-items: center;">
|
|
|
+ <el-avatar :src="voice.avatar" size="small" style="margin-right: 10px;"></el-avatar>
|
|
|
+ <span>{{ voice.name }}</span>
|
|
|
+ <el-button
|
|
|
+ @click.stop="toggleAudio(voice)"
|
|
|
+ :icon="voice.isPlaying ? VideoPause : VideoPlay"
|
|
|
+ circle
|
|
|
+ size="small"
|
|
|
+ style="margin-left: 20px"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </el-option>
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <div class="card-header">口语对话区</div>
|
|
|
+ <div class="status-label-wrapper">
|
|
|
+ <span
|
|
|
+ :class="{
|
|
|
+ 'status-default': !isHomeworkStarted,
|
|
|
+ 'status-started':
|
|
|
+ isHomeworkStarted &&
|
|
|
+ !isHomeworkSaved &&
|
|
|
+ !isHomeworkSubmitted,
|
|
|
+ 'status-saved': isHomeworkSaved && !isHomeworkSubmitted,
|
|
|
+ 'status-submitted': isHomeworkSubmitted,
|
|
|
+ }"
|
|
|
+ >
|
|
|
+ {{ getStatusText() }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <div class="chat-messages" ref="chatContainerRef">
|
|
|
+ <div
|
|
|
+ v-for="(message, index) in chatMessages"
|
|
|
+ :key="index"
|
|
|
+ :class="{
|
|
|
+ 'user-message': message.sender === 'user',
|
|
|
+ 'ai-message': message.sender === 'ai',
|
|
|
+ }"
|
|
|
+ >
|
|
|
+ <el-avatar
|
|
|
+ :src="message.sender === 'user' ? userAvatar : (message.voiceCode !== null ? voiceOptions[message.voiceCode].avatar : voiceOptions[voiceCode].avatar)"
|
|
|
+ size="small"
|
|
|
+ style="flex-shrink:0"
|
|
|
+ shape="circle"
|
|
|
+ ></el-avatar>
|
|
|
+ <div class="message-content">
|
|
|
+ <div class="message-text" @click="handleMessageClick(message, index)">{{ message.text }}</div>
|
|
|
+ <div class="message-time">{{ formatTimestamp(message.time) }}</div>
|
|
|
+ </div>
|
|
|
+ <!-- 添加播放图标 -->
|
|
|
+ <el-button
|
|
|
+ v-if="message.sender === 'ai'&& message.audioUrl"
|
|
|
+ @click="playAudio(message)"
|
|
|
+ :icon="message.isPlaying ? VideoPause : VideoPlay"
|
|
|
+ circle
|
|
|
+ size="default"
|
|
|
+ ></el-button>
|
|
|
+ <el-button
|
|
|
+ v-if="message.sender === 'user' && message.audioUrl"
|
|
|
+ @click="playAudio(message)"
|
|
|
+ :icon="message.isPlaying ? VideoPause : VideoPlay"
|
|
|
+ circle
|
|
|
+ size="default"
|
|
|
+ ></el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ <div class="input-container">
|
|
|
+ <!-- 修改为语音输入框 -->
|
|
|
+ <input
|
|
|
+ v-model="userInput"
|
|
|
+ placeholder="请点击语音开始按钮进行对话"
|
|
|
+ @keyup.enter="sendMessage"
|
|
|
+ x-webkit-speech
|
|
|
+ speech
|
|
|
+ :disabled="!isHomeworkStarted || isHomeworkSubmitted"
|
|
|
+ />
|
|
|
+ <el-button
|
|
|
+ @click="startRecording"
|
|
|
+ class="start-button"
|
|
|
+ :disabled="!isHomeworkStarted || isHomeworkSubmitted || hasStartPlay"
|
|
|
+ >
|
|
|
+ {{ hasStopRecording ? "继续" : "开始" }}
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ @click="stopRecording"
|
|
|
+ class="stop-button"
|
|
|
+ :disabled="!isHomeworkStarted || isHomeworkSubmitted || hasStartPlay"
|
|
|
+ >
|
|
|
+ 暂停
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ @click="resetRecording"
|
|
|
+ class="reset-button"
|
|
|
+ :disabled="!isHomeworkStarted || isHomeworkSubmitted"
|
|
|
+ >
|
|
|
+ 重试
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ @click="sendMessage"
|
|
|
+ class="send-button"
|
|
|
+ :disabled="!isHomeworkStarted || isHomeworkSubmitted || isLoading"
|
|
|
+ >
|
|
|
+ {{ isLoading ? "发送中..." : " 发送"}}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div class="recording-bar">
|
|
|
+ <div class="recording-bar">
|
|
|
+ <progress
|
|
|
+ :value="playTime"
|
|
|
+ :max="recorder && recorder.duration"
|
|
|
+ ></progress>
|
|
|
+ <p>
|
|
|
+ {{ playTime.toFixed(2) }}/{{
|
|
|
+ recorder && recorder.duration.toFixed(2)
|
|
|
+ }}
|
|
|
+ </p>
|
|
|
+ <button
|
|
|
+ class="wechat-audio-button"
|
|
|
+ :disabled="!isHomeworkStarted || isHomeworkSubmitted || recorder.duration == 0"
|
|
|
+ @click="togglePlayback"
|
|
|
+ >
|
|
|
+ <span
|
|
|
+ class="icon"
|
|
|
+ :class="{ play: !isPlaying, pause: isPlaying }"
|
|
|
+ ></span>
|
|
|
+ <span class="text">{{ isPlaying ? "暂停" : "播放" }}</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 右侧语音分析模块 -->
|
|
|
+ <VoiceAnalysis ref="voiceAnalysis" :userId = "userId" :assignmentId="assignmentId" :key="voiceInctanceKey"/>
|
|
|
+ <video ref="cameraPreview" class="camera-preview" autoplay muted v-show="isHomeworkStarted"></video>
|
|
|
+ <div class="recording-duration" v-if="isHomeworkStarted">
|
|
|
+ 录制时长: {{ formatDuration(recordingDuration) }}
|
|
|
+ </div>
|
|
|
+ <ViewsWordEditor v-if="aiSpeakingAssignmentInfo.descriptionFile" :alert="alertProps" :docxInfo="docxInfo"></ViewsWordEditor>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, onMounted, nextTick, onBeforeUnmount, reactive, useId } from "vue";
|
|
|
+import { ElMessage, ElLoading } from "element-plus";
|
|
|
+import {useFormatDate} from "@/hooks/useFormatDate";
|
|
|
+import doubaoApis from "@/apis/doubao";
|
|
|
+import fileApis from "@/apis/file"
|
|
|
+import aiSpeakingAssignmentApis from '@/apis/ai_speaking_assignment';
|
|
|
+import mediaApis from "@/apis/media"
|
|
|
+import engagementApis from "@/apis/engagement";
|
|
|
+import Recorder from "js-audio-recorder";
|
|
|
+import ViewsWordEditor from "@/components/ViewsWordEditor/ViewsWordEditor.vue";
|
|
|
+import { VideoPlay, VideoPause,Back, Document} from '@element-plus/icons-vue';
|
|
|
+import VoiceAnalysis from "./component/VoiceAnalysis.vue";
|
|
|
+import { useRoute, useRouter } from 'vue-router';
|
|
|
+import {useStore} from "@/store";
|
|
|
+// 引入头像图片
|
|
|
+import userAvatar from "@/assets/user.png";
|
|
|
+import ryanAvatar from "@/assets/ryan.png"
|
|
|
+import gavinAvatar from "@/assets/gavin.png"
|
|
|
+import lauraAvatar from "@/assets/laura.png"
|
|
|
+import ashleighAvatar from "@/assets/ashleigh.png"
|
|
|
+
|
|
|
+const route = useRoute();
|
|
|
+const router = useRouter();
|
|
|
+const store = useStore();
|
|
|
+const {formatDate} = useFormatDate()
|
|
|
+const userId = Number(store.user.id);
|
|
|
+const assignmentId = Number(route.params.assignmentId)
|
|
|
+const voiceCode = ref(0);
|
|
|
+const voiceOptions = ref([
|
|
|
+ { code: 0, name: "Ryan", audioUrl:"https://eai-files.oss-cn-shanghai.aliyuncs.com/tts_1744024346.wav", isPlaying: false, avatar: ryanAvatar },
|
|
|
+ { code: 1, name: "Gavin", audioUrl:"https://eai-files.oss-cn-shanghai.aliyuncs.com/tts_1744024442.wav",isPlaying:false, avatar:gavinAvatar },
|
|
|
+ { code: 2, name: "Laura", audioUrl:"https://eai-files.oss-cn-shanghai.aliyuncs.com/tts_1744024469.wav", isPlaying:false, avatar:lauraAvatar },
|
|
|
+ { code: 3, name: "Ashleigh", audioUrl:"https://eai-files.oss-cn-shanghai.aliyuncs.com/tts_1744024502.wav", isPlaying:false, avatar:ashleighAvatar },
|
|
|
+]);
|
|
|
+const isLoading = ref(false); // 加载状态
|
|
|
+const aiSpeakingAssignmentInfo = ref({
|
|
|
+ assignmentName: "",
|
|
|
+ description: "",
|
|
|
+ descriptionFile: '',
|
|
|
+ type: "",
|
|
|
+ startTime: "",
|
|
|
+ endTime: "",
|
|
|
+ minDuration:null,
|
|
|
+ minInteraction:null,
|
|
|
+ surveyLinks:[''],
|
|
|
+ surveyDuration:null,
|
|
|
+})
|
|
|
+
|
|
|
+// 存储聊天消息的数组
|
|
|
+const chatMessages = ref([]);
|
|
|
+// 用户输入的问题
|
|
|
+const userInput = ref("");
|
|
|
+
|
|
|
+// 语音识别相关
|
|
|
+let recognition = ref(null);
|
|
|
+const isRecording = ref(false);
|
|
|
+const recorder = ref(null)
|
|
|
+const playTime = ref(0);
|
|
|
+const timer = ref(null);
|
|
|
+const recordSrc = ref(null);
|
|
|
+const hasStopRecording = ref(null);
|
|
|
+const isPlaying = ref(false);
|
|
|
+
|
|
|
+let currentAudio = null; // 当前播放的音频对象
|
|
|
+// 播放/暂停音频
|
|
|
+const toggleAudio = (voice) => {
|
|
|
+ // 如果当前有其他音频正在播放,则暂停
|
|
|
+ if (currentAudio && currentAudio !== voice.audio) {
|
|
|
+ currentAudio.pause();
|
|
|
+ currentAudio.onended = null; // 移除之前的 onended 回调
|
|
|
+ voiceOptions.value.forEach((v) => {
|
|
|
+ if (v.audio === currentAudio) {
|
|
|
+ v.isPlaying = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果当前语音未初始化,则创建 Audio 对象
|
|
|
+ if (!voice.audio) {
|
|
|
+ voice.audio = new Audio(voice.audioUrl);
|
|
|
+ voice.audio.onended = () => {
|
|
|
+ voice.isPlaying = false;
|
|
|
+ currentAudio = null;
|
|
|
+ };
|
|
|
+ }
|
|
|
+ // 如果当前语音正在播放,则暂停
|
|
|
+ if (voice.isPlaying) {
|
|
|
+ voice.audio.pause();
|
|
|
+ voice.isPlaying = false;
|
|
|
+ } else {
|
|
|
+ // 如果当前语音已暂停,则继续播放
|
|
|
+ if (currentAudio === voice.audio && voice.audio.paused) {
|
|
|
+ voice.audio.play();
|
|
|
+ } else {
|
|
|
+ // 否则,开始播放新语音
|
|
|
+ voice.audio.play();
|
|
|
+ currentAudio = voice.audio;
|
|
|
+ }
|
|
|
+ voice.isPlaying = true;
|
|
|
+ }
|
|
|
+};
|
|
|
+const initRecognitionValue = ()=>{
|
|
|
+ recognition.value = null;
|
|
|
+ isRecording.value = false;
|
|
|
+ recorder.value = new Recorder({
|
|
|
+ sampleRate: 16000, // 采样率 16 kHz
|
|
|
+ sampleBits: 16, // 位长 16 bit
|
|
|
+ numChannels: 1, // 单声道
|
|
|
+ })
|
|
|
+ playTime.value = 0;
|
|
|
+ timer.value = null;
|
|
|
+ recordSrc.value = null;
|
|
|
+ hasStopRecording.value = null;
|
|
|
+ isPlaying.value = false;
|
|
|
+ userInput.value = "";
|
|
|
+}
|
|
|
+// 作业状态
|
|
|
+const isHomeworkStarted = ref(false);
|
|
|
+const isHomeworkSaved = ref(false);
|
|
|
+const isHomeworkSubmitted = ref(false);
|
|
|
+const hasHomeworkSaved = ref(false);
|
|
|
+
|
|
|
+// 摄像头录制相关
|
|
|
+const mediaRecorder = ref(null);
|
|
|
+const recordedChunks = ref([]);
|
|
|
+// 暂存的录制数据
|
|
|
+const savedChunks = ref([]);
|
|
|
+const ffmpeg = ref(null);
|
|
|
+// 初始化语音识别
|
|
|
+// 初始化语音识别
|
|
|
+const initRecognition = () => {
|
|
|
+ const SpeechRecognition =
|
|
|
+ window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
|
+ if (SpeechRecognition) {
|
|
|
+ recognition.value = new SpeechRecognition();
|
|
|
+ recognition.value.lang = "en-US";
|
|
|
+ recognition.value.continuous = true;
|
|
|
+ recognition.value.interimResults = true; // 实时获取识别结果
|
|
|
+ // 语音识别开始事件
|
|
|
+ recognition.value.onstart = () => {
|
|
|
+ console.log("语音识别已启动");
|
|
|
+ };
|
|
|
+
|
|
|
+ // 语音识别结束事件
|
|
|
+ recognition.value.onend = () => {
|
|
|
+ console.log("语音识别已结束");
|
|
|
+ };
|
|
|
+
|
|
|
+ // 语音识别错误事件
|
|
|
+ recognition.value.onerror = (event) => {
|
|
|
+ console.error("语音识别错误:", event.error);
|
|
|
+ };
|
|
|
+ recognition.value.onresult = (event) => {
|
|
|
+ let transcript = "";
|
|
|
+ for (let i = event.resultIndex; i < event.results.length; ++i) {
|
|
|
+ if (event.results[i].isFinal) {
|
|
|
+ transcript += event.results[i][0].transcript;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ userInput.value += transcript;
|
|
|
+ };
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+recorder.value = new Recorder({
|
|
|
+ sampleRate: 16000, // 采样率 16 kHz
|
|
|
+ sampleBits: 16, // 位长 16 bit
|
|
|
+ numChannels: 1, // 单声道
|
|
|
+});
|
|
|
+// 开始录音
|
|
|
+const startRecording = async () => {
|
|
|
+ // 暂停播放
|
|
|
+ if (isPlaying.value) {
|
|
|
+ togglePlayback();
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await Recorder.getPermission();
|
|
|
+ if (hasStopRecording.value) {
|
|
|
+ recorder.value.resume();
|
|
|
+ if (recognition.value) {
|
|
|
+ recognition.value.start();
|
|
|
+ }
|
|
|
+ ElMessage.success("恢复录音");
|
|
|
+ } else {
|
|
|
+ ElMessage.success("开始录音");
|
|
|
+ recorder.value.start(); // 开始录音
|
|
|
+ initRecognition();
|
|
|
+ recognition.value.start();
|
|
|
+ }
|
|
|
+ isRecording.value = true;
|
|
|
+ console.log(isRecording.value)
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.info("请先允许该网页使用麦克风");
|
|
|
+ console.log(`${error.name} : ${error.message}`);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 暂停录音
|
|
|
+const stopRecording = () => {
|
|
|
+ if(!isRecording.value){
|
|
|
+ ElMessage("暂未开始录音")
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ recorder.value.pause(); // 暂停录音
|
|
|
+ if (recognition.value) {
|
|
|
+ recognition.value.stop();
|
|
|
+ }
|
|
|
+ hasStopRecording.value = true;
|
|
|
+ isRecording.value = false
|
|
|
+};
|
|
|
+
|
|
|
+// 重新录音录音、、
|
|
|
+const resetRecording = async() => {
|
|
|
+ // 暂停播放
|
|
|
+ if (isPlaying.value) {
|
|
|
+ togglePlayback();
|
|
|
+ }
|
|
|
+
|
|
|
+ ElMessage.success("重置录音");
|
|
|
+ initRecognitionValue()
|
|
|
+ await nextTick(); // 等待 DOM 更新
|
|
|
+ hasStopRecording.value = false;
|
|
|
+ hasStartPlay.value = false;
|
|
|
+ playTime.value = 0;
|
|
|
+ recorder.value.destroy();
|
|
|
+ timer.value = null;
|
|
|
+};
|
|
|
+
|
|
|
+const hasStartPlay = ref(false);
|
|
|
+const togglePlayback = () => {
|
|
|
+ if (!isPlaying.value) {
|
|
|
+ if (hasStartPlay.value) {
|
|
|
+ recorder.value.resumePlay();
|
|
|
+ } else {
|
|
|
+ recorder.value.play();
|
|
|
+ hasStartPlay.value = true;
|
|
|
+ }
|
|
|
+ // 播放时长
|
|
|
+ timer.value = setInterval(() => {
|
|
|
+ try {
|
|
|
+ playTime.value = recorder.value.getPlayTime();
|
|
|
+ // 判断是否播放结束
|
|
|
+ if (recorder.value && playTime.value >= recorder.value.duration) {
|
|
|
+ clearInterval(timer.value);
|
|
|
+ isPlaying.value = false;
|
|
|
+ hasStartPlay.value = false;
|
|
|
+ playTime.value = 0;
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ clearInterval(timer.value);
|
|
|
+ timer.value = null;
|
|
|
+ }
|
|
|
+ }, 100);
|
|
|
+ isPlaying.value = true;
|
|
|
+ } else {
|
|
|
+ recorder.value.pausePlay();
|
|
|
+ playTime.value = recorder.value.getPlayTime();
|
|
|
+ clearInterval(timer.value);
|
|
|
+ timer.value = null;
|
|
|
+ isPlaying.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const voiceAnalysis = ref(null);
|
|
|
+const sendMessage = () => {
|
|
|
+ if (userInput.value.trim() === '') return;
|
|
|
+ // 添加"正在思考中"提示
|
|
|
+ const thinkingMessage = {
|
|
|
+ sender: 'ai',
|
|
|
+ text: 'Thinking...',
|
|
|
+ isThinking: true, // 添加标记表示这是思考中的临时消息
|
|
|
+ isPlaying: false,
|
|
|
+ voiceCode: voiceCode.value
|
|
|
+ };
|
|
|
+ // 上传录制的语音文件
|
|
|
+ if (recorder.value && recorder.value.getWAVBlob) {
|
|
|
+ recorder.value.stop(); // 确保录音已停止
|
|
|
+ let wavBlob = recorder.value.getWAVBlob();
|
|
|
+ let fileName = `${userId}_${assignmentId}_${Date.now()}`;
|
|
|
+ let renameFile = new File([wavBlob], fileName + '.wav', { type: 'audio/wav' });
|
|
|
+
|
|
|
+ // 调用 API 上传文件
|
|
|
+ fileApis.uploadFile(renameFile)
|
|
|
+ .then((uploadRes) => {
|
|
|
+ console.log('文件上传成功:', uploadRes.data.data.split('?')[0]);
|
|
|
+ chatMessages.value.push({ sender: 'user', text: userInput.value, audioUrl: uploadRes.data.data, isPlaying: false });
|
|
|
+ ElMessage.success('语音上传成功');
|
|
|
+ chatMessages.value.push(thinkingMessage);
|
|
|
+ scrollToBottom();
|
|
|
+ // 发送消息给 AI
|
|
|
+ doubaoApis.sendMessage(userId, assignmentId, userInput.value, uploadRes.data.data.split('?')[0], recorder.value.duration, voiceCode.value)
|
|
|
+ .then((res) => {
|
|
|
+ if (res.data.code === 200) {
|
|
|
+ // 移除"正在思考中"消息
|
|
|
+ chatMessages.value = chatMessages.value.filter(msg => !msg.isThinking);
|
|
|
+ // 添加 AI 回复到聊天记录
|
|
|
+ const aiMessage = { sender: 'ai', text: res.data.data.message, audioUrl: res.data.data.audioUrl, voiceCode: voiceCode.value, isPlaying: false };
|
|
|
+ chatMessages.value.push(aiMessage);
|
|
|
+ // 自动播放 AI 回复的音频
|
|
|
+ autoPlay(aiMessage);
|
|
|
+ mediaApis.getAudioSentiment(userId, assignmentId, -1)
|
|
|
+ .then(res => {
|
|
|
+ const emotion = res.data.data
|
|
|
+ mediaApis.getAudioScore(userId, assignmentId,-1)
|
|
|
+ .then(score => {
|
|
|
+ console.log(score.data.data)
|
|
|
+ const data = score.data.data
|
|
|
+ voiceAnalysis.value.addAnalysisResult({
|
|
|
+ content:data.content,
|
|
|
+ accuracyScore: data.accuracyScore,
|
|
|
+ fluencyScore: data.fluencyScore,
|
|
|
+ integrityScore: data.integrityScore,
|
|
|
+ standardScore: data.standardScore,
|
|
|
+ totalScore: data.totalScore,
|
|
|
+ wordScoreList: data.wordScoreList,
|
|
|
+ emotion:emotion
|
|
|
+ });
|
|
|
+ })
|
|
|
+ }).catch(err =>{
|
|
|
+ ElMessage('分析出错啦')
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ // 移除"正在思考中"消息
|
|
|
+ chatMessages.value = chatMessages.value.filter(msg => !msg.isThinking);
|
|
|
+ chatMessages.value.push({ sender: 'ai', text: '服务器繁忙,请稍后再试...', isPlaying: false });
|
|
|
+ }
|
|
|
+ scrollToBottom()
|
|
|
+ initRecognitionValue();
|
|
|
+ })
|
|
|
+ .catch((err) => {
|
|
|
+ // 移除"正在思考中"消息
|
|
|
+ chatMessages.value = chatMessages.value.filter(msg => !msg.isThinking);
|
|
|
+ chatMessages.value.push({ sender: 'ai', text: '服务器繁忙,请稍后再试...', isPlaying: false });
|
|
|
+ });
|
|
|
+ })
|
|
|
+ .catch((uploadErr) => {
|
|
|
+ console.error('文件上传失败:', uploadErr);
|
|
|
+ ElMessage.error('文件上传失败');
|
|
|
+ });
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+
|
|
|
+let audio = null;
|
|
|
+// 播放音频
|
|
|
+const autoPlay = (message) => {
|
|
|
+ if (message.audioUrl) {
|
|
|
+ // 如果当前有音频正在播放,则暂停
|
|
|
+ if (audio && !audio.paused) {
|
|
|
+ audio.pause();
|
|
|
+ audio.onended = null; // 移除之前的 onended 回调
|
|
|
+ }
|
|
|
+ // 创建新的音频对象并播放
|
|
|
+ audio = new Audio(message.audioUrl);
|
|
|
+ audio.play();
|
|
|
+ message.isPlaying = true;
|
|
|
+ // 播放结束后更新状态
|
|
|
+ audio.onended = () => {
|
|
|
+ message.isPlaying = false;
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ ElMessage.warning('该消息没有语音文件');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const playAudio = (message) => {
|
|
|
+ if (message.audioUrl) {
|
|
|
+ if (message.isPlaying) {
|
|
|
+ // 如果正在播放,则暂停
|
|
|
+ audio.pause();
|
|
|
+ message.isPlaying = false;
|
|
|
+ } else {
|
|
|
+ if (audio && !audio.paused) {
|
|
|
+ audio.pause();
|
|
|
+ audio.onended = null; // 移除之前的 onended 回调
|
|
|
+ }
|
|
|
+ // 如果未播放,则开始播放
|
|
|
+ audio = new Audio(message.audioUrl);
|
|
|
+ audio.play();
|
|
|
+ message.isPlaying = true;
|
|
|
+ audio.onended = () => {
|
|
|
+ message.isPlaying = false; // 播放结束后更新状态
|
|
|
+ };
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ ElMessage.warning('该消息没有语音文件');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 返回按钮点击事件处理函数
|
|
|
+const goBack = () => {
|
|
|
+ // 这里可以添加具体的返回逻辑,比如返回上一个页面
|
|
|
+ //http://127.0.0.1:4000/home/homeworkDetail/3054
|
|
|
+ router.go(-1)
|
|
|
+};
|
|
|
+
|
|
|
+// 开始计时的变量
|
|
|
+let homeworkStartTime = null;
|
|
|
+const cameraPreview = ref(null);
|
|
|
+const recordingDuration = ref(0); // 录制时长(秒)
|
|
|
+let recordingTimer = null; // 录制计时器
|
|
|
+// 开始作业
|
|
|
+const startHomework = async () => {
|
|
|
+ scrollToBottom();
|
|
|
+ if(isHomeworkSubmitted.value){
|
|
|
+ router.push({
|
|
|
+ path: `/home/assignment/${assignmentId}/airecord`,
|
|
|
+ query:{
|
|
|
+ userId: userId,
|
|
|
+ assignmentId: assignmentId,
|
|
|
+ assignmentName: aiSpeakingAssignmentInfo.value.assignmentName
|
|
|
+ }});
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (isHomeworkStarted.value) {
|
|
|
+ ElMessage.warning("作业进行中");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
+ video: true,
|
|
|
+ audio: true,
|
|
|
+ });
|
|
|
+ console.log("摄像头媒体流获取成功:", stream); // 添加日志
|
|
|
+ isHomeworkStarted.value = true;
|
|
|
+ isHomeworkSaved.value = false;
|
|
|
+ homeworkStartTime = Date.now();
|
|
|
+ doubaoApis.getRestricData(userId, assignmentId)
|
|
|
+ .then(res =>{
|
|
|
+ recordingDuration.value = +Number(res.data.data.recordTime).toFixed(0);
|
|
|
+ // 启动录制时长计时器
|
|
|
+ recordingTimer = setInterval(() => {
|
|
|
+ recordingDuration.value += 1;
|
|
|
+ }, 1000);
|
|
|
+ // 将摄像头画面绑定到 video 元素
|
|
|
+ if (cameraPreview.value) {
|
|
|
+ cameraPreview.value.srcObject = stream;
|
|
|
+ console.log("摄像头画面已绑定到 video 元素:", cameraPreview.value.srcObject); // 添加日志
|
|
|
+ }
|
|
|
+ mediaRecorder.value = new MediaRecorder(stream);
|
|
|
+ mediaRecorder.value.ondataavailable = (event) => {
|
|
|
+ if (event.data.size > 0) {
|
|
|
+ recordedChunks.value.push(event.data);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ mediaRecorder.value.start();
|
|
|
+ })
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error("获取摄像头和麦克风媒体流失败:", error);
|
|
|
+ ElMessage.error('获取摄像头和麦克风媒体流失败')
|
|
|
+ isHomeworkStarted.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 暂存作业
|
|
|
+const saveHomework = async () => {
|
|
|
+ if (!isHomeworkStarted.value) {
|
|
|
+ ElMessage.warning("作业未开始,无法暂存");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (isHomeworkSubmitted.value) {
|
|
|
+ ElMessage.warning("作业已提交,无法暂存");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 显示加载状态
|
|
|
+ const loadingInstance = ElLoading.service({
|
|
|
+ lock: true,
|
|
|
+ text: '正在暂存作业,请稍候...',
|
|
|
+ background: 'rgba(0, 0, 0, 0.1)'
|
|
|
+ });
|
|
|
+ if (isHomeworkStarted.value && !isHomeworkSubmitted.value) {
|
|
|
+ if (mediaRecorder.value && mediaRecorder.value.state === "recording") {
|
|
|
+ mediaRecorder.value.stop();
|
|
|
+ mediaRecorder.value.onstop =async () => {
|
|
|
+ // 记录结束时间
|
|
|
+ const homeworkEndTime = Date.now();
|
|
|
+ // 计算耗时(单位:秒)
|
|
|
+ const duration = (homeworkEndTime - homeworkStartTime) / 1000;
|
|
|
+ const videoBlob = new Blob([...recordedChunks.value], { type: "video/webm" });
|
|
|
+ const fileName = `user_${userId}_${assignmentId}_${Date.now()}`;
|
|
|
+ let renameFile =new File([videoBlob], fileName +'.webm', { type: 'video/webm' })
|
|
|
+ try {
|
|
|
+ fileApis
|
|
|
+ .uploadFile(renameFile)
|
|
|
+ .then(res =>{
|
|
|
+ recordedChunks.value = [];
|
|
|
+ isHomeworkSaved.value = true;
|
|
|
+ hasHomeworkSaved.value = true;
|
|
|
+ isHomeworkStarted.value = false;
|
|
|
+ ElMessage.success("视频片段暂存成功");
|
|
|
+ doubaoApis
|
|
|
+ .saveSigment(userId, assignmentId, res.data.data, duration)
|
|
|
+ .then(url =>{
|
|
|
+ console.log(url.data.data)
|
|
|
+ savedChunks.value = url.data.data;
|
|
|
+ loadingInstance.close();
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ ElMessage.error('保存出错啦,请重新尝试')
|
|
|
+ loadingInstance.close();
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error("视频片段上传失败:", error);
|
|
|
+ ElMessage.error("视频片段上传失败");
|
|
|
+ loadingInstance.close();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ ElMessage.error('状态出错啦')
|
|
|
+ loadingInstance.close();
|
|
|
+ }
|
|
|
+ if (mediaRecorder.value && mediaRecorder.value.stream) {
|
|
|
+ mediaRecorder.value.stream.getTracks().forEach((track) => track.stop());
|
|
|
+ }
|
|
|
+ // 停止录制时长计时器
|
|
|
+ if (recordingTimer) {
|
|
|
+ clearInterval(recordingTimer);
|
|
|
+ recordingTimer = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清除摄像头画面
|
|
|
+ if (cameraPreview.value) {
|
|
|
+ cameraPreview.value.srcObject = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ initRecognitionValue()
|
|
|
+ console.log(mediaRecorder.value.state);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 文档预览相关
|
|
|
+const open = ref(false)
|
|
|
+const alertProps = reactive({
|
|
|
+ open: open.value,
|
|
|
+ onOk: () => (open.value = false),
|
|
|
+ onCancel: () => (open.value = false),
|
|
|
+});
|
|
|
+
|
|
|
+const docxInfo = reactive({
|
|
|
+ docxUrl: '',
|
|
|
+ docxTitle: '',
|
|
|
+});
|
|
|
+
|
|
|
+// 打开文档编辑器
|
|
|
+const openEditor = (url, title) => {
|
|
|
+ open.value = true;
|
|
|
+ docxInfo.docxUrl = url;
|
|
|
+ docxInfo.docxTitle = title;
|
|
|
+ Object.assign(alertProps, {
|
|
|
+ open: open.value,
|
|
|
+ onCancel: () => {
|
|
|
+ open.value = false
|
|
|
+ Object.assign(alertProps,{
|
|
|
+ open: open.value,
|
|
|
+ onOk: () => (open.value = false),
|
|
|
+ onCancel: () => (open.value = false),
|
|
|
+ })
|
|
|
+ Object.assign(docxInfo, {
|
|
|
+ docxUrl: url,
|
|
|
+ docxTitle: title,
|
|
|
+ })
|
|
|
+ },
|
|
|
+ })
|
|
|
+ Object.assign(docxInfo, {
|
|
|
+ docxUrl: url,
|
|
|
+ docxTitle: title,
|
|
|
+ })
|
|
|
+};
|
|
|
+
|
|
|
+// 格式化录制时长
|
|
|
+const formatDuration = (duration) => {
|
|
|
+ const minutes = Math.floor(duration / 60);
|
|
|
+ const seconds = duration % 60;
|
|
|
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
|
+};
|
|
|
+
|
|
|
+// 重试作业
|
|
|
+const resetHomework = async() => {
|
|
|
+ if (isHomeworkSubmitted.value) {
|
|
|
+ ElMessage.warning("作业已提交,无法重试");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (isHomeworkStarted.value) {
|
|
|
+ if (mediaRecorder.value && mediaRecorder.value.state === "recording") {
|
|
|
+ mediaRecorder.value.stop();
|
|
|
+ }
|
|
|
+ // 停止录制时长计时器
|
|
|
+ if (recordingTimer) {
|
|
|
+ clearInterval(recordingTimer);
|
|
|
+ recordingTimer = null;
|
|
|
+ }
|
|
|
+ // 清除摄像头画面
|
|
|
+ if (cameraPreview.value) {
|
|
|
+ cameraPreview.value.srcObject = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 显示加载状态
|
|
|
+ const loadingInstance = ElLoading.service({
|
|
|
+ lock: true,
|
|
|
+ text: '正在重置作业,请稍候...',
|
|
|
+ background: 'rgba(0, 0, 0, 0.2)'
|
|
|
+ });
|
|
|
+ doubaoApis.deleteChatHistory(userId, assignmentId, aiSpeakingAssignmentInfo.value.assignmentName, voiceCode.value )
|
|
|
+ .then(res =>{
|
|
|
+ if(res.data.code == 200){
|
|
|
+ recordedChunks.value = [];
|
|
|
+ savedChunks.value = [];
|
|
|
+ chatMessages.value = [];
|
|
|
+ isHomeworkSaved.value = false;
|
|
|
+ isHomeworkStarted.value = false;
|
|
|
+ initRecognitionValue()
|
|
|
+ ElMessage.success("作业内容已清空");
|
|
|
+ getHistory()
|
|
|
+
|
|
|
+ } else {
|
|
|
+ ElMessage.error("清除失败,请稍后尝试")
|
|
|
+ }
|
|
|
+ // 隐藏加载状态
|
|
|
+ loadingInstance.close();
|
|
|
+
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ ElMessage.error("清除失败,请稍后尝试");
|
|
|
+ // 隐藏加载状态
|
|
|
+ loadingInstance.close();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 提交作业
|
|
|
+const submitHomework = async () => {
|
|
|
+ if (isHomeworkSubmitted.value) {
|
|
|
+ ElMessage.warning("作业已提交,请勿重复提交");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if(isHomeworkStarted.value){
|
|
|
+ ElMessage.warning("作业正在进行中,请保存后再提交")
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ doubaoApis.getRestricData(userId, assignmentId)
|
|
|
+ .then(res => {
|
|
|
+ console.log(res.data.data)
|
|
|
+ if(Number(res.data.data.chatTimes) < aiSpeakingAssignmentInfo.value.minInteraction){
|
|
|
+ ElMessage.info(`交流次数小于${aiSpeakingAssignmentInfo.value.minInteraction}次,无法提交`);
|
|
|
+ return;
|
|
|
+ } else if(Number(res.data.data.recordTime) / 60 < aiSpeakingAssignmentInfo.value.minDuration){
|
|
|
+ ElMessage.info(`交流时长小于${aiSpeakingAssignmentInfo.value.minDuration}分钟,无法提交`);
|
|
|
+ return;
|
|
|
+ } else {
|
|
|
+ // 显示加载状态
|
|
|
+ const loadingInstance = ElLoading.service({
|
|
|
+ lock: true,
|
|
|
+ text: '正在提交作业,请稍候...',
|
|
|
+ background: 'rgba(0, 0, 0, 0.2)'
|
|
|
+ });
|
|
|
+ engagementApis.engagementSubmit(userId, assignmentId)
|
|
|
+ .then(res => {
|
|
|
+ doubaoApis.endChat(userId, assignmentId)
|
|
|
+ .then(res => {
|
|
|
+ isHomeworkSubmitted.value = true;
|
|
|
+ initRecognitionValue()
|
|
|
+ getHistory()
|
|
|
+ ElMessage.success("作业已成功提交");
|
|
|
+ // 隐藏加载状态
|
|
|
+ loadingInstance.close();
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ ElMessage.error('提交失败,请稍后再试'+ err)
|
|
|
+ // 隐藏加载状态
|
|
|
+ loadingInstance.close();
|
|
|
+ })
|
|
|
+ })
|
|
|
+ .catch(err=> {
|
|
|
+ ElMessage.error('提交失败,请稍后再试'+ err)
|
|
|
+ // 隐藏加载状态
|
|
|
+ loadingInstance.close();
|
|
|
+ })
|
|
|
+
|
|
|
+ // 关闭摄像头和麦克风
|
|
|
+ if (mediaRecorder.value && mediaRecorder.value.stream) {
|
|
|
+ mediaRecorder.value.stream.getTracks().forEach((track) => track.stop());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+};
|
|
|
+
|
|
|
+// 根据作业状态返回对应的文本
|
|
|
+const getStatusText = () => {
|
|
|
+ if (isHomeworkSubmitted.value) {
|
|
|
+ return "已提交";
|
|
|
+ } else if (!isHomeworkStarted.value) {
|
|
|
+ return hasHomeworkSaved.value ? "已暂存" : "待开始中";
|
|
|
+ } else if (isHomeworkSaved.value) {
|
|
|
+ return "暂存中";
|
|
|
+ } else {
|
|
|
+ return "进行中";
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 格式化时间戳为 "月-日 时:分:秒"
|
|
|
+const formatTimestamp = (timestamp) => {
|
|
|
+ if(timestamp == null){
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+ const date = new Date(timestamp);
|
|
|
+
|
|
|
+ // 获取月、日、时、分、秒
|
|
|
+ const month = String(date.getMonth() + 1).padStart(2, '0'); // 月份从 0 开始,需要 +1
|
|
|
+ const day = String(date.getDate()).padStart(2, '0');
|
|
|
+ const hours = String(date.getHours()).padStart(2, '0');
|
|
|
+ const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
|
+ const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
|
+
|
|
|
+ // 返回格式化后的时间字符串
|
|
|
+ return `${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
|
+};
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+const voiceInctanceKey = ref(0)
|
|
|
+const getHistory = () =>{
|
|
|
+ doubaoApis
|
|
|
+ .getRecord(userId, assignmentId)
|
|
|
+ .then(res =>{
|
|
|
+ console.log(res.data.data)
|
|
|
+ if(res.data.data.audioSigment && res.data.data.audioSigment.length == 0 ){
|
|
|
+ hasHomeworkSaved.value = false;
|
|
|
+ } else{
|
|
|
+ hasHomeworkSaved.value = true;
|
|
|
+ }
|
|
|
+ isHomeworkSubmitted.value = res.data.data.hasCommitted || false;
|
|
|
+ if(res.data.data.voiceCode != null){
|
|
|
+ voiceCode.value = res.data.data.voiceCode;
|
|
|
+ }
|
|
|
+ voiceInctanceKey.value += 1;
|
|
|
+ })
|
|
|
+ doubaoApis
|
|
|
+ .startChat(userId, assignmentId, aiSpeakingAssignmentInfo.value.assignmentName, voiceCode.value)
|
|
|
+ .then((res) => {
|
|
|
+ chatMessages.value = [];
|
|
|
+ res.data.data.forEach(entry => {
|
|
|
+ if (entry.role === 'user' &&!entry.content.startsWith('请你扮演')) {
|
|
|
+ chatMessages.value.push({ sender: "user", text: entry.content, isPlaying: false, audioUrl: entry.audioUrl, time: entry.timestamp});
|
|
|
+ } else if (entry.role === 'assistant') {
|
|
|
+ chatMessages.value.push({ sender: "ai", text: entry.content, isPlaying: false, audioUrl: entry.audioUrl, time: entry.timestamp, voiceCode: entry.entryVoiceCode });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ })
|
|
|
+ .catch((err) => {
|
|
|
+ console.log(err);
|
|
|
+ });
|
|
|
+}
|
|
|
+const chatContainerRef = ref(null); // 绑定 chat-messages 容器
|
|
|
+// 滚动到底部方法
|
|
|
+const scrollToBottom = () => {
|
|
|
+ nextTick(() => {
|
|
|
+ if (chatContainerRef.value) {
|
|
|
+ chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleMessageClick = (message, index) => {
|
|
|
+ console.log(message, index);
|
|
|
+ console.log(voiceAnalysis.value.voiceAnalysisContainerRef)
|
|
|
+ if (message.sender === 'user' && voiceAnalysis.value.voiceAnalysisContainerRef) {
|
|
|
+ const analysisResultElements = voiceAnalysis.value.voiceAnalysisContainerRef.querySelectorAll('.analysis-result');
|
|
|
+ if (analysisResultElements.length > (index -1) / 2) {
|
|
|
+ const targetElement = analysisResultElements[(index - 1) / 2];
|
|
|
+ nextTick(() => {
|
|
|
+ targetElement.scrollIntoView({
|
|
|
+ behavior: 'smooth',
|
|
|
+ block: 'start'
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ // 销毁录制时长计时器
|
|
|
+ if (recordingTimer) {
|
|
|
+ clearInterval(recordingTimer);
|
|
|
+ recordingTimer = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mediaRecorder.value && mediaRecorder.value.stream) {
|
|
|
+ mediaRecorder.value.stream.getTracks().forEach((track) => track.stop());
|
|
|
+ }
|
|
|
+ if (cameraPreview.value) {
|
|
|
+ cameraPreview.value.srcObject = null;
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ aiSpeakingAssignmentApis.getAiSpeakingAssignmentById(assignmentId)
|
|
|
+ .then(res => {
|
|
|
+ aiSpeakingAssignmentInfo.value = res.data.data;
|
|
|
+ getHistory();
|
|
|
+ })
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+
|
|
|
+.app {
|
|
|
+ font-family: Avenir, Helvetica, Arial, sans-serif;
|
|
|
+ -webkit-font-smoothing: antialiased;
|
|
|
+ -moz-osx-font-smoothing: grayscale;
|
|
|
+ color: #2c3e50;
|
|
|
+ margin: 0;
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.main-container {
|
|
|
+ display: flex;
|
|
|
+ height: 820px;
|
|
|
+ width: 1600px;
|
|
|
+ margin-top:2%;
|
|
|
+ margin-left: 7%;
|
|
|
+ border-radius: 10px;
|
|
|
+ overflow: hidden;
|
|
|
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+.info-container {
|
|
|
+ width: 23%;
|
|
|
+ padding: 30px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: space-between;
|
|
|
+ text-align: left;
|
|
|
+ background-color: #e1f5fe; /* 信息区背景色 */
|
|
|
+}
|
|
|
+
|
|
|
+.back-button{
|
|
|
+ font-size: 35px;
|
|
|
+ margin-right: 200px;
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+.main-button{
|
|
|
+ width:80px;
|
|
|
+ display: inline-block;
|
|
|
+ margin-left:-10px;
|
|
|
+ font-size: 13px;
|
|
|
+ height: 30px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-name {
|
|
|
+ font-size: 26px;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ color: #0277bd;
|
|
|
+}
|
|
|
+
|
|
|
+.task-time {
|
|
|
+ color: #78909c;
|
|
|
+ font-size: 14px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-description {
|
|
|
+ margin-bottom: 12px;
|
|
|
+ color: #546e7a;
|
|
|
+}
|
|
|
+
|
|
|
+.task-file,
|
|
|
+.task-attachments {
|
|
|
+ margin-bottom: 6px;
|
|
|
+ color: #546e7a;
|
|
|
+}
|
|
|
+
|
|
|
+.card {
|
|
|
+ background-color: rgba(255, 255, 255, 0.4);
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
+ padding: 10px;
|
|
|
+ max-width: 350px;
|
|
|
+ font-family: Arial, sans-serif;
|
|
|
+ }
|
|
|
+ .card-header {
|
|
|
+ border-bottom: 1px solid #eee;
|
|
|
+ padding-bottom: 8px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ }
|
|
|
+ .card-title {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #546e7a;
|
|
|
+ }
|
|
|
+ .data-item {
|
|
|
+ margin-bottom: 10px;
|
|
|
+ }
|
|
|
+ .data-label {
|
|
|
+ color: #546e7a;
|
|
|
+ margin-right: 8px;
|
|
|
+ font-weight: bold;
|
|
|
+ }
|
|
|
+ .data-value {
|
|
|
+ color: #777;
|
|
|
+ }
|
|
|
+
|
|
|
+.homework-button-group {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+ margin-top: auto;
|
|
|
+ margin-left: 40px;
|
|
|
+ border:none;
|
|
|
+}
|
|
|
+
|
|
|
+.start-homework-button {
|
|
|
+ margin-left: 10px;
|
|
|
+ background-color: #90caf9;
|
|
|
+ color: #0d47a1;
|
|
|
+ width: 100px;
|
|
|
+ border:none;
|
|
|
+}
|
|
|
+
|
|
|
+.save-homework-button {
|
|
|
+ background-color: #fff59d;
|
|
|
+ color: #f57f17;
|
|
|
+ width: 100px;
|
|
|
+ border:none;
|
|
|
+}
|
|
|
+
|
|
|
+.reset-homework-button {
|
|
|
+ background-color: #ffcdd2;
|
|
|
+ color: #b71c1c;
|
|
|
+ width: 100px;
|
|
|
+ border:none;
|
|
|
+}
|
|
|
+
|
|
|
+.submit-homework-button {
|
|
|
+ background-color: #a5d6a7;
|
|
|
+ color: #1b5e20;
|
|
|
+ width: 100px;
|
|
|
+ border:none;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-container-wrapper {
|
|
|
+ width: 75%;
|
|
|
+ min-width: 500px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100%;
|
|
|
+ position: relative;
|
|
|
+ background-color: #fafafa; /* 问答区背景色 */
|
|
|
+}
|
|
|
+
|
|
|
+.voice-analysis-container {
|
|
|
+ display: flex;
|
|
|
+ box-sizing: border-box;
|
|
|
+ flex-direction: column;
|
|
|
+ position: relative;
|
|
|
+ width: 40%;
|
|
|
+ background-color: #e1f5fe; /* 信息区背景色 */
|
|
|
+}
|
|
|
+
|
|
|
+.ai-message.is-thinking .message-text {
|
|
|
+ color: #999;
|
|
|
+ font-style: italic;
|
|
|
+}
|
|
|
+
|
|
|
+.card-wrapper {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.card-header {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ margin: 0 auto;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.status-label-wrapper {
|
|
|
+ display: flex;
|
|
|
+ padding: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.status-label-wrapper span {
|
|
|
+ padding: 4px 45px;
|
|
|
+ border-radius: 4px;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.status-default {
|
|
|
+ background-color: #e0e0e0;
|
|
|
+ color: #757575;
|
|
|
+}
|
|
|
+
|
|
|
+.status-started {
|
|
|
+ background-color: #90caf9;
|
|
|
+ color: #0d47a1;
|
|
|
+}
|
|
|
+
|
|
|
+.status-saved {
|
|
|
+ background-color: #fff59d;
|
|
|
+ color: #f57f17;
|
|
|
+}
|
|
|
+
|
|
|
+.status-submitted {
|
|
|
+ background-color: #a5d6a7;
|
|
|
+ color: #1b5e20;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-container {
|
|
|
+ box-sizing: border-box;
|
|
|
+ display: flex;
|
|
|
+ height: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+ flex-direction: column;
|
|
|
+ border-bottom: 1px solid #e0e0e0;
|
|
|
+}
|
|
|
+
|
|
|
+.card-header-fixed {
|
|
|
+ position: sticky;
|
|
|
+ top: 0;
|
|
|
+ z-index: 10;
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+.chat-messages {
|
|
|
+ flex: 1;
|
|
|
+ padding: 10px;
|
|
|
+ overflow-y: auto;
|
|
|
+ max-height: calc(100vh - 300px); /* 关键:确保它不会超出屏幕 */
|
|
|
+ scrollbar-width: none; /* Firefox */
|
|
|
+}
|
|
|
+
|
|
|
+.chat-messages::-webkit-scrollbar {
|
|
|
+ display: none; /* Chrome, Safari */
|
|
|
+}
|
|
|
+
|
|
|
+.user-message {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-start;
|
|
|
+ text-align: left;
|
|
|
+ margin: 10px;
|
|
|
+ flex-direction: row-reverse;
|
|
|
+ margin-bottom: 30px;
|
|
|
+}
|
|
|
+
|
|
|
+.ai-message {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-start;
|
|
|
+ text-align: left;
|
|
|
+ margin: 10px;
|
|
|
+ margin-bottom: 30px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column; /* 竖直排列 */
|
|
|
+ max-width: 90%;
|
|
|
+}
|
|
|
+
|
|
|
+.message-text {
|
|
|
+ background-color: #e0f7fa;
|
|
|
+ padding: 6px;
|
|
|
+ border-radius: 5px;
|
|
|
+ margin: 0 5px;
|
|
|
+ align-self: flex-start;
|
|
|
+ color: #006064;
|
|
|
+}
|
|
|
+
|
|
|
+.message-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+ margin-top: 4px; /* 调整时间戳与消息内容的间距 */
|
|
|
+ margin-left: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.ai-message .message-text {
|
|
|
+ background-color: #f1f8e9;
|
|
|
+ color: #33691e;
|
|
|
+}
|
|
|
+
|
|
|
+.input-container {
|
|
|
+ display: flex;
|
|
|
+ padding: 10px;
|
|
|
+ position: sticky;
|
|
|
+ bottom: 0;
|
|
|
+ background-color: #fff;
|
|
|
+ z-index: 1;
|
|
|
+ border-top: 1px solid #e0e0e0;
|
|
|
+}
|
|
|
+
|
|
|
+.input-container input {
|
|
|
+ flex: 1;
|
|
|
+ padding: 6px;
|
|
|
+ border: 1px solid #bdbdbd;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.input-container el-button {
|
|
|
+ margin-left: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 开始按钮样式 */
|
|
|
+.start-button {
|
|
|
+ margin-left: 10px;
|
|
|
+ background-color: #90caf9;
|
|
|
+ color: #0d47a1;
|
|
|
+ border: none;
|
|
|
+}
|
|
|
+
|
|
|
+/* 结束按钮样式 */
|
|
|
+.stop-button {
|
|
|
+ background-color: #ffcdd2;
|
|
|
+ color: #b71c1c;
|
|
|
+ border:none;
|
|
|
+}
|
|
|
+
|
|
|
+/* 重新按钮样式 */
|
|
|
+.reset-button {
|
|
|
+ background-color: #fff59d;
|
|
|
+ color: #f57f17;
|
|
|
+ border:none;
|
|
|
+}
|
|
|
+
|
|
|
+/* 发送按钮样式 */
|
|
|
+.send-button {
|
|
|
+ background-color: #a5d6a7;
|
|
|
+ color: #1b5e20;
|
|
|
+ border:none;
|
|
|
+}
|
|
|
+
|
|
|
+.recording-bar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+progress {
|
|
|
+ margin-left: 20px;
|
|
|
+ /* 设置宽度和高度 */
|
|
|
+ width: 200px;
|
|
|
+ height: 15px;
|
|
|
+ /* 设置边框和圆角 */
|
|
|
+ border: none;
|
|
|
+ border-radius: 10px;
|
|
|
+ /* 隐藏溢出部分 */
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.wechat-audio-button {
|
|
|
+ margin-left: 20px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ background-color: #90caf9;
|
|
|
+ border: none;
|
|
|
+ border-radius: 15px;
|
|
|
+ padding: 8px 15px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background-color 0.3s;
|
|
|
+}
|
|
|
+
|
|
|
+.wechat-audio-button:hover {
|
|
|
+ background-color: #e0e0e0;
|
|
|
+}
|
|
|
+
|
|
|
+.wechat-audio-button .icon {
|
|
|
+ display: inline-block;
|
|
|
+ width: 12px;
|
|
|
+ height: 12px;
|
|
|
+ margin-right: 8px;
|
|
|
+ background-size: cover;
|
|
|
+}
|
|
|
+
|
|
|
+.wechat-audio-button .play {
|
|
|
+ background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>');
|
|
|
+}
|
|
|
+
|
|
|
+.wechat-audio-button .pause {
|
|
|
+ background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>');
|
|
|
+}
|
|
|
+
|
|
|
+.wechat-audio-button .text {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.ai-message .el-button {
|
|
|
+ margin-left: 10px;
|
|
|
+ background-color: transparent;
|
|
|
+ border: none;
|
|
|
+ color: #33691e;
|
|
|
+}
|
|
|
+
|
|
|
+.ai-message .el-button:hover {
|
|
|
+ background-color: #f1f8e9;
|
|
|
+}
|
|
|
+
|
|
|
+.camera-preview {
|
|
|
+ position: fixed;
|
|
|
+ top: 20px;
|
|
|
+ right: 20px;
|
|
|
+ width: 200px; /* 根据需要调整大小 */
|
|
|
+ height: 150px; /* 根据需要调整大小 */
|
|
|
+ border: 2px solid #ccc;
|
|
|
+ border-radius: 10px;
|
|
|
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
|
+ z-index: 1000; /* 确保摄像头画面在最上层 */
|
|
|
+}
|
|
|
+
|
|
|
+.recording-duration {
|
|
|
+ position: fixed;
|
|
|
+ top: 180px; /* 根据 camera-preview 的高度调整 */
|
|
|
+ right: 65px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #333;
|
|
|
+ background-color: rgba(255, 255, 255, 0.8);
|
|
|
+ padding: 4px 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ z-index: 1000;
|
|
|
+}
|
|
|
+
|
|
|
+</style>
|