Переглянути джерело

fix:修复口语作业chrome浏览器不兼容问题

lalala 10 місяців тому
батько
коміт
d84367abf6

+ 1 - 0
index.html

@@ -5,6 +5,7 @@
     <link rel="icon" href="/favicon.ico">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>SEEAI专业英语写作</title>
+    <script src="/asr-sdk/index.umd.js"></script> <!-- 引入 SDK -->
   </head>
   <body>
     <div id="app"></div>

+ 7 - 0
package-lock.json

@@ -13,6 +13,7 @@
         "@vue-office/docx": "^1.6.2",
         "@vueup/vue-quill": "^1.0.0-alpha.40",
         "axios": "^1.7.2",
+        "crypto-js": "^4.2.0",
         "dayjs": "^1.11.13",
         "docx-preview": "^0.3.2",
         "echarts": "^5.6.0",
@@ -1983,6 +1984,12 @@
         "node": ">= 8"
       }
     },
+    "node_modules/crypto-js": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz",
+      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+      "license": "MIT"
+    },
     "node_modules/cssesc": {
       "version": "3.0.0",
       "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",

+ 1 - 0
package.json

@@ -17,6 +17,7 @@
     "@vue-office/docx": "^1.6.2",
     "@vueup/vue-quill": "^1.0.0-alpha.40",
     "axios": "^1.7.2",
+    "crypto-js": "^4.2.0",
     "dayjs": "^1.11.13",
     "docx-preview": "^0.3.2",
     "echarts": "^5.6.0",

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
public/asr-sdk/index.cjs.js


+ 30 - 0
public/asr-sdk/index.d.ts

@@ -0,0 +1,30 @@
+declare class RecorderManager {
+    /**
+     * 构造函数
+     * @param processorPath processor的文件路径,如果processor.worker.js的访问地址为`/a/b/processor.worker.js`,则processorPath 为`/a/b`
+     *
+     */
+    constructor(processorPath: string);
+    private audioBuffers;
+    private processorPath;
+    private audioContext?;
+    private audioTracks?;
+    private audioWorklet?;
+    onStop?: (audioBuffers: ArrayBuffer[]) => void;
+    onFrameRecorded?: (params: {
+        isLastFrame: boolean;
+        frameBuffer: ArrayBuffer;
+    }) => void;
+    /**
+     * 监听录音开始事件
+     */
+    onStart?: () => void;
+    start({ sampleRate, frameSize, arrayBufferType, }: {
+        sampleRate?: number;
+        frameSize?: number;
+        arrayBufferType?: "short16" | "float32";
+    }): Promise<void>;
+    stop(): void;
+}
+
+export { RecorderManager as default };

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
public/asr-sdk/index.esm.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
public/asr-sdk/index.umd.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
public/asr-sdk/processor.worker.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
public/asr-sdk/processor.worklet.js


+ 217 - 0
src/utils/xunfeiUtil.js

@@ -0,0 +1,217 @@
+import {computed, ref} from "vue";
+import CryptoJS from "crypto-js";
+
+// TODO 自己去讯飞官网获获取 apiKey、apiSecret、app_id 等信息
+
+const apiKey = "9d44aad124b15562cd36f1c69f907c23";
+const apiSecret = "MWZkZTM5ZjMwMGJkNjJkM2NmNjJjMjI3";
+const app_id = "fdf4a5d8";
+
+/**
+ * 获取websocket url
+ * 该接口需要后端提供,这里为了方便前端处理
+ */
+function getWebSocketUrl() {
+    const url = "wss: //iat-api.xfyun.cn/v2/iat";
+    const host = "iat-api.xfyun.cn";
+    const date = new Date().toUTCString();
+    const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/iat HTTP/1.1`;
+    const signature = CryptoJS.HmacSHA256(signatureOrigin, apiSecret);
+    const signatureBase64 = CryptoJS.enc.Base64.stringify(signature);
+    const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signatureBase64}"`;
+    // base64编码authorization
+    const authorization = CryptoJS.enc.Utf8.parse(authorizationOrigin).toString(CryptoJS.enc.Base64);
+    // 构造URL参数
+    const params = new URLSearchParams({
+      authorization: authorization,
+      date: date,
+      host: host
+    });
+    return `wss://${host}/v2/iat?${params.toString()}`;
+}
+
+/**
+ * 将音频二进制数据转换为base64编码
+ * @param buffer
+ * @returns {string}
+ */
+function bufferToBase64(buffer) {
+    let binary = "";
+    const bytes = new Uint8Array(buffer);
+    const len = bytes.byteLength;
+    for (let i = 0; i < len; i ++) {
+        binary += String.fromCharCode(bytes[i]);
+    }
+    return window.btoa(binary);
+}
+
+export function useXfAsr() {
+    const resultText = ref(); // 识别结果
+    let resultTextTemp = "";
+    let iatWS = null; // websocket
+    let countdownInterval = null; // 倒计时
+    const nextTime = ref(60); // 录音时长(最大60秒)
+    const recorder = new RecorderManager("/asr-sdk");
+    const recordStatus = ref("CLOSED"); //  CONNECTING | OPEN | CLOSING | CLOSED
+    const recordText = computed(() => {
+        if (recordStatus.value === "CONNECTING") {
+            return "建立连接中";
+        } else if (recordStatus.value === "OPEN") {
+            return `录音中(${nextTime.value})`;
+        } else if (recordStatus.value === "CLOSING") {
+            return "关闭连接中";
+        } else if (recordStatus.value === "CLOSED") {
+            return "开始录音";
+        }
+    });
+    /**
+     * 录音开始事件
+     */
+    recorder.onStart = () => {
+        updateStatus("OPEN");
+    };
+    /**
+     * 监听已录制完指定帧大小的文件事件。如果设置了 frameSize,则会回调此事件。
+     * @param isLastFrame 当前帧是否正常录音结束前的最后一帧
+     * @param frameBuffer 录音分片数据
+     */
+    recorder.onFrameRecorded = ({ isLastFrame, frameBuffer }) => {
+        if (iatWS.readyState === iatWS.OPEN) {
+            const data = {
+                data: {
+                    // 0 :第一帧音频、1 :中间的音频、2 :最后一帧音频,最后一帧必须要发送
+                    status: isLastFrame ? 2 : 1,
+                    format: "audio/L16;rate=16000",
+                    encoding: "raw",
+                    audio: bufferToBase64(frameBuffer),
+                },
+            };
+            iatWS.send(JSON.stringify(data));
+            if (isLastFrame) {
+                updateStatus("CLOSING");
+            }
+        }
+    };
+    /**
+     * 录音结束事件
+     */
+    recorder.onStop = () => {
+        clearInterval(countdownInterval);
+    };
+
+    /**
+     * 倒计时
+     */
+    function countdown() {
+        nextTime.value = 60;
+        countdownInterval = setInterval(() => {
+            nextTime.value --;
+            if (nextTime.value <= 0) {
+                clearInterval(countdownInterval);
+                recorder.stop();
+            }
+        }, 1000);
+    }
+
+    /**
+     * 更新状态
+     * @param status CONNECTING | OPEN | CLOSING | CLOSED
+     */
+    function updateStatus(status) {
+        recordStatus.value = status;
+        if (status === "OPEN") {
+            countdown();
+        } else if (status === "CONNECTING") {
+            resultText.value = "";
+            resultTextTemp = "";
+        }
+    }
+
+    /**
+     * 渲染识别结果
+     * @param resultData
+     */
+    function renderResult(resultData) {
+        let jsonData = JSON.parse(resultData);
+        console.log("识别结果:", jsonData);
+        if (jsonData.data && jsonData.data.result) {
+            let data = jsonData.data.result;
+            let str = "";
+            let ws = data.ws;
+            for (let i = 0; i < ws.length; i ++) {
+                str = str + ws[i].cw[0].w;
+            }
+            // 开启 wpgs 会有此字段(前提:在控制台开通动态修正功能)
+            // 取值为 "apd"时表示该片结果是追加到前面的最终结果;取值为"rpl" 时表示替换前面的部分结果,替换范围为rg字段
+            if (data.pgs) {
+                if (data.pgs === "apd") {
+                    // 将resultTextTemp同步给resultText
+                    resultText.value = resultTextTemp;
+                    console.log("追加结果:", resultText.value);
+                }
+                // 将结果存储在resultTextTemp中
+                resultTextTemp = resultText.value + str;
+            } else {
+                resultText.value = resultText.value + str;
+            }
+        }
+        if (jsonData.code === 0 && jsonData.data.status === 2) {
+            iatWS.close();
+        }
+        if (jsonData.code !== 0) {
+            iatWS.close();
+            console.error(jsonData);
+        }
+    }
+
+    /**
+     * 开始录音
+     */
+    function startRecognition() {
+        if (recordStatus.value !== "CLOSED") return;
+        const url = getWebSocketUrl();
+        if ("WebSocket" in window) {
+            iatWS = new WebSocket(url);
+        } else if ("MozWebSocket" in window) {
+            iatWS = new MozWebSocket(url);
+        } else {
+            console.error(new Error("浏览器不支持WebSocket"));
+            return;
+        }
+        updateStatus("CONNECTING");
+        iatWS.onopen = (e) => {
+            recorder.start({ sampleRate: 16000, frameSize: 1280 });
+            const params = {
+                common: { app_id },
+                business: { language: "zh_cn", domain: "iat", accent: "mandarin", vad_eos: 50000, dwa: "wpgs" },
+                data: { status: 0, format: "audio/L16;rate=16000", encoding: "raw" },
+            };
+            iatWS.send(JSON.stringify(params));
+        };
+        iatWS.onmessage = (e) => {
+            renderResult(e.data);
+        };
+        iatWS.onerror = (e) => {
+            recorder.stop();
+            updateStatus("CLOSED");
+        };
+        iatWS.onclose = (e) => {
+            recorder.stop();
+            updateStatus("CLOSED");
+        };
+    }
+
+    /**
+     * 停止录音
+     */
+    function stopRecognition() {
+        recorder.stop();
+    }
+
+    return {
+        resultText,
+        recordText,
+        startRecognition,
+        stopRecognition,
+    };
+}

+ 35 - 22
src/views/AiSpeakingPage/SpeakingPage.vue

@@ -235,6 +235,9 @@ import ryanAvatar from "@/assets/ryan.png"
 import gavinAvatar from "@/assets/gavin.png"
 import lauraAvatar from "@/assets/laura.png"
 import ashleighAvatar from "@/assets/ashleigh.png"
+import {useXfAsr} from "@/utils/xunfeiUtil";
+
+const { startRecognition, stopRecognition, resultText } = useXfAsr();
 
 const route = useRoute();
 const router = useRouter();
@@ -394,15 +397,12 @@ const startRecording = async () => {
     await Recorder.getPermission();
     if (hasStopRecording.value) {
       recorder.value.resume();
-      if (recognition.value) {
-        recognition.value.start();
-      }
+      startRecognition();
       ElMessage.success("恢复录音");
     } else {
       ElMessage.success("开始录音");
       recorder.value.start(); // 开始录音
-      initRecognition();
-      recognition.value.start();
+      startRecognition();
     }
     isRecording.value = true;
     console.log(isRecording.value)
@@ -413,14 +413,23 @@ const startRecording = async () => {
 };
 
 // 暂停录音
-const stopRecording = () => {
+const stopRecording = async() => {
   if(!isRecording.value){
     ElMessage("暂未开始录音")
     return;
   }
   recorder.value.pause(); // 暂停录音
-  if (recognition.value) {
-    recognition.value.stop();
+  await stopRecognition();
+   // 等待一小段时间让识别结果完成处理
+  await new Promise(resolve => setTimeout(resolve, 300));
+  
+  console.log("识别结果:", resultText.value);
+  // 只有当有识别结果时才追加
+  if (resultText.value && resultText.value.trim() !== '') {
+    userInput.value += resultText.value;
+    ElMessage.success("语音识别完成");
+  } else {
+    ElMessage.warning("未识别到有效语音");
   }
   hasStopRecording.value = true;
   isRecording.value = false
@@ -440,6 +449,7 @@ const resetRecording = async() => {
   hasStartPlay.value = false;
   playTime.value = 0;
   recorder.value.destroy();
+  resultText.value = "";
   timer.value = null;
 };
 
@@ -487,7 +497,7 @@ const sendMessage = () => {
     text: 'Thinking...', 
     isThinking: true, // 添加标记表示这是思考中的临时消息
     isPlaying: false,
-    voiceCode: voiceCode.value
+    voiceCode: voiceCode.value || 0
   };
   isLoading.value = true;
   // 上传录制的语音文件
@@ -1054,10 +1064,12 @@ onMounted(() => {
 
 .main-container {
   display: flex;
-  height: 820px;
-  width: 1600px;
-  margin-top:2%;
-  margin-left: 7%;
+   /* 修改后 */
+  height: 88vh; /* 使用视口高度 */
+  width: 90vw;  /* 使用视口宽度 */
+  max-width: 1600px; /* 设置最大宽度 */
+  margin: 2% auto; /* 居中显示 */
+  margin-left: 5%;
   border-radius: 10px;
   overflow: hidden;
   box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@@ -1065,7 +1077,7 @@ onMounted(() => {
 
 
 .info-container {
-  width: 23%;
+  width: 22%;
   padding: 30px;
   box-sizing: border-box;
   display: flex;
@@ -1076,8 +1088,8 @@ onMounted(() => {
 }
 
 .back-button{
-  font-size: 35px;
-  margin-right: 200px;
+  font-size: 30px;
+  margin-right: 150px;
 }
 
 
@@ -1091,14 +1103,14 @@ onMounted(() => {
 }
 
 .task-name {
-  font-size: 26px;
+  font-size: 22px;
   margin-bottom: 10px;
   color: #0277bd;
 }
 
 .task-time {
   color: #78909c;
-  font-size: 14px;
+  font-size: 12px;
   margin-bottom: 20px;
 }
 
@@ -1302,23 +1314,24 @@ onMounted(() => {
   display: flex;
   justify-content: flex-start;
   text-align: left;
-  margin: 10px;
-  margin-bottom: 30px;
+  margin: 5px;
+  margin-bottom: 10px;
 }
 
 .message-content {
   display: flex;
   flex-direction: column; /* 竖直排列 */
-  max-width: 90%;
+  max-width: 95%;
 }
 
 .message-text {
   background-color: #e0f7fa;
-  padding: 6px;
+  padding: 4px;
   border-radius: 5px;
   margin: 0 5px;
   align-self: flex-start;
   color: #006064;
+  font-size: 14px;
 }
 
 .message-time {

+ 11 - 11
src/views/AiSpeakingPage/SpeakingRecord.vue

@@ -145,7 +145,7 @@
         style="width: 100%; min-width: 800px"
         :row-class-name="getRowClassName"
       >
-        <el-table-column prop="role" label="Role" width="130">
+        <el-table-column prop="role" label="Role" width="100">
           <template #default="scope">
             <span
               v-if="scope.row.role == 'assistant'"
@@ -169,11 +169,11 @@
           label="Count"
           width="80"
         ></el-table-column>
-        <el-table-column label="Audio" width="350">
+        <el-table-column label="Audio" width="300">
           <template #default="scope">
             <audio
               controls
-              style="width: 300px; height: 40px"
+              style="width: 250px; height: 40px"
               v-if="scope.row.audioUrl"
             >
               <source :src="scope.row.audioUrl" type="audio/mp3" />
@@ -182,13 +182,13 @@
             </audio>
           </template>
         </el-table-column>
-        <el-table-column label="Timestamp" width="200px">
+        <!-- <el-table-column label="Timestamp" width="200px">
           <template #default="scope">
           <div style="font-size: 15px">
               {{ new Date(scope.row.timestamp).toLocaleString() }}
             </div>
           </template>
-        </el-table-column>
+        </el-table-column> -->
         <el-table-column label="Score">
           <template #default="scope">
             <div
@@ -1115,7 +1115,7 @@ onMounted(async () => {
 .container{
   margin:auto;
   margin-top:30px;
-  width:1600px;
+  width:80vw;
 }
 .header-container {
   display: flex; /* 使用 Flex 布局 */
@@ -1130,19 +1130,19 @@ onMounted(async () => {
 }
 
 .chart-container {
-  width: 180px; /* 调整宽度 */
+  width: 130px; /* 调整宽度 */
   height: 100px; /* 调整高度 */
 }
 
 .page-title{
   text-align: center;
-  font-size:50px;
+  font-size:40px;
   margin-bottom: 20px;
 }
 
 .card-header {
   font-weight: bold;
-  margin-bottom: 30px;
+  margin-bottom: 20px;
   font-size: 23px;
   text-align: center;
 }
@@ -1214,8 +1214,8 @@ onMounted(async () => {
 }
 
 .video-player {
-  width: 350px;
-  height: 280px;
+  width: 250px;
+  height: 180px;
   margin-left: 30px;
 }
 

+ 12 - 3
src/views/CourseSquare/component/CourseDetails/index.vue

@@ -47,7 +47,7 @@
           </template>
           <template v-else-if="activeTab == '班级列表'">
             <div>
-                <button 
+                <button  v-if="classId"
                 style="color: #fff;background-color: #4a90c4;border: none;padding: 5px 10px;border-radius: 5px;cursor: pointer;" 
                 @click="$router.push(`/home/studentDetail?classId=${classId}&courseId=${courseId}&stuId=${store.user.id}`)">
                 查看班级列表
@@ -328,14 +328,22 @@ const handleChange = (file,fileList) => {
   用于处理课程图片
    */
 const processCourses = () => {
-  dataForm.courseImages[0] = dataForm.courseImages[0].split("?")[0]
+  if (!dataForm.courseImages || 
+      !Array.isArray(dataForm.courseImages) || 
+      !dataForm.courseImages[0]) {
+    console.warn('课程图片数据为空,使用默认图片');
+    dataForm.courseImages = ['']; // 设置默认图片
+    return;
+  }
+  // 处理图片URL(移除查询参数)
+  dataForm.courseImages[0] = dataForm.courseImages[0].split("?")[0];
 }
 const getClassId = () => {
   
   apis.getClassNameOfStudent(courseId,store.user.id)
     .then((res:any)=>{
       console.log('NEWRES',res);
-      classId = res.data.data.classId
+      classId = res.data.data.classId || null;
     })
 }
 const getCourse = () => {
@@ -343,6 +351,7 @@ const getCourse = () => {
   apis.getCourseByCourseId(courseId)
       .then((res: any) => {
         let data = res.data.data.records[0]
+        console.log('课程信息',data);
         Object.assign(dataForm, {...data})
         processCourses()
         getNewCourseInfo(dataForm)

+ 1 - 1
tsconfig.json

@@ -8,7 +8,7 @@
       "path": "./tsconfig.app.json"
     }
   ],
-  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
+  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/utils/xunfeiUtil.js"],
   
   "compilerOptions": {
      "declaration": true,

Деякі файли не було показано, через те що забагато файлів було змінено