瀏覽代碼

Merge branch 'beta' of https://git.cocorobo.cn/CocoRoboLabs/pbl-teacher-table into beta

lsc 2 天之前
父節點
當前提交
842c03d316

+ 1 - 1
dist/index.html

@@ -37,7 +37,7 @@
       width: 100%;
       background: #e6eaf0;
       font-family: '黑体';
-    }</style><link href=./static/css/app.36a8624b08d5570f35678e95ee9f10cc.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.4fe3039e3721320d6197.js></script><script type=text/javascript src=./static/js/app.7ff1cdaaf74515a95cad.js></script></body></html><script>function stopSafari() {
+    }</style><link href=./static/css/app.dec1ff17944e764bbd96dbb66cfb544f.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.28a61ec657138cd713da.js></script></body></html><script>function stopSafari() {
     //阻止safari浏览器双击放大功能
     let lastTouchEnd = 0  //更新手指弹起的时间
     document.documentElement.addEventListener("touchstart", function (event) {

文件差異過大導致無法顯示
+ 0 - 0
dist/static/css/app.dec1ff17944e764bbd96dbb66cfb544f.css


文件差異過大導致無法顯示
+ 0 - 0
dist/static/css/app.dec1ff17944e764bbd96dbb66cfb544f.css.map


文件差異過大導致無法顯示
+ 0 - 0
dist/static/css/workPage.092a2b6b85fcea9b6c61502e86fb2750.css


文件差異過大導致無法顯示
+ 0 - 0
dist/static/css/workPage.092a2b6b85fcea9b6c61502e86fb2750.css.map


文件差異過大導致無法顯示
+ 0 - 0
dist/static/js/app.28a61ec657138cd713da.js


文件差異過大導致無法顯示
+ 0 - 0
dist/static/js/app.28a61ec657138cd713da.js.map


文件差異過大導致無法顯示
+ 0 - 0
dist/static/js/manifest.23ea04dc469b57e2b4f8.js.map


文件差異過大導致無法顯示
+ 0 - 0
dist/static/js/vendor.2c492f3d5a39df08f4a3.js


文件差異過大導致無法顯示
+ 0 - 0
dist/static/js/vendor.2c492f3d5a39df08f4a3.js.map


文件差異過大導致無法顯示
+ 0 - 0
dist/static/js/workPage-manifest.2ece51fa34be51c8610a.js.map


文件差異過大導致無法顯示
+ 0 - 0
dist/static/js/workPage.d41c86de27c1cca942a0.js


文件差異過大導致無法顯示
+ 0 - 0
dist/static/js/workPage.d41c86de27c1cca942a0.js.map


文件差異過大導致無法顯示
+ 0 - 0
dist/static/js/workPage.f7ce195de4377589b864.js


文件差異過大導致無法顯示
+ 0 - 0
dist/static/js/workPage.f7ce195de4377589b864.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.472c92f73ff8560c76ae5a9797335399.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.f7ce195de4377589b864.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.092a2b6b85fcea9b6c61502e86fb2750.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.d41c86de27c1cca942a0.js></script></body></html><script>function stopSafari() {
     //阻止safari浏览器双击放大功能
     let lastTouchEnd = 0  //更新手指弹起的时间
     document.documentElement.addEventListener("touchstart", function (event) {

+ 133 - 6
src/components/pages/test/examine/conpoments/personPage.vue

@@ -2,8 +2,11 @@
   <div>
     <div class="personPage">
       <div
-        style="width: 100%;display: flex;justify-content: flex-end;align-items: center;"
-      >
+       style="width: 100%;display: flex;justify-content: flex-end;align-items: center;"
+     >
+        <div style="margin-left: 10px;" v-show="false">
+          <el-button type="primary" @click="exportListExcel" size="mini">导出Excel</el-button>
+        </div>
         <!-- <el-select v-model="teas" multiple placeholder="请选择">
           <el-option
             v-for="item in TeaList"
@@ -468,8 +471,9 @@ export default {
       PageBaseDataTwo: [],
       remDig: false,
       timeSortList:[],
-      typeStatusList:["待办","已完成",'/'],
-      formula:""
+     typeStatusList:["待办","已完成",'/'],
+      formula:"",
+      formulaList:""
     };
   },
   watch: {
@@ -746,6 +750,8 @@ ws['!page'] = {
             e.json = JSON.parse(e.json);
           });
 
+          data =  data.filter(e=>[this.timeA,this.timeB].includes(e.time))
+          // console.log("filterData",data)
           data.forEach(e => {
             e.evaSca = 0;
             e.cogSco = 0;
@@ -792,6 +798,8 @@ ws['!page'] = {
 
             if(testList.length>0){
               let _time = data[i].timeValue?JSON.parse(data[i].timeValue):{};
+              // console.log("testList",testList)
+              // console.log("await this.computedTest(testList,data[i].userid,_time)3")
               let testData = await this.computedTest(testList,data[i].userid,_time)
               let _isReviewNum = 0;
               let _num = 0;
@@ -913,6 +921,7 @@ ws['!page'] = {
 
       let _time = _test.timeValue?JSON.parse(_test.timeValue):{};
 
+      // console.log("this.computedTest(_testList,_test.userid,_time)1")
       let allTestData = await this.computedTest(_testList,_test.userid,_time)
 
 
@@ -1372,6 +1381,7 @@ ws['!page'] = {
       for (const e of this.PageBaseData) {
         for (const k of e.children) {
           if (k.testid.test.length > 0) {
+            // console.log("await this.computedTest(k.testid.test);")
             k.testid.test = await this.computedTest(k.testid.test);
           }
         }
@@ -1467,9 +1477,126 @@ ws['!page'] = {
           this.$message.success("保存成功")
         })
         .catch(error => {
-          console.log(error);
+         console.log(error);
+       });
+   },
+
+    fetchFormulaForList() {
+      if (this.formulaList) return Promise.resolve(this.formulaList);
+      let params = { typ: this.pType, org: this.org, oid: this.oid };
+      return this.ajax.get(this.$store.state.api + "selectTestExamineBase", params).then(res => {
+        this.formulaList = res.data[0][0].formula ? res.data[0][0].formula : "";
+        return this.formulaList;
+      }).catch(() => { this.formulaList = ""; return ""; });
+    },
+
+    computeFormulaForPerson(personJson, formula) {
+      if (!formula || !personJson) return "0.00";
+      let scoreObj = {};
+      personJson.forEach(i => {
+        if (!scoreObj[i.id]) scoreObj[i.id] = 0;
+        if (i.children) {
+          i.children.forEach(i2 => {
+            if (i2.sco2) scoreObj[i.id] += parseFloat(i2.sco2);
+          });
+        }
+      });
+      let formulaText = formula;
+      for (const key in scoreObj) {
+        if (scoreObj.hasOwnProperty(key)) {
+          const regex = new RegExp("\\b" + key + "\\b", "g");
+          formulaText = formulaText.replace(regex, scoreObj[key]);
+        }
+      }
+      if (!formulaText) return "0.00";
+      try {
+        return eval(formulaText).toFixed(2);
+      } catch (e) {
+        return "0.00";
+      }
+    },
+
+    exportListExcel() {
+      this.fetchFormulaForList().then(formula => {
+        // 解析 rjson 的辅助函数
+        const parseRjson = (person) => {
+          if (!person.rjson) return null;
+          try {
+            let obj = typeof person.rjson === "string" ? JSON.parse(person.rjson) : person.rjson;
+            return obj.rjson || null;
+          } catch (e) {
+            return null;
+          }
+        };
+
+        // 收集所有唯一的二级指标(按首次出现顺序)
+        let indicatorMap = new Map();
+        this.copyTableData.forEach(person => {
+          let rjsonArr = parseRjson(person);
+          if (rjsonArr && Array.isArray(rjsonArr)) {
+            rjsonArr.forEach(firstLevel => {
+              if (firstLevel.children && Array.isArray(firstLevel.children)) {
+                firstLevel.children.forEach(secondLevel => {
+                  if (!indicatorMap.has(secondLevel.id)) {
+                    indicatorMap.set(secondLevel.id, {
+                      id: secondLevel.id,
+                      name: secondLevel.name
+                    });
+                  }
+                });
+              }
+            });
+          }
         });
-    }
+        let indicators = Array.from(indicatorMap.values());
+
+        let headers = [["教师名称", ...indicators.map(i => i.name), "认定分", "加权分"]];
+        let rows = this.copyTableData.map(person => {
+          // 构建该人员的 sco2 映射
+          let sco2Map = {};
+          let rjsonArr = parseRjson(person);
+          let personJson = person.json;
+          if (rjsonArr && Array.isArray(rjsonArr)) {
+            rjsonArr.forEach(firstLevel => {
+              if (firstLevel.children && Array.isArray(firstLevel.children)) {
+                firstLevel.children.forEach(secondLevel => {
+                  sco2Map[secondLevel.id] = secondLevel.sco2 || "";
+                });
+              }
+            });
+          }
+          let indicatorScores = indicators.map(ind =>
+            sco2Map[ind.id] !== undefined ? sco2Map[ind.id] : ""
+          );
+          let cogSco = person.cogSco || "0.00";
+          let formulaScore = "0.00";
+          if (formula && personJson) {
+            formulaScore = this.computeFormulaForPerson(personJson, formula);
+          }
+          return [person.username || "", ...indicatorScores, cogSco, formulaScore];
+        });
+        let data = [...headers, ...rows];
+        const wb = XLSX.utils.book_new();
+        let ws = XLSX.utils.aoa_to_sheet(data);
+        const commonStyle = {
+          alignment: { horizontal: "center", vertical: "center", wrapText: true },
+          font: { sz: 12 }
+        };
+        for (let r = 0; r < data.length; ++r) {
+          for (let c = 0; c < data[r].length; ++c) {
+            const cellRef = XLSX.utils.encode_cell({ r, c });
+            ws[cellRef] = ws[cellRef] || {};
+            ws[cellRef].s = commonStyle;
+          }
+        }
+        let colWidths = [{ wch: 20 }];
+        indicators.forEach(() => colWidths.push({ wch: 14 }));
+        colWidths.push({ wch: 12 }, { wch: 12 });
+        ws["!cols"] = colWidths;
+        XLSX.utils.book_append_sheet(wb, ws, "考核列表");
+        XLSX.writeFile(wb, "考核数据导出.xlsx");
+      });
+    },
   }
 };
 </script>

+ 84 - 79
src/components/pages/workPage/components/kwlPage.vue

@@ -10,7 +10,10 @@
         <div class="kwlHeaderRight" @click="showHowTo = !showHowTo">
           <span>{{ lang.ssKWLHowTo }}</span>
           <i :class="['kwlArrow', { 'kwlArrowUp': showHowTo }]">
-            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4 transition-transform "><polyline points="6 9 12 15 18 9"></polyline></svg>
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+              class="w-4 h-4 transition-transform ">
+              <polyline points="6 9 12 15 18 9"></polyline>
+            </svg>
           </i>
         </div>
       </div>
@@ -50,20 +53,14 @@
     </div>
 
     <!-- 视频全屏播放弹窗 -->
-    <el-dialog
-      :visible.sync="videoDialogVisible"
-      :append-to-body="true"
-      :show-close="true"
-      custom-class="kwlVideoDialog"
-      width="80%"
-      @closed="onVideoDialogClosed"
-    >
+    <el-dialog :visible.sync="videoDialogVisible" :append-to-body="true" :show-close="true"
+      custom-class="kwlVideoDialog" width="80%" @closed="onVideoDialogClosed">
       <div class="kwlVideoDialogBody" v-if="currentVideoUrl">
         <video :src="currentVideoUrl" controls autoplay class="kwlVideoFull"></video>
       </div>
     </el-dialog>
 
-    <!-- KWL 三列 
+    <!-- KWL 三列
             @keyup.enter="addItem('know')"
      -->
     <div class="kwlColumns">
@@ -80,11 +77,8 @@
           </div>
         </div>
         <div class="kwlAddRow">
-          <input
-            v-model="newKnow"
-            class="kwlInput"
-            :placeholder="work.kplaceholder ? work.kplaceholder : lang.ssKWLAddPlaceholder"
-          />
+          <input v-model="newKnow" class="kwlInput"
+            :placeholder="work.kplaceholder ? work.kplaceholder : lang.ssKWLAddPlaceholder" />
           <button class="kwlAddBtn kwlAddBtnKnow" @click="addItem('know')">+</button>
         </div>
       </div>
@@ -104,11 +98,8 @@
           </div>
         </div>
         <div class="kwlAddRow">
-          <input
-            v-model="newWant"
-            class="kwlInput"
-            :placeholder="work.wplaceholder ? work.wplaceholder : lang.ssKWLAddPlaceholder"
-          />
+          <input v-model="newWant" class="kwlInput"
+            :placeholder="work.wplaceholder ? work.wplaceholder : lang.ssKWLAddPlaceholder" />
           <button class="kwlAddBtn kwlAddBtnWant" @click="addItem('want')">+</button>
         </div>
       </div>
@@ -128,11 +119,8 @@
           </div>
         </div>
         <div class="kwlAddRow">
-          <input
-            v-model="newLearned"
-            class="kwlInput"
-            :placeholder="work.lplaceholder ? work.lplaceholder : lang.ssKWLAddPlaceholder"
-          />
+          <input v-model="newLearned" class="kwlInput"
+            :placeholder="work.lplaceholder ? work.lplaceholder : lang.ssKWLAddPlaceholder" />
           <button class="kwlAddBtn kwlAddBtnLearn" @click="addItem('learned')">+</button>
         </div>
       </div>
@@ -142,18 +130,11 @@
     <div class="kwlCard kwlReflection" v-if="isShowReflection">
       <div class="kwlRefTitle">{{ lang.ssKWLReflection }}</div>
       <div class="kwlRefAreaWrap">
-        <textarea
-          v-model="work.reflection"
-          class="kwlRefTextarea"
-          :placeholder="lang.ssKWLReflectionPH"
-        ></textarea>
+        <textarea v-model="work.reflection" class="kwlRefTextarea" :placeholder="lang.ssKWLReflectionPH"></textarea>
         <!-- 右下角麦克风按钮:点击开始/停止录音 -->
-        <div
-          class="kwlMicBtn"
-          :class="{ kwlMicBtnActive: recordObj.status == '1' }"
+        <div class="kwlMicBtn" :class="{ kwlMicBtnActive: recordObj.status == '1' }"
           :title="recordObj.status == '1' ? lang.ssKWLStopRec : lang.ssKWLVoiceInput"
-          @click="recordObj.status == '1' ? stopRecord() : startRecord()"
-        >
+          @click="recordObj.status == '1' ? stopRecord() : startRecord()">
           <!-- 录音中:声波动画 -->
           <svg v-if="recordObj.status == '1'" width="18" height="18" viewBox="0 0 120 20">
             <rect x="5" y="5" width="8" :height="10" rx="3" fill="currentColor">
@@ -214,9 +195,9 @@ export default {
         taskDescription: "",
         taskUrl: [],
         showHowTo: false,
-        kplaceholder:"",
-        wplaceholder:"",
-        lplaceholder:"",
+        kplaceholder: "",
+        wplaceholder: "",
+        lplaceholder: "",
         isShowReflection: true
       },
       newKnow: "",
@@ -272,9 +253,9 @@ export default {
             taskDescription: "",
             taskUrl: [],
             showHowTo: false,
-            kplaceholder:"",
-            wplaceholder:"",
-            lplaceholder:"",
+            kplaceholder: "",
+            wplaceholder: "",
+            lplaceholder: "",
             isShowReflection: true
           }, _data);
 
@@ -287,6 +268,11 @@ export default {
     }
   },
   methods: {
+    // 清理 iframe 录音会话(统一清理入口)
+    _cleanupTranscriber(iframe) {
+      iframe.contentWindow.onSessionStopped = null;
+      iframe.contentWindow.window.onRecognizedResult = null;
+    },
     changeWorkData(newValue) {
       const str = JSON.stringify(newValue);
       this.$emit("changeWorkData", str);
@@ -328,16 +314,18 @@ export default {
         this.$message.error(this.lang.ssKWLRecServiceNotReady);
         return;
       }
-      try {
-        if(this.lang.lang == 'com'){
+
+      const startTranscriber = () => {
+        // try {
+        if (this.lang.lang == 'com') {
           iframe.contentWindow.window.document.getElementById(
             "languageOptions"
           ).selectedIndex = 8;
-        }else if(this.lang.lang == 'hk'){
+        } else if (this.lang.lang == 'hk') {
           iframe.contentWindow.window.document.getElementById(
             "languageOptions"
           ).selectedIndex = 4;
-        }else{
+        } else {
           iframe.contentWindow.window.document.getElementById(
             "languageOptions"
           ).selectedIndex = 2;
@@ -358,23 +346,44 @@ export default {
         iframe.contentWindow.ConversationTranscriber();
         this.recordObj.status = "1";
         this.recordObj.startTime = new Date().getTime();
-        setTimeout(() => {
-          navigator.permissions && navigator.permissions.query({ name: 'microphone' }).then(permissionStatus => {
-            if (permissionStatus.state !== "granted") {
-              this.recordObj.status = "0";
-              this.recordObj.loading = false;
-              iframe.contentWindow.onSessionStopped = null;
-              iframe.contentWindow.window.onRecognizedResult = null;
-              this.$message.warning(this.lang.ssKWLNoPermStop);
-              return;
-            }
-          }).catch(() => {});
-        }, 2000);
-      } catch (err) {
-        console.error("startRecord error:", err);
-        this.$message.error(this.lang.ssKWLRecStartFail);
-        this.recordObj.status = "0";
+
+      }
+      // 先通过 getUserMedia 主动请求麦克风权限,避免 ConversationTranscriber
+      // 在浏览器权限弹窗期间被中断导致内部状态异常
+      if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+        navigator.mediaDevices.getUserMedia({ audio: true })
+          .then(stream => {
+            // 权限已获取,立即释放 stream,让 ConversationTranscriber 自己接管麦克风
+            stream.getTracks().forEach(t => t.stop());
+            startTranscriber();
+          })
+          .catch(() => {
+            // 用户拒绝权限
+            this._cleanupTranscriber(iframe);
+            this.$message.success(this.lang.ssNoPermStop);
+          });
+      } else {
+        // 降级:不支持 getUserMedia 的浏览器直接启动
+        startTranscriber();
       }
+
+
+      setTimeout(() => {
+        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.warning(this.lang.ssKWLNoPermStop);
+            return;
+          }
+        }).catch(() => { });
+      }, 2000);
+      // } catch (err) {
+      //   console.error("startRecord error:", err);
+      //   this.$message.error(this.lang.ssKWLRecStartFail);
+      //   this.recordObj.status = "0";
+      // }
     },
     // 停止录音
     stopRecord() {
@@ -382,20 +391,19 @@ export default {
         this.$message.info(this.lang.ssKWLRecordShort);
         return;
       }
+      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;
-          let iframe = this.$refs["iframeRef"];
           if (iframe && iframe.contentWindow) {
-            iframe.contentWindow.onSessionStopped = null;
-            iframe.contentWindow.window.onRecognizedResult = null;
+            this._cleanupTranscriber(iframe);
           }
           this.$message.warning(this.lang.ssKWLNoMicPerm);
           return;
         }
         if (this.recordObj.status == '1' && !this.recordObj.loading) {
-          let iframe = this.$refs["iframeRef"];
+          // let iframe = this.$refs["iframeRef"];
           if (!iframe || !iframe.contentWindow) {
             this.recordObj.status = "0";
             this.recordObj.loading = false;
@@ -416,16 +424,14 @@ export default {
             this.recordObj.status = "0";
             this.recordObj.loading = false;
             this.$message.success(this.lang.ssKWLRecStopped);
-            iframe.contentWindow.onSessionStopped = null;
-            iframe.contentWindow.window.onRecognizedResult = null;
+            this._cleanupTranscriber(iframe);
           };
         } else {
           this.recordObj.status = "0";
           this.recordObj.loading = false;
           let iframe = this.$refs["iframeRef"];
           if (iframe && iframe.contentWindow) {
-            iframe.contentWindow.onSessionStopped = null;
-            iframe.contentWindow.window.onRecognizedResult = null;
+            this._cleanupTranscriber(iframe);
           }
         }
       }).catch(() => {
@@ -433,15 +439,14 @@ export default {
         this.recordObj.loading = false;
         let iframe = this.$refs["iframeRef"];
         if (iframe && iframe.contentWindow) {
-          iframe.contentWindow.onSessionStopped = null;
-          iframe.contentWindow.window.onRecognizedResult = null;
+          this._cleanupTranscriber(iframe);
         }
         this.$message.success(this.lang.ssKWLRecStopped);
       });
     }
   },
-  mounted() {},
-  beforeUnmount() {}
+  mounted() { },
+  beforeUnmount() { }
 };
 </script>
 
@@ -658,17 +663,17 @@ export default {
 }
 
 /* 视频全屏弹窗 */
-.kwlVideoDialog >>> .el-dialog {
+.kwlVideoDialog>>>.el-dialog {
   background: transparent;
   box-shadow: none;
 }
 
-.kwlVideoDialog >>> .el-dialog__header {
+.kwlVideoDialog>>>.el-dialog__header {
   padding: 0;
   margin: 0;
 }
 
-.kwlVideoDialog >>> .el-dialog__headerbtn {
+.kwlVideoDialog>>>.el-dialog__headerbtn {
   top: -2.5rem;
   right: -2.5rem;
   width: 2.5rem;
@@ -678,17 +683,17 @@ export default {
   z-index: 10;
 }
 
-.kwlVideoDialog >>> .el-dialog__close {
+.kwlVideoDialog>>>.el-dialog__close {
   color: #fff;
   font-size: 1.5rem;
 }
 
-.kwlVideoDialog >>> .el-dialog__body {
+.kwlVideoDialog>>>.el-dialog__body {
   padding: 0;
   background: transparent;
 }
 
-.kwlVideoDialog >>> .el-dialog__headerbtn:hover .el-dialog__close {
+.kwlVideoDialog>>>.el-dialog__headerbtn:hover .el-dialog__close {
   color: #fff;
 }
 

+ 62 - 53
src/components/pages/workPage/components/questionsAndAnswers.vue

@@ -116,6 +116,11 @@ export default {
     }
   },
   methods: {
+    // 清理 iframe 录音会话(统一清理入口)
+    _cleanupTranscriber(iframe) {
+      iframe.contentWindow.onSessionStopped = null;
+      iframe.contentWindow.window.onRecognizedResult = null;
+    },
     // 更新作业
     changeWorkData(newValue) {
       this.$emit("changeWorkData", JSON.stringify(newValue));
@@ -124,52 +129,64 @@ export default {
     changeAnswer(newValue) {
       this.work.answer = newValue;
     },
-    // 开始录音
+    // 开始录音 — 先请求权限,权限通过后再启动转录
     startRecord() {
       let iframe = this.$refs["iframeRef"];
-      if(this.lang.lang == 'com'){
-        iframe.contentWindow.window.document.getElementById(
-          "languageOptions"
-        ).selectedIndex = 8;
-      }else if(this.lang.lang == 'hk'){
-        iframe.contentWindow.window.document.getElementById(
-          "languageOptions"
-        ).selectedIndex = 4;
-      }else{
-        iframe.contentWindow.window.document.getElementById(
-          "languageOptions"
-        ).selectedIndex = 2;
-      }
+      const langMap = { com: 8, hk: 4 };
+      const idx = langMap[this.lang.lang] || 2;
+      iframe.contentWindow.window.document.getElementById("languageOptions").selectedIndex = idx;
 
-      iframe.contentWindow.window.onRecognizedResult = e => {
-        console.log("onRecognizedResult", e)
-        let privText = e.privText;
-        console.log("👇转译对象👇");
-        console.log(e);
-        console.log("👇转译结果👇");
-        console.log(privText);
-        if (!privText) {
-          return;
-        }
+      const startTranscriber = () => {
+        iframe.contentWindow.window.onRecognizedResult = e => {
+          console.log("onRecognizedResult", e)
+          let privText = e.privText;
+          console.log("👇转译对象👇");
+          console.log(e);
+          console.log("👇转译结果👇");
+          console.log(privText);
+          if (!privText) {
+            return;
+          }
 
-        // 获取wangEnduit富文本组件实例
-        const editorComponent = this.$refs.wangEnduitRef;
-        if (editorComponent && editorComponent.editor) {
-          // 获取当前光标位置并插入文本
-          editorComponent.editor.cmd.do('insertText', privText);
-          // 同步内容到work.answer
-          this.work.answer = editorComponent.editor.txt.html();
-        } else {
-          // 如果获取不到富文本实例,则直接追加
-          this.work.answer += privText;
-        }
-        console.log(privText)
+          // 获取wangEnduit富文本组件实例
+          const editorComponent = this.$refs.wangEnduitRef;
+          if (editorComponent && editorComponent.editor) {
+            // 获取当前光标位置并插入文本
+            editorComponent.editor.cmd.do('insertText', privText);
+            // 同步内容到work.answer
+            this.work.answer = editorComponent.editor.txt.html();
+          } else {
+            // 如果获取不到富文本实例,则直接追加
+            this.work.answer += privText;
+          }
+          console.log(privText)
+        };
 
+        iframe.contentWindow.ConversationTranscriber();
+        this.recordObj.status = "1"
+        this.recordObj.startTime = new Date().getTime();
       };
 
-      iframe.contentWindow.ConversationTranscriber();
-      this.recordObj.status = "1"
-      this.recordObj.startTime = new Date().getTime();
+      // 先通过 getUserMedia 主动请求麦克风权限,避免 ConversationTranscriber
+      // 在浏览器权限弹窗期间被中断导致内部状态异常
+      if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+        navigator.mediaDevices.getUserMedia({ audio: true })
+          .then(stream => {
+            // 权限已获取,立即释放 stream,让 ConversationTranscriber 自己接管麦克风
+            stream.getTracks().forEach(t => t.stop());
+            startTranscriber();
+          })
+          .catch(() => {
+            // 用户拒绝权限
+            this._cleanupTranscriber(iframe);
+            this.$message.success(this.lang.ssNoPermStop);
+          });
+      } else {
+        // 降级:不支持 getUserMedia 的浏览器直接启动
+        startTranscriber();
+      }
+
+      // 预留 2 秒后的兜底权限检查(处理用户后续在浏览器设置中撤销权限的情况)
       setTimeout(() => {
         navigator.permissions && navigator.permissions.query({ name: 'microphone' }).then(permissionStatus => {
           if (permissionStatus.state !== "granted") {
@@ -177,7 +194,7 @@ export default {
             this.recordObj.status = "0";
             this.recordObj.loading = false;
             iframe.contentWindow.onSessionStopped = null;
-            iframe.contentWindow.window.onRecognizedResult = null;
+            this._cleanupTranscriber(iframe);
             this.$message.success(this.lang.ssNoPermStop);
             return;
           }
@@ -189,16 +206,14 @@ export default {
       if (new Date().getTime() - this.recordObj.startTime < 2000) {
         return this.$message.info(this.lang.ssRecordShort)
       }
+      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;
-          let iframe = this.$refs["iframeRef"];
-          iframe.contentWindow.onSessionStopped = null;
-          iframe.contentWindow.window.onRecognizedResult = null;
-
+          this._cleanupTranscriber(iframe);
           this.$message.success(this.lang.ssNoPermStop);
           return;
         }
@@ -206,7 +221,6 @@ export default {
         // 有权限再判断状态
         if (this.recordObj.status == '1' && !this.recordObj.loading) {
           console.log("测试测3333")
-          let iframe = this.$refs["iframeRef"];
           this.recordObj.loading = true;
           iframe.contentWindow.window.document
             .getElementById("scenarioStopButton")
@@ -216,26 +230,21 @@ export default {
             this.recordObj.loading = false;
             this.$message.success(this.lang.ssStoppedRec)
             iframe.contentWindow.onSessionStopped = null;
-            iframe.contentWindow.window.onRecognizedResult = null;
+            this._cleanupTranscriber(iframe);
           };
         } else {
           this.recordObj.status = "0";
           this.recordObj.loading = false;
-          let iframe = this.$refs["iframeRef"];
-          iframe.contentWindow.onSessionStopped = null;
-          iframe.contentWindow.window.onRecognizedResult = null;
+          this._cleanupTranscriber(iframe);
         }
       }).catch(() => {
         console.log("测试测4444试")
         // 浏览器不支持或异常,默认直接停止
         this.recordObj.status = "0";
         this.recordObj.loading = false;
-        let iframe = this.$refs["iframeRef"];
-        iframe.contentWindow.onSessionStopped = null;
-        iframe.contentWindow.window.onRecognizedResult = null;
+        this._cleanupTranscriber(this.$refs["iframeRef"]);
         this.$message.success(this.lang.ssMicCheckStop);
       });
-      // this.recordObj.status = "0"
     }
   },
   mounted() {

+ 78 - 27
src/components/pages/workPage/components/sixHatPage.vue

@@ -156,7 +156,8 @@
               <button v-for="(opt, idx) in timerPresets" :key="idx" class="shTimerOpt"
                 :class="{ shTimerOptActive: work.timerDuration === opt.value }"
                 @click="work.timerDuration = opt.value">{{ opt.label }}</button>
-              <button class="shTimerOpt" :class="{ shTimerOptActive: timerCustomFocused }" @click="focusTimerCustom">{{ lang.ssSHTimerCustom }}</button>
+              <button class="shTimerOpt" :class="{ shTimerOptActive: timerCustomFocused }" @click="focusTimerCustom">{{
+                lang.ssSHTimerCustom }}</button>
               <input v-if="timerCustomFocused" type="number" min="1" class="shTimerCustomInput"
                 v-model.number="work.timerCustomValue" @blur="onTimerCustomBlur" @keyup.enter="onTimerCustomBlur" />
             </div>
@@ -206,7 +207,13 @@
                   d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z">
                 </path>
               </svg>
-              <svg v-else-if="hat.key === 'black'" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert w-4 h-4 text-white" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>
+              <svg v-else-if="hat.key === 'black'" xmlns="http://www.w3.org/2000/svg" width="28" height="28"
+                viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"
+                stroke-linejoin="round" class="lucide lucide-triangle-alert w-4 h-4 text-white" aria-hidden="true">
+                <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
+                <path d="M12 9v4"></path>
+                <path d="M12 17h.01"></path>
+              </svg>
               <svg v-else-if="hat.key === 'yellow'" viewBox="0 0 24 24" width="28" height="28" fill="none"
                 stroke="#ca8a04" stroke-width="2">
                 <polygon
@@ -282,8 +289,11 @@
       <!-- 任务说明:有讨论主题时显示 -->
       <div class="shTaskDescCard" v-if="work.stage === 'activity' && work.scenario && work.scenario.trim()">
         <div class="shTaskDescIcon">
-          <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#F5A623" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-            <path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"></path>
+          <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#F5A623" stroke-width="2"
+            stroke-linecap="round" stroke-linejoin="round">
+            <path
+              d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z">
+            </path>
           </svg>
         </div>
         <div class="shTaskDescBody">
@@ -320,7 +330,13 @@
                 d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z">
               </path>
             </svg>
-            <svg v-else-if="currentHat.key === 'black'" xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert w-4 h-4 text-white" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>
+            <svg v-else-if="currentHat.key === 'black'" xmlns="http://www.w3.org/2000/svg" width="44" height="44"
+              viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"
+              stroke-linejoin="round" class="lucide lucide-triangle-alert w-4 h-4 text-white" aria-hidden="true">
+              <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
+              <path d="M12 9v4"></path>
+              <path d="M12 17h.01"></path>
+            </svg>
             <svg v-else-if="currentHat.key === 'yellow'" viewBox="0 0 24 24" width="44" height="44" fill="none"
               stroke="#ca8a04" stroke-width="2">
               <polygon
@@ -345,7 +361,7 @@
           <div class="shActivityHatDesc">{{ currentHat.desc }}</div>
           <div class="shActivityHatProgress">{{ lang.ssSHHatProgress.replace('{n}', currentHatIdx +
             1).replace('{total}',
-            selectedHats.length) }}</div>
+              selectedHats.length) }}</div>
           <!-- 引导问题 -->
           <div class="shActivityHatPrompt">{{ currentHatPrompt }}</div>
         </div>
@@ -419,7 +435,7 @@
         <button class="shSecondaryBtn" :class="{ shSecondaryBtnDisabled: currentHatIdx === 0 }"
           :disabled="currentHatIdx === 0" @click="prevHat">{{ lang.ssSHPrevHat }}</button>
         <button class="shPrimaryBtn" @click="nextOrFinish">{{ isLastHat ? lang.ssSHFinish : lang.ssSHNextHat
-          }}</button>
+        }}</button>
       </div>
     </div>
 
@@ -446,7 +462,13 @@
                 d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z">
               </path>
             </svg>
-            <svg v-else-if="hat.key === 'black'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert w-4 h-4 text-white" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>
+            <svg v-else-if="hat.key === 'black'" xmlns="http://www.w3.org/2000/svg" width="24" height="24"
+              viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"
+              stroke-linejoin="round" class="lucide lucide-triangle-alert w-4 h-4 text-white" aria-hidden="true">
+              <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
+              <path d="M12 9v4"></path>
+              <path d="M12 17h.01"></path>
+            </svg>
             <svg v-else-if="hat.key === 'yellow'" viewBox="0 0 24 24" width="22" height="22" fill="none"
               stroke="#ca8a04" stroke-width="2">
               <polygon
@@ -776,6 +798,11 @@ export default {
     },
   },
   methods: {
+    // 清理 iframe 录音会话(统一清理入口)
+    _cleanupTranscriber(iframe) {
+      iframe.contentWindow.onSessionStopped = null;
+      iframe.contentWindow.window.onRecognizedResult = null;
+    },
     // —— 加载班级学生(接口无数据时保持 mock)——
     async loadClassStudents() {
       try {
@@ -960,7 +987,8 @@ export default {
         this.$message.error(this.lang.ssSHRecServiceNotReady);
         return;
       }
-      try {
+      // try {
+      const startTranscriber = () => {
         if (this.lang.lang === 'com') iframe.contentWindow.window.document.getElementById('languageOptions').selectedIndex = 8;
         else if (this.lang.lang === 'hk') iframe.contentWindow.window.document.getElementById('languageOptions').selectedIndex = 4;
         else iframe.contentWindow.window.document.getElementById('languageOptions').selectedIndex = 2;
@@ -977,23 +1005,45 @@ export default {
         iframe.contentWindow.ConversationTranscriber();
         this.recordObj.status = '1';
         this.recordObj.startTime = new Date().getTime();
-        setTimeout(() => {
-          if (!navigator.permissions) return;
-          navigator.permissions.query({ name: 'microphone' }).then(ps => {
-            if (ps.state !== 'granted') {
-              this.recordObj.status = '0';
-              this.recordObj.loading = false;
-              iframe.contentWindow.onSessionStopped = null;
-              iframe.contentWindow.window.onRecognizedResult = null;
-              this.$message.warning(this.lang.ssSHNoPermStop);
-            }
-          }).catch(() => { });
-        }, 2000);
-      } catch (err) {
-        console.error('startRecord error:', err);
-        this.$message.error(this.lang.ssSHRecStartFail);
-        this.recordObj.status = '0';
       }
+      // 先通过 getUserMedia 主动请求麦克风权限,避免 ConversationTranscriber
+      // 在浏览器权限弹窗期间被中断导致内部状态异常
+      if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+        navigator.mediaDevices.getUserMedia({ audio: true })
+          .then(stream => {
+            // 权限已获取,立即释放 stream,让 ConversationTranscriber 自己接管麦克风
+            stream.getTracks().forEach(t => t.stop());
+            startTranscriber();
+          })
+          .catch(() => {
+            // 用户拒绝权限
+            this._cleanupTranscriber(iframe);
+            this.$message.success(this.lang.ssNoPermStop);
+          });
+      } else {
+        // 降级:不支持 getUserMedia 的浏览器直接启动
+        startTranscriber();
+      }
+
+
+      setTimeout(() => {
+        if (!navigator.permissions) return;
+        navigator.permissions.query({ name: 'microphone' }).then(ps => {
+          if (ps.state !== 'granted') {
+            this.recordObj.status = '0';
+            this.recordObj.loading = false;
+            this._cleanupTranscriber(iframe);
+            // iframe.contentWindow.onSessionStopped = null;
+            // iframe.contentWindow.window.onRecognizedResult = null;
+            this.$message.warning(this.lang.ssSHNoPermStop);
+          }
+        }).catch(() => { });
+      }, 2000);
+      // } catch (err) {
+      //   console.error('startRecord error:', err);
+      //   this.$message.error(this.lang.ssSHRecStartFail);
+      //   this.recordObj.status = '0';
+      // }
     },
     stopRecord() {
       if (new Date().getTime() - this.recordObj.startTime < 2000) {
@@ -1019,8 +1069,9 @@ export default {
         this.recordObj.status = '0';
         this.recordObj.loading = false;
         this.$message.success(this.lang.ssSHRecStopped);
-        iframe.contentWindow.onSessionStopped = null;
-        iframe.contentWindow.window.onRecognizedResult = null;
+        this._cleanupTranscriber(iframe);
+        // iframe.contentWindow.onSessionStopped = null;
+        // iframe.contentWindow.window.onRecognizedResult = null;
       };
     },
   },

部分文件因文件數量過多而無法顯示