Browse Source

Merge branch 'beta'

SanHQin 10 hours ago
parent
commit
bbc892a4dd

+ 1 - 1
dist/index.html

@@ -37,7 +37,7 @@
       width: 100%;
       width: 100%;
       background: #e6eaf0;
       background: #e6eaf0;
       font-family: '黑体';
       font-family: '黑体';
-    }</style><link href=./static/css/app.f8426c88a9c06c41914c7b63ef2e320d.css rel=stylesheet></head><body><div id=app></div><script type=text/javascript src=./static/js/manifest.23ea04dc469b57e2b4f8.js></script><script type=text/javascript src=./static/js/vendor.2c492f3d5a39df08f4a3.js></script><script type=text/javascript src=./static/js/app.a5f3f96d4582626b5a8f.js></script></body></html><script>function stopSafari() {
+    }</style><link href=./static/css/app.32159ae6e6181a425d77ce936cfeb62e.css rel=stylesheet></head><body><div id=app></div><script type=text/javascript src=./static/js/manifest.23ea04dc469b57e2b4f8.js></script><script type=text/javascript src=./static/js/vendor.2c492f3d5a39df08f4a3.js></script><script type=text/javascript src=./static/js/app.c408ed2a00af789d76c3.js></script></body></html><script>function stopSafari() {
     //阻止safari浏览器双击放大功能
     //阻止safari浏览器双击放大功能
     let lastTouchEnd = 0  //更新手指弹起的时间
     let lastTouchEnd = 0  //更新手指弹起的时间
     document.documentElement.addEventListener("touchstart", function (event) {
     document.documentElement.addEventListener("touchstart", function (event) {

File diff suppressed because it is too large
+ 0 - 0
dist/static/css/app.32159ae6e6181a425d77ce936cfeb62e.css


File diff suppressed because it is too large
+ 0 - 0
dist/static/css/app.32159ae6e6181a425d77ce936cfeb62e.css.map


File diff suppressed because it is too large
+ 0 - 0
dist/static/css/workPage.f3ea7df09da17667161d00617782c8ce.css


File diff suppressed because it is too large
+ 0 - 0
dist/static/css/workPage.f3ea7df09da17667161d00617782c8ce.css.map


File diff suppressed because it is too large
+ 0 - 0
dist/static/js/app.c408ed2a00af789d76c3.js


File diff suppressed because it is too large
+ 0 - 0
dist/static/js/app.c408ed2a00af789d76c3.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/static/js/manifest.23ea04dc469b57e2b4f8.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/static/js/workPage-manifest.2ece51fa34be51c8610a.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/static/js/workPage.8e2c2f40c228328812a7.js


File diff suppressed because it is too large
+ 0 - 0
dist/static/js/workPage.8e2c2f40c228328812a7.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/static/js/workPage.b7e25dae76fc8a0081e6.js


File diff suppressed because it is too large
+ 0 - 0
dist/static/js/workPage.b7e25dae76fc8a0081e6.js.map


+ 1 - 1
dist/workPage.html

@@ -1,4 +1,4 @@
-<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>工作页面 - CocoFlow</title><link rel=icon href=../static/logo.ico><link href=./static/css/workPage.12caa3dc5db1a12b5a56f4bb2db7ac67.css rel=stylesheet></head><body><noscript><strong>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script type=text/javascript src=./static/js/workPage-manifest.2ece51fa34be51c8610a.js></script><script type=text/javascript src=./static/js/workPage-vendor.7b6275e4cf468b42a92b.js></script><script type=text/javascript src=./static/js/workPage.b7e25dae76fc8a0081e6.js></script></body></html><script>function stopSafari() {
+<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>工作页面 - CocoFlow</title><link rel=icon href=../static/logo.ico><link href=./static/css/workPage.f3ea7df09da17667161d00617782c8ce.css rel=stylesheet></head><body><noscript><strong>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script type=text/javascript src=./static/js/workPage-manifest.2ece51fa34be51c8610a.js></script><script type=text/javascript src=./static/js/workPage-vendor.7b6275e4cf468b42a92b.js></script><script type=text/javascript src=./static/js/workPage.8e2c2f40c228328812a7.js></script></body></html><script>function stopSafari() {
     //阻止safari浏览器双击放大功能
     //阻止safari浏览器双击放大功能
     let lastTouchEnd = 0  //更新手指弹起的时间
     let lastTouchEnd = 0  //更新手指弹起的时间
     document.documentElement.addEventListener("touchstart", function (event) {
     document.documentElement.addEventListener("touchstart", function (event) {

+ 127 - 53
src/components/pages/workPage/components/kwlPage.vue

@@ -270,8 +270,75 @@ export default {
   methods: {
   methods: {
     // 清理 iframe 录音会话(统一清理入口)
     // 清理 iframe 录音会话(统一清理入口)
     _cleanupTranscriber(iframe) {
     _cleanupTranscriber(iframe) {
-      iframe.contentWindow.onSessionStopped = null;
-      iframe.contentWindow.window.onRecognizedResult = null;
+      const win = iframe && iframe.contentWindow;
+      if (!win) return;
+      try {
+        win.onSessionStopped = null;
+        win.onSessionStarted = null;
+        win.window.onRecognizedResult = null;
+      } catch (e) {
+        console.warn("[cleanupTranscriber] 清理 iframe 回调失败", e);
+      }
+    },
+    // 查询麦克风权限状态:永不抛错、永不挂起
+    // 不支持 / 查询失败 / 超时 统一返回 'unknown',避免停止流程被卡死
+    _queryMicPermission(timeout = 1500) {
+      return new Promise(resolve => {
+        let settled = false;
+        let timer = null;
+        const finish = state => {
+          if (settled) return;
+          settled = true;
+          if (timer) clearTimeout(timer);
+          resolve(state || 'unknown');
+        };
+        timer = setTimeout(() => finish('unknown'), timeout);
+        try {
+          if (!navigator.permissions || typeof navigator.permissions.query !== 'function') {
+            return finish('unknown');
+          }
+          const result = navigator.permissions.query({ name: 'microphone' });
+          if (result && typeof result.then === 'function') {
+            result
+              .then(status => finish(status && status.state))
+              .catch(e => {
+                console.warn("[stopRecord] permissions.query 失败,按未知权限处理", e);
+                finish('unknown');
+              });
+          } else if (result && result.state) {
+            finish(result.state);
+          } else {
+            finish('unknown');
+          }
+        } catch (e) {
+          // Firefox/Safari 等对 { name: 'microphone' } 直接抛 TypeError
+          console.warn("[stopRecord] permissions.query 不支持,按未知权限处理", e);
+          finish('unknown');
+        }
+      });
+    },
+    // 绑定 iframe 停止回调(必须在点击停止按钮之前绑定,避免回调先于监听到达)
+    _bindSessionStopped(iframe) {
+      const win = iframe && iframe.contentWindow;
+      if (!win) return;
+      win.onSessionStopped = (s, e) => {
+        console.log("onSessionStopped", s, e);
+        this._finishStop(iframe, this.lang.ssKWLRecStopped);
+      };
+    },
+    // 统一收尾:复位状态、解绑回调、清理 iframe、给出提示
+    _finishStop(iframe, tipMsg, tipType = "success") {
+      if (this._stopFallbackTimer) {
+        clearTimeout(this._stopFallbackTimer);
+        this._stopFallbackTimer = null;
+      }
+      this.recordObj.status = "0";
+      this.recordObj.loading = false;
+      this.recordObj.startTime = 0;
+      this._cleanupTranscriber(iframe);
+      if (tipMsg && this.$message) {
+        this.$message[tipType](tipMsg);
+      }
     },
     },
     changeWorkData(newValue) {
     changeWorkData(newValue) {
       const str = JSON.stringify(newValue);
       const str = JSON.stringify(newValue);
@@ -386,67 +453,74 @@ export default {
       // }
       // }
     },
     },
     // 停止录音
     // 停止录音
-    stopRecord() {
-      if (new Date().getTime() - this.recordObj.startTime < 2000) {
+    // 旧实现的问题:
+    // 1. navigator.permissions 不存在时,`permissions && permissions.query(...).then(...)`
+    //    整段短路成 undefined,回调不执行,录音实际没停、状态也没复位;
+    // 2. permissions.query 在 Firefox/Safari 上同步 throw,未捕获导致停止流程中断;
+    // 3. 只有 "granted" 才继续,state 为 "prompt"(权限弹窗未回应)时误判成无权限;
+    // 4. onSessionStopped 在点击停止按钮「之后」才绑定,回调可能先于绑定到达,状态永远不复位。
+    async stopRecord() {
+      // 防重复点击:已在停止中或已停止则忽略
+      if (this.recordObj.loading || this.recordObj.status != '1') {
+        return;
+      }
+      if (this.recordObj.startTime && new Date().getTime() - this.recordObj.startTime < 2000) {
         this.$message.info(this.lang.ssKWLRecordShort);
         this.$message.info(this.lang.ssKWLRecordShort);
         return;
         return;
       }
       }
       const iframe = this.$refs["iframeRef"];
       const iframe = this.$refs["iframeRef"];
-      navigator.permissions && navigator.permissions.query({ name: 'microphone' }).then(permissionStatus => {
-        if (permissionStatus.state !== "granted") {
-          this.recordObj.status = "0";
-          this.recordObj.loading = false;
-          if (iframe && iframe.contentWindow) {
-            this._cleanupTranscriber(iframe);
-          }
-          this.$message.warning(this.lang.ssKWLNoMicPerm);
+      if (!iframe || !iframe.contentWindow) {
+        // iframe 已不存在,直接复位本地状态
+        this._finishStop(iframe);
+        return;
+      }
+
+      this.recordObj.loading = true;
+      try {
+        // 权限查询失败/不支持/超时都返回 'unknown',只有明确 denied 才算无权限
+        const permissionState = await this._queryMicPermission(1500);
+        if (permissionState === 'denied') {
+          this._finishStop(iframe, this.lang.ssKWLNoMicPerm, "warning");
           return;
           return;
         }
         }
-        if (this.recordObj.status == '1' && !this.recordObj.loading) {
-          // let iframe = this.$refs["iframeRef"];
-          if (!iframe || !iframe.contentWindow) {
-            this.recordObj.status = "0";
-            this.recordObj.loading = false;
-            return;
-          }
-          this.recordObj.loading = true;
-          try {
-            iframe.contentWindow.window.document
-              .getElementById("scenarioStopButton")
-              .click();
-          } catch (err) {
-            console.error("stop click error:", err);
-            this.recordObj.status = "0";
-            this.recordObj.loading = false;
-            return;
-          }
-          iframe.contentWindow.onSessionStopped = () => {
-            this.recordObj.status = "0";
-            this.recordObj.loading = false;
-            this.$message.success(this.lang.ssKWLRecStopped);
-            this._cleanupTranscriber(iframe);
-          };
-        } else {
-          this.recordObj.status = "0";
-          this.recordObj.loading = false;
-          let iframe = this.$refs["iframeRef"];
-          if (iframe && iframe.contentWindow) {
-            this._cleanupTranscriber(iframe);
-          }
-        }
-      }).catch(() => {
-        this.recordObj.status = "0";
-        this.recordObj.loading = false;
-        let iframe = this.$refs["iframeRef"];
-        if (iframe && iframe.contentWindow) {
-          this._cleanupTranscriber(iframe);
+
+        // 关键:先绑定回调,再触发 iframe 内的停止按钮
+        this._bindSessionStopped(iframe);
+
+        const stopBtn = iframe.contentWindow.window.document.getElementById("scenarioStopButton");
+        if (!stopBtn) {
+          // iframe 内页面尚未就绪,无法优雅停止,直接本地收尾
+          console.warn("[stopRecord] 未找到 scenarioStopButton,直接结束录音会话");
+          this._finishStop(iframe, this.lang.ssKWLRecStopped);
+          return;
         }
         }
-        this.$message.success(this.lang.ssKWLRecStopped);
-      });
+        stopBtn.click();
+
+        // 兜底:iframe 若因故不再回调 onSessionStopped,超时后强制复位,避免卡在「录音中」
+        if (this._stopFallbackTimer) clearTimeout(this._stopFallbackTimer);
+        this._stopFallbackTimer = setTimeout(() => {
+          this._stopFallbackTimer = null;
+          if (this.recordObj.status == '1') {
+            console.warn("[stopRecord] 等待 onSessionStopped 超时,强制结束录音会话");
+            this._finishStop(iframe, this.lang.ssKWLRecStopped);
+          }
+        }, 3000);
+      } catch (error) {
+        console.error("[stopRecord] 停止录音异常", error);
+        this._finishStop(iframe, this.lang.ssKWLRecStopped);
+      }
     }
     }
   },
   },
   mounted() { },
   mounted() { },
-  beforeUnmount() { }
+  // 注意:当前工程是 Vue 2.6,销毁钩子为 beforeDestroy(beforeUnmount 在 2.6 不会触发)
+  beforeDestroy() {
+    // 组件卸载时清掉停止录音的兜底定时器与 iframe 回调,避免内存泄漏
+    if (this._stopFallbackTimer) {
+      clearTimeout(this._stopFallbackTimer);
+      this._stopFallbackTimer = null;
+    }
+    this._cleanupTranscriber(this.$refs["iframeRef"]);
+  }
 };
 };
 </script>
 </script>
 
 

+ 143 - 44
src/components/pages/workPage/components/questionsAndAnswers.vue

@@ -3,7 +3,8 @@
     <div class="qaa_title">{{ work.answerQ }}</div>
     <div class="qaa_title">{{ work.answerQ }}</div>
     <div class="qaa_img" v-if="work.imageList && work.imageList.length > 0">
     <div class="qaa_img" v-if="work.imageList && work.imageList.length > 0">
       <div v-for="(item, index) in work.imageList" :key="index">
       <div v-for="(item, index) in work.imageList" :key="index">
-        <el-image style="width: auto; height: 100%;cursor: pointer;max-height: 300px;" :src="item.url" alt="" :preview-src-list="[item.url]" fit="cover" />
+        <el-image style="width: auto; height: 100%;cursor: pointer;max-height: 300px;" :src="item.url" alt=""
+          :preview-src-list="[item.url]" fit="cover" />
       </div>
       </div>
     </div>
     </div>
     <div class="qaa_type">{{ lang.ssQAQuestion }}</div>
     <div class="qaa_type">{{ lang.ssQAQuestion }}</div>
@@ -120,8 +121,75 @@ export default {
   methods: {
   methods: {
     // 清理 iframe 录音会话(统一清理入口)
     // 清理 iframe 录音会话(统一清理入口)
     _cleanupTranscriber(iframe) {
     _cleanupTranscriber(iframe) {
-      iframe.contentWindow.onSessionStopped = null;
-      iframe.contentWindow.window.onRecognizedResult = null;
+      const win = iframe && iframe.contentWindow;
+      if (!win) return;
+      try {
+        win.onSessionStopped = null;
+        win.onSessionStarted = null;
+        win.window.onRecognizedResult = null;
+      } catch (e) {
+        console.warn("[cleanupTranscriber] 清理 iframe 回调失败", e);
+      }
+    },
+    // 查询麦克风权限状态:永不抛错、永不挂起
+    // 不支持/查询失败/超时 统一返回 'unknown',避免整个停止流程被卡死
+    _queryMicPermission(timeout = 1500) {
+      return new Promise(resolve => {
+        let settled = false;
+        let timer = null;
+        const finish = state => {
+          if (settled) return;
+          settled = true;
+          if (timer) clearTimeout(timer);
+          resolve(state || 'unknown');
+        };
+        timer = setTimeout(() => finish('unknown'), timeout);
+        try {
+          if (!navigator.permissions || typeof navigator.permissions.query !== 'function') {
+            return finish('unknown');
+          }
+          const result = navigator.permissions.query({ name: 'microphone' });
+          if (result && typeof result.then === 'function') {
+            result
+              .then(status => finish(status && status.state))
+              .catch(e => {
+                console.warn("[stopRecord] permissions.query 失败,按未知权限处理", e);
+                finish('unknown');
+              });
+          } else if (result && result.state) {
+            finish(result.state);
+          } else {
+            finish('unknown');
+          }
+        } catch (e) {
+          // Firefox/Safari 等对 { name: 'microphone' } 直接抛 TypeError
+          console.warn("[stopRecord] permissions.query 不支持,按未知权限处理", e);
+          finish('unknown');
+        }
+      });
+    },
+    // 绑定 iframe 停止回调(必须在点击停止按钮之前绑定,避免回调先于监听到达)
+    _bindSessionStopped(iframe) {
+      const win = iframe && iframe.contentWindow;
+      if (!win) return;
+      win.onSessionStopped = (s, e) => {
+        console.log("onSessionStopped", s, e);
+        this._finishStop(iframe, this.lang.ssStoppedRec);
+      };
+    },
+    // 统一收尾:复位状态、解绑回调、清理 iframe、给出提示
+    _finishStop(iframe, tipMsg) {
+      if (this._stopFallbackTimer) {
+        clearTimeout(this._stopFallbackTimer);
+        this._stopFallbackTimer = null;
+      }
+      this.recordObj.status = "0";
+      this.recordObj.loading = false;
+      this.recordObj.startTime = 0;
+      this._cleanupTranscriber(iframe);
+      if (tipMsg) {
+        this.$message.success(tipMsg);
+      }
     },
     },
     // 更新作业
     // 更新作业
     changeWorkData(newValue) {
     changeWorkData(newValue) {
@@ -164,9 +232,18 @@ export default {
           console.log(privText)
           console.log(privText)
         };
         };
 
 
+        iframe.contentWindow.onSessionStarted = (s, e) => {
+          console.log("onSessionStarted", s, e)
+          this.recordObj.status = "1"
+          this.recordObj.startTime = new Date().getTime();
+        }
+
         iframe.contentWindow.ConversationTranscriber();
         iframe.contentWindow.ConversationTranscriber();
-        this.recordObj.status = "1"
-        this.recordObj.startTime = new Date().getTime();
+
+        // this.recordObj.status = "1"
+        // this.recordObj.startTime = new Date().getTime();
+
+
       };
       };
 
 
       // 先通过 getUserMedia 主动请求麦克风权限,避免 ConversationTranscriber
       // 先通过 getUserMedia 主动请求麦克风权限,避免 ConversationTranscriber
@@ -203,50 +280,64 @@ export default {
       //  })
       //  })
       //}, 2000)
       //}, 2000)
     },
     },
-    stopRecord() {
-      console.log("测试测试")
-      if (new Date().getTime() - this.recordObj.startTime < 2000) {
-        return this.$message.info(this.lang.ssRecordShort)
+    // 停止录音
+    // 旧实现的问题:
+    // 1. navigator.permissions 不存在时,`permissions && permissions.query(...).then(...)`
+    //    整段短路成 undefined,回调一个都不执行,函数直接走到 alert("...end"),录音实际没停;
+    // 2. permissions.query 在 Firefox/Safari 上会直接 throw,未捕获导致停止流程中断;
+    // 3. 只有 "granted" 才继续,导致 state 为 "prompt"(用户一直没回应权限弹窗)时误判为无权限;
+    // 4. onSessionStopped 在点击停止按钮「之后」才绑定,回调可能先于绑定到达,导致状态永远不复位。
+    async stopRecord() {
+      // 防重复点击:卸载中或已停止则直接忽略
+      if (this.recordObj.loading || this.recordObj.status != '1') {
+        return;
       }
       }
+      if (this.recordObj.startTime && new Date().getTime() - this.recordObj.startTime < 2000) {
+        return this.$message.info(this.lang.ssRecordShort);
+      }
+
       const iframe = this.$refs["iframeRef"];
       const iframe = this.$refs["iframeRef"];
-      // 判断浏览器是否有开启录音权限
-      navigator.permissions && navigator.permissions.query({ name: 'microphone' }).then(permissionStatus => {
-        if (permissionStatus.state !== "granted") {
-          // 没有开启录音权限,直接确定停止录音
-          this.recordObj.status = "0";
-          this.recordObj.loading = false;
-          this._cleanupTranscriber(iframe);
-          this.$message.success(this.lang.ssNoPermStop);
+      if (!iframe || !iframe.contentWindow) {
+        // iframe 已不存在,直接复位本地状态
+        this._finishStop(iframe);
+        return;
+      }
+
+      this.recordObj.loading = true;
+      try {
+        // 权限查询失败/不支持/超时都返回 'unknown',只有明确 denied 才算无权限,
+        // 避免因浏览器差异把「正常录音」误判成「无权限」
+        const permissionState = await this._queryMicPermission(1500);
+        if (permissionState === 'denied') {
+          this._finishStop(iframe, this.lang.ssNoPermStop);
           return;
           return;
         }
         }
-        console.log("测试测试222")
-        // 有权限再判断状态
-        if (this.recordObj.status == '1' && !this.recordObj.loading) {
-          console.log("测试测3333")
-          this.recordObj.loading = true;
-          iframe.contentWindow.window.document
-            .getElementById("scenarioStopButton")
-            .click();
-          iframe.contentWindow.onSessionStopped = (s, e) => {
-            this.recordObj.status = "0"
-            this.recordObj.loading = false;
-            this.$message.success(this.lang.ssStoppedRec)
-            iframe.contentWindow.onSessionStopped = null;
-            this._cleanupTranscriber(iframe);
-          };
-        } else {
-          this.recordObj.status = "0";
-          this.recordObj.loading = false;
-          this._cleanupTranscriber(iframe);
+
+        // 关键:先绑定回调,再触发 iframe 内的停止按钮
+        this._bindSessionStopped(iframe);
+
+        const stopBtn = iframe.contentWindow.window.document.getElementById("scenarioStopButton");
+        if (!stopBtn) {
+          // iframe 内页面尚未就绪,无法优雅停止,直接本地收尾
+          console.warn("[stopRecord] 未找到 scenarioStopButton,直接结束录音会话");
+          this._finishStop(iframe, this.lang.ssMicCheckStop);
+          return;
         }
         }
-      }).catch(() => {
-        console.log("测试测4444试")
-        // 浏览器不支持或异常,默认直接停止
-        this.recordObj.status = "0";
-        this.recordObj.loading = false;
-        this._cleanupTranscriber(this.$refs["iframeRef"]);
-        this.$message.success(this.lang.ssMicCheckStop);
-      });
+        stopBtn.click();
+
+        // 兜底:iframe 若因故不再回调 onSessionStopped,超时后强制复位,避免一直卡在「录音中」
+        if (this._stopFallbackTimer) clearTimeout(this._stopFallbackTimer);
+        this._stopFallbackTimer = setTimeout(() => {
+          this._stopFallbackTimer = null;
+          if (this.recordObj.status == '1') {
+            console.warn("[stopRecord] 等待 onSessionStopped 超时,强制结束录音会话");
+            this._finishStop(iframe, this.lang.ssStoppedRec);
+          }
+        }, 3000);
+      } catch (error) {
+        console.error("[stopRecord] 停止录音异常", error);
+        this._finishStop(iframe, this.lang.ssMicCheckStop);
+      }
     },
     },
     setWork() {
     setWork() {
       this.work = JSON.parse(JSON.stringify(this.workData.json));
       this.work = JSON.parse(JSON.stringify(this.workData.json));
@@ -254,6 +345,14 @@ export default {
   },
   },
   mounted() {
   mounted() {
 
 
+  },
+  beforeDestroy() {
+    // 组件销毁时清掉停止录音的兜底定时器与 iframe 回调,避免内存泄漏
+    if (this._stopFallbackTimer) {
+      clearTimeout(this._stopFallbackTimer);
+      this._stopFallbackTimer = null;
+    }
+    this._cleanupTranscriber(this.$refs["iframeRef"]);
   }
   }
 };
 };
 </script>
 </script>

+ 130 - 21
src/components/pages/workPage/components/sixHatPage.vue

@@ -800,8 +800,75 @@ export default {
   methods: {
   methods: {
     // 清理 iframe 录音会话(统一清理入口)
     // 清理 iframe 录音会话(统一清理入口)
     _cleanupTranscriber(iframe) {
     _cleanupTranscriber(iframe) {
-      iframe.contentWindow.onSessionStopped = null;
-      iframe.contentWindow.window.onRecognizedResult = null;
+      const win = iframe && iframe.contentWindow;
+      if (!win) return;
+      try {
+        win.onSessionStopped = null;
+        win.onSessionStarted = null;
+        win.window.onRecognizedResult = null;
+      } catch (e) {
+        console.warn('[cleanupTranscriber] 清理 iframe 回调失败', e);
+      }
+    },
+    // 查询麦克风权限状态:永不抛错、永不挂起
+    // 不支持 / 查询失败 / 超时 统一返回 'unknown',避免停止流程被卡死
+    _queryMicPermission(timeout = 1500) {
+      return new Promise(resolve => {
+        let settled = false;
+        let timer = null;
+        const finish = state => {
+          if (settled) return;
+          settled = true;
+          if (timer) clearTimeout(timer);
+          resolve(state || 'unknown');
+        };
+        timer = setTimeout(() => finish('unknown'), timeout);
+        try {
+          if (!navigator.permissions || typeof navigator.permissions.query !== 'function') {
+            return finish('unknown');
+          }
+          const result = navigator.permissions.query({ name: 'microphone' });
+          if (result && typeof result.then === 'function') {
+            result
+              .then(status => finish(status && status.state))
+              .catch(e => {
+                console.warn('[stopRecord] permissions.query 失败,按未知权限处理', e);
+                finish('unknown');
+              });
+          } else if (result && result.state) {
+            finish(result.state);
+          } else {
+            finish('unknown');
+          }
+        } catch (e) {
+          // Firefox/Safari 等对 { name: 'microphone' } 直接抛 TypeError
+          console.warn('[stopRecord] permissions.query 不支持,按未知权限处理', e);
+          finish('unknown');
+        }
+      });
+    },
+    // 绑定 iframe 停止回调(必须在点击停止按钮之前绑定,避免回调先于监听到达)
+    _bindSessionStopped(iframe) {
+      const win = iframe && iframe.contentWindow;
+      if (!win) return;
+      win.onSessionStopped = (s, e) => {
+        console.log('onSessionStopped', s, e);
+        this._finishStop(iframe, this.lang.ssSHRecStopped);
+      };
+    },
+    // 统一收尾:复位状态、解绑回调、清理 iframe、给出提示
+    _finishStop(iframe, tipMsg) {
+      if (this._stopFallbackTimer) {
+        clearTimeout(this._stopFallbackTimer);
+        this._stopFallbackTimer = null;
+      }
+      this.recordObj.status = '0';
+      this.recordObj.loading = false;
+      this.recordObj.startTime = 0;
+      this._cleanupTranscriber(iframe);
+      if (tipMsg && this.$message) {
+        this.$message.success(tipMsg);
+      }
     },
     },
     // —— 加载班级学生(接口无数据时保持 mock)——
     // —— 加载班级学生(接口无数据时保持 mock)——
     async loadClassStudents() {
     async loadClassStudents() {
@@ -1045,38 +1112,80 @@ export default {
       //   this.recordObj.status = '0';
       //   this.recordObj.status = '0';
       // }
       // }
     },
     },
-    stopRecord() {
-      if (new Date().getTime() - this.recordObj.startTime < 2000) {
+    // 停止录音
+    // 旧实现的问题:
+    // 1. onSessionStopped 在点击停止按钮「之后」才绑定,iframe 回调若同步/极快到,
+    //    永远绑不上,UI 卡在「录音中」且 loading 一直为 true,后续点击全部被忽略;
+    // 2. 未做权限判断,麦克风被系统/浏览器撤销时停止按钮点了也没反应;
+    // 3. 只有 try/catch 兜底,没有超时兜底,会话异常时状态无法复位。
+    async stopRecord() {
+      // 防重复点击:已在停止中或已停止则忽略
+      if (this.recordObj.loading || this.recordObj.status != '1') {
+        return;
+      }
+      if (this.recordObj.startTime && new Date().getTime() - this.recordObj.startTime < 2000) {
         this.$message.info(this.lang.ssSHRecordShort);
         this.$message.info(this.lang.ssSHRecordShort);
         return;
         return;
       }
       }
       const iframe = this.$refs['iframeRef'];
       const iframe = this.$refs['iframeRef'];
       if (!iframe || !iframe.contentWindow) {
       if (!iframe || !iframe.contentWindow) {
-        this.recordObj.status = '0';
-        this.recordObj.loading = false;
+        // iframe 已不存在,直接复位本地状态
+        this._finishStop(iframe);
         return;
         return;
       }
       }
+
       this.recordObj.loading = true;
       this.recordObj.loading = true;
       try {
       try {
-        iframe.contentWindow.window.document.getElementById('scenarioStopButton').click();
-      } catch (err) {
-        console.error('stop click error:', err);
-        this.recordObj.status = '0';
-        this.recordObj.loading = false;
-        return;
+        // 权限查询失败/不支持/超时都返回 'unknown',只有明确 denied 才算无权限
+        const permissionState = await this._queryMicPermission(1500);
+        if (permissionState === 'denied') {
+          this._finishStop(iframe, this.lang.ssSHNoPermStop);
+          return;
+        }
+
+        // 关键:先绑定回调,再触发 iframe 内的停止按钮
+        this._bindSessionStopped(iframe);
+
+        const stopBtn = iframe.contentWindow.window.document.getElementById('scenarioStopButton');
+        if (!stopBtn) {
+          // iframe 内页面尚未就绪,无法优雅停止,直接本地收尾
+          console.warn('[stopRecord] 未找到 scenarioStopButton,直接结束录音会话');
+          this._finishStop(iframe, this.lang.ssSHRecStopped);
+          return;
+        }
+        stopBtn.click();
+
+        // 兜底:iframe 若因故不再回调 onSessionStopped,超时后强制复位,避免卡在「录音中」
+        if (this._stopFallbackTimer) clearTimeout(this._stopFallbackTimer);
+        this._stopFallbackTimer = setTimeout(() => {
+          this._stopFallbackTimer = null;
+          if (this.recordObj.status == '1') {
+            console.warn('[stopRecord] 等待 onSessionStopped 超时,强制结束录音会话');
+            this._finishStop(iframe, this.lang.ssSHRecStopped);
+          }
+        }, 3000);
+      } catch (error) {
+        console.error('[stopRecord] 停止录音异常', error);
+        this._finishStop(iframe, this.lang.ssSHRecStopped);
       }
       }
-      iframe.contentWindow.onSessionStopped = () => {
-        this.recordObj.status = '0';
-        this.recordObj.loading = false;
-        this.$message.success(this.lang.ssSHRecStopped);
-        this._cleanupTranscriber(iframe);
-        // iframe.contentWindow.onSessionStopped = null;
-        // iframe.contentWindow.window.onRecognizedResult = null;
-      };
     },
     },
+    // 卸载收尾:清定时器 + 解绑 iframe 回调(幂等,两个销毁钩子都调用)
+    _disposeRecord() {
+      this.clearCountdown();
+      if (this._stopFallbackTimer) {
+        clearTimeout(this._stopFallbackTimer);
+        this._stopFallbackTimer = null;
+      }
+      this._cleanupTranscriber(this.$refs['iframeRef']);
+    },
+  },
+  // 注意:当前工程是 Vue 2.6,销毁钩子为 beforeDestroy(beforeUnmount 在 2.6 不会触发),
+  // 两个都保留以保证升级到 Vue 2.7+/3.x 后依然生效;下面两个操作都是幂等的。
+  beforeDestroy() {
+    this._disposeRecord();
   },
   },
   beforeUnmount() {
   beforeUnmount() {
-    this.clearCountdown();
+    this._disposeRecord();
   },
   },
 };
 };
 </script>
 </script>

Some files were not shown because too many files changed in this diff