Kaynağa Gözat

Merge branch 'wanzi'

# Conflicts:
#	.env
liyanbo 8 ay önce
ebeveyn
işleme
16ab382356

+ 6 - 6
.env

@@ -2,11 +2,11 @@
 VITE_APP_TITLE=AI课程网
 
 # 请求路径
-VITE_BASE_URL='http://59.110.91.129/admin-api'
-#VITE_BASE_URL='http://127.0.0.1/admin-api'
-#VITE_BASE_URL='http://192.168.110.8:8080/admin-api'
+#VITE_BASE_URL='http://59.110.91.129:8088/admin-api'
+#VITE_BASE_URL='https://learn-ai.com.cn/admin-api'
+VITE_BASE_URL='http://192.168.110.8:8080/admin-api'
 
 # 默认账户密码
-VITE_APP_DEFAULT_LOGIN_TENANT = 博雅智算
-VITE_APP_DEFAULT_LOGIN_USERNAME = aiTest
-VITE_APP_DEFAULT_LOGIN_PASSWORD = aiTest
+VITE_APP_DEFAULT_LOGIN_TENANT =
+VITE_APP_DEFAULT_LOGIN_USERNAME =
+VITE_APP_DEFAULT_LOGIN_PASSWORD =

+ 2 - 2
index.html

@@ -2,9 +2,9 @@
 <html lang="en">
   <head>
     <meta charset="UTF-8" />
-    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>ai通识课程平台</title>
+    <title>人工智能通识课平台</title>
   </head>
   <body>
     <div id="app"></div>

+ 520 - 117
package-lock.json

@@ -11,6 +11,9 @@
         "@element-plus/icons-vue": "^2.3.1",
         "@microsoft/fetch-event-source": "^2.0.1",
         "@vitejs/plugin-legacy": "^7.0.1",
+        "@vue-office/docx": "^1.6.3",
+        "@vue-office/excel": "^1.7.14",
+        "@vue-office/pptx": "^1.0.1",
         "axios": "^1.10.0",
         "element-plus": "^2.10.2",
         "highlight.js": "^11.11.1",
@@ -19,10 +22,13 @@
         "jsencrypt": "^3.3.2",
         "markdown-it": "^14.1.0",
         "router": "^2.2.0",
-        "video.js": "^8.23.3",
+        "video.js": "^7.21.5",
         "vue": "^3.5.17",
+        "vue-demi": "^0.14.10",
         "vue-router": "^4.5.1",
-        "vue3-video-play": "^1.3.2",
+        "vue-video-play": "^7.0.4",
+        "vue-video-player": "^6.0.0",
+        "vue3-video-play": "^1.3.1-beta.6",
         "vuex": "^4.0.2",
         "web-storage-cache": "^1.1.1"
       },
@@ -2823,43 +2829,62 @@
         "undici-types": "~7.8.0"
       }
     },
+    "node_modules/@types/video.js": {
+      "version": "7.3.58",
+      "resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.58.tgz",
+      "integrity": "sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==",
+      "license": "MIT",
+      "peer": true
+    },
     "node_modules/@types/web-bluetooth": {
       "version": "0.0.16",
       "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
       "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==",
       "license": "MIT"
     },
+    "node_modules/@videojs-player/vue": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@videojs-player/vue/-/vue-1.0.0.tgz",
+      "integrity": "sha512-WonTezRfKu3fYdQLt/ta+nuKH6gMZUv8l40Jke/j4Lae7IqeO/+lLAmBnh3ni88bwR+vkFXIlZ2Ci7VKInIYJg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/video.js": "7.x",
+        "video.js": "7.x",
+        "vue": "3.x"
+      }
+    },
     "node_modules/@videojs/http-streaming": {
-      "version": "3.17.0",
-      "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.0.tgz",
-      "integrity": "sha512-Ch1P3tvvIEezeZXyK11UfWgp4cWKX4vIhZ30baN/lRinqdbakZ5hiAI3pGjRy3d+q/Epyc8Csz5xMdKNNGYpcw==",
+      "version": "2.16.2",
+      "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-2.16.2.tgz",
+      "integrity": "sha512-etPTUdCFu7gUWc+1XcbiPr+lrhOcBu3rV5OL1M+3PDW89zskScAkkcdqYzP4pFodBPye/ydamQoTDScOnElw5A==",
       "license": "Apache-2.0",
       "dependencies": {
         "@babel/runtime": "^7.12.5",
-        "@videojs/vhs-utils": "^4.1.1",
-        "aes-decrypter": "^4.0.2",
+        "@videojs/vhs-utils": "3.0.5",
+        "aes-decrypter": "3.1.3",
         "global": "^4.4.0",
-        "m3u8-parser": "^7.2.0",
-        "mpd-parser": "^1.3.1",
-        "mux.js": "7.1.0",
-        "video.js": "^7 || ^8"
+        "m3u8-parser": "4.8.0",
+        "mpd-parser": "^0.22.1",
+        "mux.js": "6.0.1",
+        "video.js": "^6 || ^7"
       },
       "engines": {
         "node": ">=8",
         "npm": ">=5"
       },
       "peerDependencies": {
-        "video.js": "^8.19.0"
+        "video.js": "^6 || ^7"
       }
     },
     "node_modules/@videojs/vhs-utils": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz",
-      "integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==",
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz",
+      "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==",
       "license": "MIT",
       "dependencies": {
         "@babel/runtime": "^7.12.5",
-        "global": "^4.4.0"
+        "global": "^4.4.0",
+        "url-toolkit": "^2.2.1"
       },
       "engines": {
         "node": ">=8",
@@ -2867,9 +2892,9 @@
       }
     },
     "node_modules/@videojs/xhr": {
-      "version": "2.7.0",
-      "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz",
-      "integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==",
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.6.0.tgz",
+      "integrity": "sha512-7J361GiN1tXpm+gd0xz2QWr3xNWBE+rytvo8J3KuggFaLg+U37gZQ2BuPLcnkfGffy2e+ozY70RHC8jt7zjA6Q==",
       "license": "MIT",
       "dependencies": {
         "@babel/runtime": "^7.5.5",
@@ -2922,6 +2947,57 @@
         "vue": "^3.2.25"
       }
     },
+    "node_modules/@vue-office/docx": {
+      "version": "1.6.3",
+      "resolved": "https://registry.npmjs.org/@vue-office/docx/-/docx-1.6.3.tgz",
+      "integrity": "sha512-Cs+3CAaRBOWOiW4XAhTwwxJ0dy8cPIf6DqfNvYcD3YACiLwO4kuawLF2IAXxyijhbuOeoFsfvoVbOc16A/4bZA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@vue/composition-api": "^1.7.1",
+        "vue": "^2.0.0 || >=3.0.0",
+        "vue-demi": "^0.14.6"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue-office/excel": {
+      "version": "1.7.14",
+      "resolved": "https://registry.npmjs.org/@vue-office/excel/-/excel-1.7.14.tgz",
+      "integrity": "sha512-pVUgt+emDQUnW7q22CfnQ+jl43mM/7IFwYzOg7lwOwPEbiVB4K4qEQf+y/bc4xGXz75w1/e3Kz3G6wAafmFBFg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@vue/composition-api": "^1.7.1",
+        "vue": "^2.0.0 || >=3.0.0",
+        "vue-demi": "^0.14.6"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue-office/pptx": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@vue-office/pptx/-/pptx-1.0.1.tgz",
+      "integrity": "sha512-+V7Kctzl6f6+Yk4NaD/wQGRIkqLWcowe0jEhPexWQb8Oilbzt1OyhWRWcMsxNDTdrgm6aMLP+0/tmw27cxddMg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@vue/composition-api": "^1.7.1",
+        "vue": "^2.0.0 || >=3.0.0",
+        "vue-demi": "^0.14.6"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@vue/compiler-core": {
       "version": "3.5.17",
       "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.17.tgz",
@@ -3043,32 +3119,6 @@
         "url": "https://github.com/sponsors/antfu"
       }
     },
-    "node_modules/@vueuse/core/node_modules/vue-demi": {
-      "version": "0.14.10",
-      "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
-      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
-      "hasInstallScript": true,
-      "license": "MIT",
-      "bin": {
-        "vue-demi-fix": "bin/vue-demi-fix.js",
-        "vue-demi-switch": "bin/vue-demi-switch.js"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/antfu"
-      },
-      "peerDependencies": {
-        "@vue/composition-api": "^1.0.0-rc.1",
-        "vue": "^3.0.0-0 || ^2.6.0"
-      },
-      "peerDependenciesMeta": {
-        "@vue/composition-api": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/@vueuse/metadata": {
       "version": "9.13.0",
       "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz",
@@ -3090,32 +3140,6 @@
         "url": "https://github.com/sponsors/antfu"
       }
     },
-    "node_modules/@vueuse/shared/node_modules/vue-demi": {
-      "version": "0.14.10",
-      "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
-      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
-      "hasInstallScript": true,
-      "license": "MIT",
-      "bin": {
-        "vue-demi-fix": "bin/vue-demi-fix.js",
-        "vue-demi-switch": "bin/vue-demi-switch.js"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/antfu"
-      },
-      "peerDependencies": {
-        "@vue/composition-api": "^1.0.0-rc.1",
-        "vue": "^3.0.0-0 || ^2.6.0"
-      },
-      "peerDependenciesMeta": {
-        "@vue/composition-api": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/@xmldom/xmldom": {
       "version": "0.8.10",
       "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@@ -3148,13 +3172,13 @@
       }
     },
     "node_modules/aes-decrypter": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz",
-      "integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==",
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-3.1.3.tgz",
+      "integrity": "sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==",
       "license": "Apache-2.0",
       "dependencies": {
         "@babel/runtime": "^7.12.5",
-        "@videojs/vhs-utils": "^4.1.1",
+        "@videojs/vhs-utils": "^3.0.5",
         "global": "^4.4.0",
         "pkcs7": "^1.0.4"
       }
@@ -3260,6 +3284,30 @@
         "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
       }
     },
+    "node_modules/babel-runtime": {
+      "version": "6.26.0",
+      "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
+      "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
+      "license": "MIT",
+      "dependencies": {
+        "core-js": "^2.4.0",
+        "regenerator-runtime": "^0.11.0"
+      }
+    },
+    "node_modules/babel-runtime/node_modules/core-js": {
+      "version": "2.6.12",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
+      "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
+      "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
+      "hasInstallScript": true,
+      "license": "MIT"
+    },
+    "node_modules/babel-runtime/node_modules/regenerator-runtime": {
+      "version": "0.11.1",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
+      "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
+      "license": "MIT"
+    },
     "node_modules/balanced-match": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3710,6 +3758,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/es5-shim": {
+      "version": "4.6.7",
+      "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.7.tgz",
+      "integrity": "sha512-jg21/dmlrNQI7JyyA2w7n+yifSxBng0ZralnSfVZjoCawgNTCnS+yBCyVM9DL5itm7SUnDGgv7hcq2XCZX4iRQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
     "node_modules/esbuild": {
       "version": "0.25.5",
       "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.5.tgz",
@@ -4339,6 +4396,11 @@
         "node": ">=0.8.19"
       }
     },
+    "node_modules/individual": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz",
+      "integrity": "sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g=="
+    },
     "node_modules/inherits": {
       "version": "2.0.3",
       "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.3.tgz",
@@ -4492,6 +4554,12 @@
         "node": ">=6"
       }
     },
+    "node_modules/keycode": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz",
+      "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==",
+      "license": "MIT"
+    },
     "node_modules/keyv": {
       "version": "4.5.4",
       "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4587,13 +4655,13 @@
       }
     },
     "node_modules/m3u8-parser": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz",
-      "integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==",
+      "version": "4.8.0",
+      "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-4.8.0.tgz",
+      "integrity": "sha512-UqA2a/Pw3liR6Df3gwxrqghCP17OpPlQj6RBPLYygf/ZSQ4MoSgvdvhvt35qV+3NaaA0FSZx93Ix+2brT1U7cA==",
       "license": "Apache-2.0",
       "dependencies": {
         "@babel/runtime": "^7.12.5",
-        "@videojs/vhs-utils": "^4.1.1",
+        "@videojs/vhs-utils": "^3.0.5",
         "global": "^4.4.0"
       }
     },
@@ -4726,13 +4794,13 @@
       }
     },
     "node_modules/mpd-parser": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz",
-      "integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==",
+      "version": "0.22.1",
+      "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.22.1.tgz",
+      "integrity": "sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==",
       "license": "Apache-2.0",
       "dependencies": {
         "@babel/runtime": "^7.12.5",
-        "@videojs/vhs-utils": "^4.0.0",
+        "@videojs/vhs-utils": "^3.0.5",
         "@xmldom/xmldom": "^0.8.3",
         "global": "^4.4.0"
       },
@@ -4747,9 +4815,9 @@
       "license": "MIT"
     },
     "node_modules/mux.js": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz",
-      "integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-6.0.1.tgz",
+      "integrity": "sha512-22CHb59rH8pWGcPGW5Og7JngJ9s+z4XuSlYvnxhLuc58cA1WqGDQPzuG8I+sPm1/p0CdgpzVTaKW408k5DNn8w==",
       "license": "Apache-2.0",
       "dependencies": {
         "@babel/runtime": "^7.11.2",
@@ -4820,6 +4888,15 @@
         "url": "https://github.com/fb55/nth-check?sponsor=1"
       }
     },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/optionator": {
       "version": "0.9.4",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4883,6 +4960,12 @@
         "node": ">=6"
       }
     },
+    "node_modules/parse-headers": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
+      "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
+      "license": "MIT"
+    },
     "node_modules/parseurl": {
       "version": "1.3.3",
       "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
@@ -5224,6 +5307,23 @@
         "node": ">= 18"
       }
     },
+    "node_modules/rust-result": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz",
+      "integrity": "sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==",
+      "license": "MIT",
+      "dependencies": {
+        "individual": "^2.0.0"
+      }
+    },
+    "node_modules/safe-json-parse": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz",
+      "integrity": "sha512-RjZPPHugjK0TOzFrLZ8inw44s9bKox99/0AZW9o/BEQVrJfhI+fIHMErnPyRa89/yRXUUr93q+tiN6zhoVV4wQ==",
+      "dependencies": {
+        "rust-result": "^1.0.0"
+      }
+    },
     "node_modules/sass": {
       "version": "1.89.2",
       "resolved": "https://registry.npmmirror.com/sass/-/sass-1.89.2.tgz",
@@ -5408,6 +5508,13 @@
         "node": ">=8.0"
       }
     },
+    "node_modules/tsml": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/tsml/-/tsml-1.0.1.tgz",
+      "integrity": "sha512-3KmepnH9SUsoOVtg013CRrL7c+AK7ECaquAsJdvu4288EDJuraqBlP4PDXT/rLEJ9YDn4jqLAzRJsnFPx+V6lg==",
+      "deprecated": "no longer maintained",
+      "license": "MIT"
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -5514,6 +5621,12 @@
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/url-toolkit": {
+      "version": "2.2.5",
+      "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz",
+      "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==",
+      "license": "Apache-2.0"
+    },
     "node_modules/util": {
       "version": "0.10.4",
       "resolved": "https://registry.npmmirror.com/util/-/util-0.10.4.tgz",
@@ -5532,47 +5645,235 @@
       "license": "MIT"
     },
     "node_modules/video.js": {
-      "version": "8.23.3",
-      "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.3.tgz",
-      "integrity": "sha512-Toe0VLlDZcUhiaWfcePS1OEdT3ATfktm0hk/PELfD7zUoPDHeT+cJf/wZmCy5M5eGVwtGUg25RWPCj1L/1XufA==",
+      "version": "7.21.5",
+      "resolved": "https://registry.npmjs.org/video.js/-/video.js-7.21.5.tgz",
+      "integrity": "sha512-WRq86tXZKrThA9mK+IR+v4tIQVVvnb5LhvL71fD2AX7TxVOPdaeK1X/wyuUruBqWaOG3w2sZXoMY6HF2Jlo9qA==",
       "license": "Apache-2.0",
       "dependencies": {
         "@babel/runtime": "^7.12.5",
-        "@videojs/http-streaming": "^3.17.0",
-        "@videojs/vhs-utils": "^4.1.1",
-        "@videojs/xhr": "2.7.0",
-        "aes-decrypter": "^4.0.2",
-        "global": "4.4.0",
-        "m3u8-parser": "^7.2.0",
-        "mpd-parser": "^1.3.1",
-        "mux.js": "^7.0.1",
-        "videojs-contrib-quality-levels": "4.1.0",
-        "videojs-font": "4.2.0",
-        "videojs-vtt.js": "0.15.5"
-      }
-    },
-    "node_modules/videojs-contrib-quality-levels": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz",
-      "integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==",
+        "@videojs/http-streaming": "2.16.2",
+        "@videojs/vhs-utils": "^3.0.4",
+        "@videojs/xhr": "2.6.0",
+        "aes-decrypter": "3.1.3",
+        "global": "^4.4.0",
+        "keycode": "^2.2.0",
+        "m3u8-parser": "4.8.0",
+        "mpd-parser": "0.22.1",
+        "mux.js": "6.0.1",
+        "safe-json-parse": "4.0.0",
+        "videojs-font": "3.2.0",
+        "videojs-vtt.js": "^0.15.5"
+      }
+    },
+    "node_modules/videojs-contrib-hls": {
+      "version": "5.15.0",
+      "resolved": "https://registry.npmjs.org/videojs-contrib-hls/-/videojs-contrib-hls-5.15.0.tgz",
+      "integrity": "sha512-18zbMYZ0XRBKTPEayA9bFTWWrqhT9b4G8+zf0czJLD7Epe5PcK1I/3dflTHQeQ5rwlWir+/XnFU3sMg/B2MMcw==",
       "license": "Apache-2.0",
       "dependencies": {
-        "global": "^4.4.0"
+        "aes-decrypter": "1.0.3",
+        "global": "^4.3.0",
+        "m3u8-parser": "2.1.0",
+        "mux.js": "4.3.2",
+        "url-toolkit": "^2.1.3",
+        "video.js": "^5.19.1 || ^6.2.0",
+        "videojs-contrib-media-sources": "4.7.2",
+        "webwackify": "0.1.6"
       },
       "engines": {
-        "node": ">=16",
-        "npm": ">=8"
+        "node": ">= 0.10.12"
+      }
+    },
+    "node_modules/videojs-contrib-hls/node_modules/aes-decrypter": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-1.0.3.tgz",
+      "integrity": "sha512-rsx8pfx7wJsn+ziYbpJ8XA5c93hKAtBCrfydxJqJCMT+qfjipd/B5wC2xHtBcoxyvlqJcpeAo3K55t0lXOn9yQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "pkcs7": "^0.2.3"
+      }
+    },
+    "node_modules/videojs-contrib-hls/node_modules/global": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
+      "integrity": "sha512-/4AybdwIDU4HkCUbJkZdWpe4P6vuw/CUtu+0I1YlLIPe7OlUO7KNJ+q/rO70CW2/NW6Jc6I62++Hzsf5Alu6rQ==",
+      "license": "MIT",
+      "dependencies": {
+        "min-document": "^2.19.0",
+        "process": "~0.5.1"
+      }
+    },
+    "node_modules/videojs-contrib-hls/node_modules/m3u8-parser": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-2.1.0.tgz",
+      "integrity": "sha512-WbEpQ2FUaNGbJ0YanSeyj9D9ruu4FUvz+ZvebIzI2bSME+PUwcPXO1kKXZkjcPUAFruDikoOI5fWQNIA6JCCOQ==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/videojs-contrib-hls/node_modules/mux.js": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-4.3.2.tgz",
+      "integrity": "sha512-g0q6DPdvb3yYcoK7ElBGobdSSrhY/RjPt19U7uUc733aqvc5bCS/aCvL9z+448y+IoCZnYDwyZfQBBXMSmGOaQ==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/videojs-contrib-hls/node_modules/pkcs7": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-0.2.3.tgz",
+      "integrity": "sha512-kJRwmADEQUg+qJyRgWLtpEL9q9cFjZschejTEK3GRjKvnsU9G5WWoe/wKqRgbBoqWdVSeTUKP6vIA3Y72M3rWA==",
+      "license": "Apache2",
+      "bin": {
+        "pkcs7": "lib/cli.js"
       },
-      "peerDependencies": {
-        "video.js": "^8"
+      "engines": {
+        "node": "^0.10",
+        "npm": "^1.4.6"
+      }
+    },
+    "node_modules/videojs-contrib-hls/node_modules/process": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
+      "integrity": "sha512-oNpcutj+nYX2FjdEW7PGltWhXulAnFlM0My/k48L90hARCOJtvBbQXc/6itV2jDvU5xAAtonP+r6wmQgCcbAUA==",
+      "engines": {
+        "node": ">= 0.6.0"
+      }
+    },
+    "node_modules/videojs-contrib-hls/node_modules/video.js": {
+      "version": "6.13.0",
+      "resolved": "https://registry.npmjs.org/video.js/-/video.js-6.13.0.tgz",
+      "integrity": "sha512-36/JR/GhPQSZj0o+GNbhcEYv/b0SkV9SQsjlodAnzMQYN0TA7VhmqrKPYMCi1NGRYu7S9W3OaFCFoUxkYfSVlg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "babel-runtime": "^6.9.2",
+        "global": "4.3.2",
+        "safe-json-parse": "4.0.0",
+        "tsml": "1.0.1",
+        "videojs-font": "2.1.0",
+        "videojs-ie8": "1.1.2",
+        "videojs-vtt.js": "0.12.6",
+        "xhr": "2.4.0"
+      }
+    },
+    "node_modules/videojs-contrib-hls/node_modules/videojs-font": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-2.1.0.tgz",
+      "integrity": "sha512-zFqWpLrXf1q8NtYx5qtZhMC6SLUFScDmR6j+UGPogobxR21lvXShhnzcNNMdOxJUuFLiToJ/BPpFUQwX4xhpvA==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/videojs-contrib-hls/node_modules/videojs-vtt.js": {
+      "version": "0.12.6",
+      "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.12.6.tgz",
+      "integrity": "sha512-XFXeGBQiljnElMhwCcZst0RDbZn2n8LU7ZScXryd3a00OaZsHAjdZu/7/RdSr7Z1jHphd45FnOvOQkGK4YrWCQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "global": "^4.3.1"
+      }
+    },
+    "node_modules/videojs-contrib-media-sources": {
+      "version": "4.7.2",
+      "resolved": "https://registry.npmjs.org/videojs-contrib-media-sources/-/videojs-contrib-media-sources-4.7.2.tgz",
+      "integrity": "sha512-e6iCHWBFuV05EGo7v+pS9iepObXnJ9joms467gzi8ZjpKVb3ifha9M0Ja24Rd8JfvYpzjltsgDVtGFDvIg4hQQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "global": "^4.3.0",
+        "mux.js": "4.3.2",
+        "video.js": "^5.17.0 || ^6.2.0",
+        "webwackify": "0.1.6"
+      }
+    },
+    "node_modules/videojs-contrib-media-sources/node_modules/global": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
+      "integrity": "sha512-/4AybdwIDU4HkCUbJkZdWpe4P6vuw/CUtu+0I1YlLIPe7OlUO7KNJ+q/rO70CW2/NW6Jc6I62++Hzsf5Alu6rQ==",
+      "license": "MIT",
+      "dependencies": {
+        "min-document": "^2.19.0",
+        "process": "~0.5.1"
+      }
+    },
+    "node_modules/videojs-contrib-media-sources/node_modules/mux.js": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-4.3.2.tgz",
+      "integrity": "sha512-g0q6DPdvb3yYcoK7ElBGobdSSrhY/RjPt19U7uUc733aqvc5bCS/aCvL9z+448y+IoCZnYDwyZfQBBXMSmGOaQ==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/videojs-contrib-media-sources/node_modules/process": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
+      "integrity": "sha512-oNpcutj+nYX2FjdEW7PGltWhXulAnFlM0My/k48L90hARCOJtvBbQXc/6itV2jDvU5xAAtonP+r6wmQgCcbAUA==",
+      "engines": {
+        "node": ">= 0.6.0"
+      }
+    },
+    "node_modules/videojs-contrib-media-sources/node_modules/video.js": {
+      "version": "6.13.0",
+      "resolved": "https://registry.npmjs.org/video.js/-/video.js-6.13.0.tgz",
+      "integrity": "sha512-36/JR/GhPQSZj0o+GNbhcEYv/b0SkV9SQsjlodAnzMQYN0TA7VhmqrKPYMCi1NGRYu7S9W3OaFCFoUxkYfSVlg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "babel-runtime": "^6.9.2",
+        "global": "4.3.2",
+        "safe-json-parse": "4.0.0",
+        "tsml": "1.0.1",
+        "videojs-font": "2.1.0",
+        "videojs-ie8": "1.1.2",
+        "videojs-vtt.js": "0.12.6",
+        "xhr": "2.4.0"
+      }
+    },
+    "node_modules/videojs-contrib-media-sources/node_modules/videojs-font": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-2.1.0.tgz",
+      "integrity": "sha512-zFqWpLrXf1q8NtYx5qtZhMC6SLUFScDmR6j+UGPogobxR21lvXShhnzcNNMdOxJUuFLiToJ/BPpFUQwX4xhpvA==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/videojs-contrib-media-sources/node_modules/videojs-vtt.js": {
+      "version": "0.12.6",
+      "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.12.6.tgz",
+      "integrity": "sha512-XFXeGBQiljnElMhwCcZst0RDbZn2n8LU7ZScXryd3a00OaZsHAjdZu/7/RdSr7Z1jHphd45FnOvOQkGK4YrWCQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "global": "^4.3.1"
+      }
+    },
+    "node_modules/videojs-flash": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/videojs-flash/-/videojs-flash-2.2.1.tgz",
+      "integrity": "sha512-mHu6TD12EKkxMvr8tg4AcfV/DuVLff427nneoZom3N9Dd2bv0sJOWwdLPQH1v5BCuAuXAVuAOba56ovTl+G3tQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "global": "^4.4.0",
+        "video.js": "^6 || ^7",
+        "videojs-swf": "5.4.2"
+      },
+      "engines": {
+        "node": ">=4.4.0"
       }
     },
     "node_modules/videojs-font": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz",
-      "integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==",
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.2.0.tgz",
+      "integrity": "sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==",
       "license": "Apache-2.0"
     },
+    "node_modules/videojs-hotkeys": {
+      "version": "0.2.30",
+      "resolved": "https://registry.npmjs.org/videojs-hotkeys/-/videojs-hotkeys-0.2.30.tgz",
+      "integrity": "sha512-G8kEQZPapoWDoEajh2Nroy4bCN1qVEul5AuzZqBS7ZCG45K7hqTYKgf1+fmYvG8m8u84sZmVMUvSWZBjaFW66Q==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/videojs-ie8": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/videojs-ie8/-/videojs-ie8-1.1.2.tgz",
+      "integrity": "sha512-0Zb2T4MLkpfZbeGMK/Z93b8Lrepr+rLFoHgQV1CoDeFqXvH7b+Vsd/VHoILGxQrgCSHFQ7mAODR6oyMjuiD4/g==",
+      "license": "Apache 2.0",
+      "dependencies": {
+        "es5-shim": "^4.5.1"
+      }
+    },
+    "node_modules/videojs-swf": {
+      "version": "5.4.2",
+      "resolved": "https://registry.npmjs.org/videojs-swf/-/videojs-swf-5.4.2.tgz",
+      "integrity": "sha512-FGg+Csioa8/A/EacvFefBdb9Z0rSiMlheHDunZnN3xXfUF43jvjawcWFQnZvrv1Cs1nE1LBrHyUZjF7j2mKOLw=="
+    },
     "node_modules/videojs-vtt.js": {
       "version": "0.15.5",
       "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",
@@ -5677,6 +5978,32 @@
         }
       }
     },
+    "node_modules/vue-demi": {
+      "version": "0.14.10",
+      "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/vue-eslint-parser": {
       "version": "10.2.0",
       "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz",
@@ -5731,10 +6058,41 @@
         "vue": "^3.2.0"
       }
     },
+    "node_modules/vue-video-play": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/vue-video-play/-/vue-video-play-7.0.4.tgz",
+      "integrity": "sha512-rTpOlAattbh53plTxVun8m6Ys70ioUHkaifX5c/QePpFNJPnw9rbQ/lXY6RHjv/bdyOaMZE+OiStdabl6y/Opw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4.1.1",
+        "video.js": "^7.0.0",
+        "videojs-contrib-hls": "^5.12.2",
+        "videojs-flash": "^2.1.0",
+        "videojs-hotkeys": "^0.2.20"
+      },
+      "engines": {
+        "node": ">= 4.0.0",
+        "npm": ">= 3.0.0"
+      }
+    },
+    "node_modules/vue-video-player": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/vue-video-player/-/vue-video-player-6.0.0.tgz",
+      "integrity": "sha512-WP47OtefsjMEReRCIKIL3tRRgH/PyNm8ELjsbYgr/WWrYAj5Ih9Adzkzp+ylYOI/v57jJ4O7O4XkbXBCmsTqNw==",
+      "license": "MIT",
+      "dependencies": {
+        "@videojs-player/vue": "1.x"
+      },
+      "peerDependencies": {
+        "@types/video.js": "7.x",
+        "video.js": "7.x",
+        "vue": "3.x"
+      }
+    },
     "node_modules/vue3-video-play": {
-      "version": "1.3.2",
-      "resolved": "https://registry.npmjs.org/vue3-video-play/-/vue3-video-play-1.3.2.tgz",
-      "integrity": "sha512-eEwCJ0NIkfVQgTj0I3Kf9b1E/04Qne8mQQiE8r77BocblHsZ2T6af3q8l8Zzs/OvjlpQAQvkN/ACVUOJC3RSXg==",
+      "version": "1.3.1-beta.6",
+      "resolved": "https://registry.npmjs.org/vue3-video-play/-/vue3-video-play-1.3.1-beta.6.tgz",
+      "integrity": "sha512-Olrx2/LNAds7fuor/yX9ZKT9sOcwcfTt2g2YbbCrEaAmZ5Tb0hwBr5z+/CoLwELzzRzXCHPmWWoT0Wm5W/Nwpw==",
       "license": "ISC",
       "dependencies": {
         "hls.js": "^1.0.10",
@@ -5760,6 +6118,12 @@
       "integrity": "sha512-D0MieGooOs8RpsrK+vnejXnvh4OOv/+lTFB35JRkJJQt+uOjPE08XpaE0QBLMTRu47B1KGT/Nq3Gbag3Orinzw==",
       "license": "MIT"
     },
+    "node_modules/webwackify": {
+      "version": "0.1.6",
+      "resolved": "https://registry.npmjs.org/webwackify/-/webwackify-0.1.6.tgz",
+      "integrity": "sha512-pGcw1T3HpNnM/UTRQqqRkkkzythSLts05mB+7Gr00B+0VbL0m39dFL5g20rSIEUt9Wrpw+/8k+snxRlUFHhcqA==",
+      "license": "MIT"
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -5786,6 +6150,36 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/xhr": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.4.0.tgz",
+      "integrity": "sha512-TUbBsdAuJbX8olk9hsDwGK8P1ri1XlV+PdEWkYw+HQQbpkiBR8PLgD1F3kQDPBs9l4Px34hP9rCYAZOCCAENbw==",
+      "license": "MIT",
+      "dependencies": {
+        "global": "~4.3.0",
+        "is-function": "^1.0.1",
+        "parse-headers": "^2.0.0",
+        "xtend": "^4.0.0"
+      }
+    },
+    "node_modules/xhr/node_modules/global": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
+      "integrity": "sha512-/4AybdwIDU4HkCUbJkZdWpe4P6vuw/CUtu+0I1YlLIPe7OlUO7KNJ+q/rO70CW2/NW6Jc6I62++Hzsf5Alu6rQ==",
+      "license": "MIT",
+      "dependencies": {
+        "min-document": "^2.19.0",
+        "process": "~0.5.1"
+      }
+    },
+    "node_modules/xhr/node_modules/process": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
+      "integrity": "sha512-oNpcutj+nYX2FjdEW7PGltWhXulAnFlM0My/k48L90hARCOJtvBbQXc/6itV2jDvU5xAAtonP+r6wmQgCcbAUA==",
+      "engines": {
+        "node": ">= 0.6.0"
+      }
+    },
     "node_modules/xml-name-validator": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
@@ -5796,6 +6190,15 @@
         "node": ">=12"
       }
     },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    },
     "node_modules/yallist": {
       "version": "3.1.1",
       "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",

+ 8 - 1
package.json

@@ -12,6 +12,9 @@
     "@element-plus/icons-vue": "^2.3.1",
     "@microsoft/fetch-event-source": "^2.0.1",
     "@vitejs/plugin-legacy": "^7.0.1",
+    "@vue-office/docx": "^1.6.3",
+    "@vue-office/excel": "^1.7.14",
+    "@vue-office/pptx": "^1.0.1",
     "axios": "^1.10.0",
     "element-plus": "^2.10.2",
     "highlight.js": "^11.11.1",
@@ -20,9 +23,13 @@
     "jsencrypt": "^3.3.2",
     "markdown-it": "^14.1.0",
     "router": "^2.2.0",
-    "video.js": "^8.23.3",
+    "video.js": "^7.21.5",
     "vue": "^3.5.17",
+    "vue-demi": "^0.14.10",
     "vue-router": "^4.5.1",
+    "vue-video-play": "^7.0.4",
+    "vue-video-player": "^6.0.0",
+    "vue3-video-play": "^1.3.1-beta.6",
     "vuex": "^4.0.2",
     "web-storage-cache": "^1.1.1"
   },

+ 1 - 1
src/App.vue

@@ -1,5 +1,5 @@
 <template>
-  <router-view></router-view>
+      <router-view></router-view>
 </template>
 
 <script setup>

+ 11 - 3
src/api/class.js

@@ -9,10 +9,18 @@ export function ClassList (data) {
   })
 }
 
-// 根据年级id获取教学大纲
+// 根据年级id获取教学大纲 通识课
 export function ClassOutline (id) {
   return axios({
-    url: 'bjdxWeb/course/getTypeByGradeId?id=' + id ,
+    url: 'bjdxWeb/course/getTypeTsByGradeId?id=' + id ,
+    method: 'get'
+  })
+}
+
+// 实操课
+export function ClassOutlineSc (id) {
+  return axios({
+    url: 'bjdxWeb/course/getTypeScByGradeId?id=' + id ,
     method: 'get'
   })
 }
@@ -20,7 +28,7 @@ export function ClassOutline (id) {
 // 根据类型id获取课程列表
 export function ClassType (typeId) {
   return axios({
-    url: 'bjdxWeb/course/getCourseByTypeId?typeId=' + typeId ,
+    url: 'bjdxWeb/course/getCourseByTypeId?typeId=' + typeId,
     method: 'get'
   })
 }

+ 3 - 1
src/api/questions.js

@@ -17,6 +17,7 @@ export function CreateDialogue (data){
 export async function sendChatMessageStream (
     conversationId,
     content,
+    contentAnswer = undefined,
     ctrl,
     enableContext,
     onMessage,
@@ -37,7 +38,8 @@ export async function sendChatMessageStream (
     body: JSON.stringify({
       conversationId,
       content,
-      useContext: enableContext
+      useContext: enableContext,
+      contentAnswer
     }),
     onmessage: onMessage,
     onerror: onError,

+ 103 - 0
src/api/tts/useAudioPlayer.js

@@ -0,0 +1,103 @@
+export function useAudioPlayer() {
+    let audioContext = null;
+    let audioQueue = [];
+    let isPlaying = false;
+    let currentTime = 0; // 当前播放时间(用于连续播放)
+    const SAMPLE_RATE = 16000; // 匹配后端采样率
+    const CHANNELS = 1; // 单声道
+    const BIT_DEPTH = 16; // 16位深
+
+    // 初始化AudioContext
+    const initAudioContext = () => {
+        if (!audioContext) {
+            audioContext = new (window.AudioContext || window.webkitAudioContext)({
+                sampleRate: SAMPLE_RATE
+            });
+            currentTime = 0; // 重置播放时间
+        }
+    };
+
+    // 播放音频块(支持流式PCM)
+    const playAudioChunk = async (base64Audio) => {
+        initAudioContext();
+
+        // 解码Base64音频数据
+        const audioBytes = Uint8Array.from(atob(base64Audio), c => c.charCodeAt(0));
+        audioQueue.push(audioBytes);
+
+        if (!isPlaying) {
+            processAudioQueue();
+        }
+    };
+
+    // 处理音频队列(核心流式播放逻辑)
+    const processAudioQueue = async () => {
+        if (audioQueue.length === 0) {
+            isPlaying = false;
+            return;
+        }
+
+        isPlaying = true;
+        const audioData = audioQueue.shift();
+
+        try {
+            // 1. 处理首个WAV分片(带文件头)
+            if (currentTime === 0) {
+                // 解码完整WAV文件(仅首次)
+                const audioBuffer = await audioContext.decodeAudioData(audioData.buffer);
+                playBuffer(audioBuffer);
+                currentTime += audioBuffer.duration; // 更新播放时间
+            }
+            // 2. 处理后续PCM分片(无文件头)
+            else {
+                // 将16位PCM字节转换为Float32Array(AudioContext要求格式)
+                const float32Data = convertPCMToFloat32(audioData);
+                // 创建音频缓冲区
+                const audioBuffer = audioContext.createBuffer(CHANNELS, float32Data.length, SAMPLE_RATE);
+                audioBuffer.copyToChannel(float32Data, 0); // 复制到音频通道
+                playBuffer(audioBuffer);
+                currentTime += audioBuffer.duration; // 更新播放时间
+            }
+        } catch (error) {
+            console.error('音频处理失败:', error);
+            isPlaying = false;
+        }
+    };
+
+    // 播放音频缓冲区并调度下一个分片
+    const playBuffer = (audioBuffer) => {
+        const source = audioContext.createBufferSource();
+        source.buffer = audioBuffer;
+        source.connect(audioContext.destination);
+        source.start(currentTime); // 从当前时间点开始播放
+        // 播放结束后继续处理队列
+        source.onended = processAudioQueue;
+    };
+
+    // 将16位PCM字节转换为Float32Array([-1.0, 1.0]范围)
+    const convertPCMToFloat32 = (bytes) => {
+        const int16Array = new Int16Array(bytes.buffer);
+        const float32Array = new Float32Array(int16Array.length);
+        for (let i = 0; i < int16Array.length; i++) {
+            float32Array[i] = int16Array[i] / 32768; // 16位PCM最大值为32767
+        }
+        return float32Array;
+    };
+
+    // 停止播放并清理
+    const stopPlayback = () => {
+        if (audioContext) {
+            audioContext.close().then(() => {
+                audioContext = null;
+                currentTime = 0; // 重置播放时间
+            });
+        }
+        audioQueue = [];
+        isPlaying = false;
+    };
+
+    return {
+        playAudioChunk,
+        stopPlayback
+    };
+}

BIN
src/assets/icon/sendicon.png


BIN
src/assets/icon/sendicon02.png


BIN
src/assets/icon/starticon.png


BIN
src/assets/icon/stopicon.png


BIN
src/assets/icon/videoImage01.png


BIN
src/assets/icon/videoImage02.png


+ 5 - 6
src/components/HomePage.vue

@@ -2,7 +2,7 @@
   <div class="home-container">
     <div class="box-1">
       <div class="inner-box left-box">
-        <span>京华实验学校</span>
+        <span>人工智能通识课平台</span>
         <div class="dropdown-box">
           <!-- 下拉菜单 -->
           <el-dropdown v-model="selectedGrade" @command="handleGradeSelect" popper-class="no-arrow-dropdown">
@@ -300,6 +300,8 @@ onMounted(() => {
   display: flex;
   justify-content: flex-start;
   align-items: flex-start;
+  background-origin: border-box; // 确保背景图从边框开始显示
+  background-clip: padding-box; // 确保背景图不会延伸到边框外
 }
 .left-box-in-box2:hover,
 .left-box-in-box2:active,
@@ -312,7 +314,7 @@ onMounted(() => {
 .top-sub-box,
 .bottom-sub-box {
   background-repeat: no-repeat;
-  background-size: cover;
+  background-size: 100% 100%;
   background-position: center;
 }
 .right-box-in-box2 {
@@ -359,7 +361,7 @@ onMounted(() => {
 .left-box span {
   position: absolute;
   margin-top: rpx(20); // 调整上边距离
-  margin-left: rpx(25);
+  margin-left: rpx(30);
   font-size: rpx(11); // 默认字体大小
   color: white;
 }
@@ -397,9 +399,6 @@ onMounted(() => {
   box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
 }
 .dropdown-box {
-  width: 100%;
-  height: 100%;
-  margin-left: rpx(-80);
   flex: 1;
   align-items: center; // 垂直居中;
   margin-top: rpx(22);

+ 226 - 0
src/components/Image/ImageView.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="image-container">
+    <div v-if="images.length > 0" class="carousel-container">
+      <!-- 轮播图容器 -->
+      <div class="carousel-wrapper" ref="carouselWrapper">
+        <transition name="carousel-transition" mode="out-in">
+          <img 
+            :key="currentIndex"
+            :src="images[currentIndex]" 
+            :alt="`${altText} ${currentIndex + 1}`" 
+            class="carousel-image" 
+          />
+        </transition>
+      </div>
+      
+      <!-- 轮播指示器 -->
+      <div class="carousel-indicators">
+        <span 
+          v-for="(item, index) in images"
+          :key="index"
+          :class="['indicator', { 'active': currentIndex === index }]"
+          @click="goToSlide(index)"
+        ></span>
+      </div>
+    </div>
+    <div v-else class="no-image">
+      <span>{{ altText }}</span>
+    </div>
+    
+    <!-- 轮播控制按钮 -->
+    <div v-if="images.length > 1" class="carousel-controls">
+      <button class="control-btn left-btn" @click="prevSlide">
+        <img :src="videoImage01" alt="上一张" />
+      </button>
+      <button class="control-btn right-btn" @click="nextSlide">
+        <img :src="videoImage02" alt="下一张" />
+      </button>
+    </div>
+    
+  </div>
+</template>
+
+<script setup>
+import { defineProps, computed, ref, onMounted, onUnmounted } from 'vue'
+import videoImage01 from '@/assets/icon/videoImage01.png'
+import videoImage02 from '@/assets/icon/videoImage02.png'
+// 消息提示
+import { ElMessage } from 'element-plus'
+
+// 定义props
+const props = defineProps({
+  imagePath: { type: String, required: true },
+  altText: { type: String, default: '课程图片' },
+  autoPlay: { type: Boolean, default: true },
+  interval: { type: Number, default: 3000 }
+})
+
+// 计算属性:将逗号分隔的图片路径字符串转换为数组
+const images = computed(() => {
+  if (!props.imagePath) return []
+  // 分割逗号分隔的字符串,并去除空字符串和前后空格
+  return props.imagePath.split(',').map(path => path.trim()).filter(path => path)
+})
+
+// 轮播相关状态
+const currentIndex = ref(0)
+const carouselWrapper = ref(null)
+const autoplayTimer = ref(null)
+
+// 下一张图片 - 取消循环切换,在最后一张时显示提示
+const nextSlide = () => {
+  if (currentIndex.value < images.value.length - 1) {
+    currentIndex.value++
+  } else {
+    // 已经是最后一张图片,显示提示信息
+    ElMessage.warning('已播放到最后一张图片')
+  }
+}
+
+// 上一张图片 - 取消循环切换
+const prevSlide = () => {
+  if (currentIndex.value > 0) {
+    currentIndex.value--
+  } else {
+    // 已经是第一张图片,显示提示信息
+    ElMessage.warning('已播放到第一张图片')
+  }
+}
+
+// 跳转到指定图片
+const goToSlide = (index) => {
+  currentIndex.value = index
+}
+
+
+// 清理定时器
+onUnmounted(() => {
+  if (autoplayTimer.value) {
+    clearInterval(autoplayTimer.value)
+  }
+})
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.image-container {
+  width: 100%;
+  height: rpx(300);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  position: relative;
+}
+
+.carousel-container {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  position: relative;
+}
+
+.carousel-wrapper {
+  width: 70%;
+  height: rpx(289);
+  overflow: hidden;
+  border-radius: rpx(12);
+  position: relative;
+}
+
+.carousel-image {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  border-radius: rpx(12);
+}
+
+// 轮播过渡动画
+.carousel-transition-enter-active,
+.carousel-transition-leave-active {
+  transition: all 0.2s ease;
+}
+
+.carousel-transition-enter-from {
+  opacity: 0;
+  transform: translateX(50px);
+}
+
+.carousel-transition-leave-to {
+  opacity: 0;
+  transform: translateX(-50px);
+}
+
+// 轮播指示器
+.carousel-indicators {
+  position: absolute;
+  bottom: rpx(10);
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  gap: rpx(8);
+}
+
+.indicator {
+  width: rpx(3);
+  height: rpx(3);
+  border-radius: 50%;
+  background-color: rgba(255, 255, 255, 0.5);
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.indicator.active {
+  width: rpx(20);
+  border-radius: rpx(4);
+  background-color: white;
+}
+
+// 轮播控制按钮
+.carousel-controls {
+  position: absolute;
+  top: 50%;
+  left: 0;
+  right: 0;
+  transform: translateY(-50%);
+  display: flex;
+  justify-content: space-between;
+  padding: 0 rpx(60);
+  pointer-events: none;
+}
+
+.control-btn {
+  width: rpx(25);
+  height: rpx(25);
+  border-radius: 50%;
+  background: linear-gradient(to bottom, #fee78d, #ffd01a);
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+  border: none;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+  pointer-events: all;
+  transition: background-color 0.3s ease;
+}
+
+.control-btn:hover {
+  background-color: rgba(0, 0, 0, 0.7);
+}
+
+.control-btn img {
+  width: rpx(18);
+  height: rpx(18);
+}
+
+.no-image {
+  color: white;
+  font-size: rpx(10);
+}
+</style>

+ 28 - 14
src/components/LeftPanel.vue

@@ -35,7 +35,7 @@
 </template>
 
 <script setup>
-import { ref, onMounted} from 'vue'
+import { ref, onMounted,watch} from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 
 // 导入图片 白色
@@ -74,19 +74,32 @@ const groupList = ref([
     title: '数字人老师'
   }
 ])
-// 组件挂载时确保默认选中状态
-onMounted(() => {
-  // 从路由判断当前应该选中的菜单项
-  const path = route.path
-  const from = route.query.from
-   if (path.includes('ai-questions') && from === 'ai-laboratory') {
-    currentActiveIndex.value = '2'  // 数字人老师
+
+
+// 提取更新选中状态的逻辑为单独函数
+const updateActiveIndex = () => {
+  const path = route.path;
+  const from = route.query.from;
+  if (path.includes('ai-questions') && from === 'ai-laboratory') {
+    currentActiveIndex.value = '2'; // 数字人老师
   } else if (path.includes('ai-questions')) {
-    currentActiveIndex.value = '0'  // 智能问答
+    currentActiveIndex.value = '0'; // 智能问答
   } else if (path.includes('ai-painting')) {
-    currentActiveIndex.value = '1'  // 智能绘画
+    currentActiveIndex.value = '1'; // 智能绘画
+  } else if (path.includes('ai-laboratory')) {
+    currentActiveIndex.value = '2'; // 数字人老师
   }
-})
+};
+
+// 组件挂载时确保默认选中状态
+onMounted(() => {
+  updateActiveIndex();
+});
+
+// 添加路由变化监听,更新选中状态
+watch(() => route, () => {
+  updateActiveIndex();
+}, { immediate: true, deep: true });
 
 // 存储小智数据
 const personData = ref([])
@@ -97,7 +110,6 @@ const navigateToAI = async (group) => {
       const grade = route.query.grade || localStorage.getItem('selectedGrade')
       // 获取小学低年级AI数据
       const juniorAIRes = await teacherList({ category: grade + 'AI' })
-      console.log(juniorAIRes);
       const aiPerson = juniorAIRes.data.list.find(
         person => person.name === '小智'
       )
@@ -113,8 +125,10 @@ const navigateToAI = async (group) => {
         router
           .push({
             path: '/ai-questions',
-            query: personData.value,
-            category: grade + 'AI'
+            query: {
+              ...personData.value,
+              category: grade + 'AI'
+            }
           })
       } else {
         console.warn('未找到名为小智的数据')

+ 2 - 2
src/components/MarkdownView/index.vue

@@ -40,12 +40,12 @@ const renderedMarkdown = computed(() => {
 <style lang="scss">
 .markdown-view {
   font-family: PingFang SC;
-  font-size: 0.95rem;
+  font-size: 1rem;
   font-weight: 400;
   line-height: 1.6rem;
   letter-spacing: 0em;
   text-align: left;
-  color: #3b3e55;
+  color: block;
   max-width: 100%;
 
   pre {

+ 326 - 0
src/components/PPT/PptView.vue

@@ -0,0 +1,326 @@
+<template>
+  <div class="box-ppt">
+    <div class="ppt-box">
+      <!-- 轮播图容器 -->
+      <div class="carousel-container">
+        <!-- 添加翻页动画容器 -->
+        <div ref="pptContainer" class="carousel-wrapper">
+          <VueOfficePptx
+              ref="pptRef"
+              :src="pptPath"
+              @error="handlePptError"
+              @rendered="handlePptRendered"
+              :animation="true"
+              :animation-speed="1.0"
+              :key="pptKey"
+          />
+        </div>
+        
+        <!-- 轮播指示器 -->
+        <div v-if="totalPages > 1" class="carousel-indicators">
+          <span 
+            v-for="index in totalPages"
+            :key="index"
+            :class="['indicator', { 'active': currentPage === index }]"
+            @click="goToSlide(index)"
+          ></span>
+        </div>
+        
+        <!-- 轮播控制按钮 -->
+        <div v-if="totalPages > 1" class="carousel-controls">
+          <button class="control-btn left-btn" @click="prevPage">
+            <img :src="leftImg" alt="上一页" />
+          </button>
+          <button class="control-btn right-btn" @click="nextPage">
+            <img :src="rightImg" alt="下一页" />
+          </button>
+        </div>
+        
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted, defineProps } from 'vue'
+import { ElMessage } from 'element-plus'
+// 导入图标
+import leftImg from '@/assets/icon/videoImage01.png'
+import rightImg from '@/assets/icon/videoImage02.png'
+// PPT
+import VueOfficePptx from '@vue-office/pptx'
+
+// PPT翻页相关变量
+const pptRef = ref(null)
+const currentPage = ref(1)
+const totalPages = ref(10)
+
+// 定义props
+const props = defineProps({
+  pptPath: { type: String }, // PPT路径
+})
+
+// 修改PPT渲染完成处理
+const handlePptRendered = (pptInfo) => {
+  // 获取总页数
+  const pageCount = pptInfo?.slides?.length || 1;
+  totalPages.value = pageCount;
+  // 确保当前页在有效范围内
+  if (currentPage.value > pageCount) {
+    currentPage.value = pageCount;
+  }
+  // 初始化滚动位置
+  scrollToPage(currentPage.value);
+}
+
+// 页面变更处理函数
+const handlePageChange = (newPage) => {
+  // 验证页码有效性
+  const normalizedPage = Math.max(1, Math.min(newPage + 1, totalPages.value));
+  if (currentPage.value !== normalizedPage) {
+    currentPage.value = normalizedPage;
+    console.log('页码已更新:', currentPage.value);
+  }
+};
+
+
+// 滚动控制变量
+const pptContainer = ref(null)
+const pageHeight = ref(0) // 单页高度,初始设为0
+const pptKey = ref(0) // 添加key用于重新渲染
+
+// 窗口大小变化处理函数
+const handleResize = () => {
+  if (pptContainer.value) {
+    // 更新页面高度
+    pageHeight.value = pptContainer.value.clientHeight || 620;
+    // 通过更新key强制重新渲染PPT
+    pptKey.value += 1;
+    // 重新定位到当前页
+    scrollToPage(currentPage.value);
+  }
+}
+
+// 下一页
+const nextPage = () => {
+  if (currentPage.value < totalPages.value) {
+    currentPage.value++;
+    scrollToPage(currentPage.value);
+  } else {
+    ElMessage.warning('已播放到最后一页')
+  }
+}
+
+// 上一页
+const prevPage = () => {
+  if (currentPage.value > 1) {
+    currentPage.value--;
+    scrollToPage(currentPage.value);
+  }else {
+    ElMessage.warning('已播放到第一页')
+  }
+}
+
+// 跳转到指定页
+const goToSlide = (index) => {
+  currentPage.value = index;
+  scrollToPage(currentPage.value);
+}
+
+// 滚动到指定页方法
+const scrollToPage = (pageNum) => {
+  if (pptContainer.value) {
+    // 计算目标滚动位置
+    const targetPosition = (pageNum - 1) * pageHeight.value;
+    // 设置滚动位置
+    pptContainer.value.scrollTop = targetPosition;
+    console.log(`滚动到第${pageNum}页,位置: ${targetPosition}`);
+  }
+}
+
+// 初始化滚动位置
+onMounted(() => {
+  // 获取容器元素
+  pptContainer.value = document.querySelector('.carousel-wrapper');
+  // 初始化页面高度
+  if (pptContainer.value) {
+    pageHeight.value = pptContainer.value.clientHeight || 620;
+  }
+  // 初始滚动到第一页
+  scrollToPage(1);
+  // 添加窗口大小监听
+  window.addEventListener('resize', handleResize);
+})
+// 组件卸载时移除监听
+onUnmounted(() => {
+  window.removeEventListener('resize', handleResize);
+})
+
+// PPT错误处理事件
+const handlePptError = (error) => {
+  console.error('PPT加载错误:', error)
+  ElMessage.error('PPT加载失败,请检查文件路径或格式')
+}
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.box-ppt {
+  width: 100%;
+  height: rpx(300);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.ppt-box {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.carousel-container {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  position: relative;
+}
+
+.carousel-wrapper {
+  width: 70%;
+  //height: rpx(290);
+
+  height: 100%;
+  overflow: hidden;
+  border-radius: rpx(12);
+  position: relative;
+}
+
+// 轮播过渡动画
+.carousel-transition-enter-active,
+.carousel-transition-leave-active {
+  transition: all 0.2s ease;
+}
+
+.carousel-transition-enter-from {
+  opacity: 0;
+  transform: translateX(50px);
+}
+
+.carousel-transition-leave-to {
+  opacity: 0;
+  transform: translateX(-50px);
+}
+
+// 轮播指示器
+.carousel-indicators {
+  position: absolute;
+  bottom: rpx(10);
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  gap: rpx(8);
+}
+
+.indicator {
+  width: rpx(3);
+  height: rpx(3);
+  border-radius: 50%;
+  background-color: rgba(255, 255, 255, 0.5);
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.indicator.active {
+  width: rpx(20);
+  border-radius: rpx(4);
+  background-color: white;
+}
+
+// 轮播控制按钮
+.carousel-controls {
+  position: absolute;
+  top: 50%;
+  left: 0;
+  right: 0;
+  transform: translateY(-50%);
+  display: flex;
+  justify-content: space-between;
+  padding: 0 rpx(60);
+  pointer-events: none;
+}
+
+.control-btn {
+  width: rpx(25);
+  height: rpx(25);
+  border-radius: 50%;
+  background: linear-gradient(to bottom, #fee78d, #ffd01a);
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+  border: none;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+  pointer-events: all;
+  transition: background-color 0.3s ease;
+}
+
+.control-btn:hover {
+  background-color: rgba(0, 0, 0, 0.7);
+}
+
+.control-btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.control-btn img {
+  width: rpx(18);
+  height: rpx(18);
+}
+
+// 修改PPT内部预览容器样式
+::v-deep .pptx-preview-wrapper {
+  height: auto !important;
+  transition: transform 0.3s ease; /* 添加平滑过渡 */
+}
+
+.carousel-wrapper ::v-deep(.pptx-preview-wrapper) {
+  height: auto !important;
+  // 滚动条整体样式
+  &::-webkit-scrollbar {
+    width: rpx(0); // 滚动条宽度
+    height: rpx(0);
+  }
+
+  // 滚动条滑块样式
+  &::-webkit-scrollbar-thumb {
+    background-color: #e2ddfc; // 滑块颜色
+    border-radius: rpx(4); // 滑块圆角
+  }
+
+  // 滚动条轨道样式
+  &::-webkit-scrollbar-track {
+    background-color: rgba(143, 116, 255, 0.2); // 轨道颜色
+    border-radius: rpx(4); // 轨道圆角
+  }
+}
+
+// 确保每张幻灯片占满容器高度
+::v-deep .slide-item {
+  //height: rpx(620) !important;
+
+  height: 100% !important;
+  width: 100% !important;
+  box-sizing: border-box;
+}
+</style>

+ 72 - 0
src/components/TTS/useAudioPlayer.js

@@ -0,0 +1,72 @@
+import { ref } from 'vue';
+
+export function useAudioPlayer() {
+    let audioContext = null;
+    let audioQueue = [];
+    let isPlaying = false;
+
+    // 初始化AudioContext
+    const initAudioContext = () => {
+        if (!audioContext) {
+            audioContext = new (window.AudioContext || window.webkitAudioContext)({
+                sampleRate: 24000 // 匹配TTS采样率
+            });
+        }
+    };
+
+    // 播放音频块
+    const playAudioChunk = async (base64Audio) => {
+
+        // console.log('playAudioChunk=========', base64Audio);
+        initAudioContext();
+
+        // 解码Base64音频数据
+        const audioBytes = Uint8Array.from(atob(base64Audio), c => c.charCodeAt(0));
+        audioQueue.push(audioBytes);
+
+        if (!isPlaying) {
+            processAudioQueue();
+        }
+    };
+
+    // 处理音频队列
+    const processAudioQueue = async () => {
+        if (audioQueue.length === 0) {
+            isPlaying = false;
+            return;
+        }
+
+        isPlaying = true;
+        const audioData = audioQueue.shift();
+
+        try {
+            const audioBuffer = await audioContext.decodeAudioData(audioData.buffer);
+            const source = audioContext.createBufferSource();
+            source.buffer = audioBuffer;
+            source.connect(audioContext.destination);
+            source.start(0);
+
+            // 播放完成后继续处理队列
+            source.onended = processAudioQueue;
+        } catch (error) {
+            console.error('音频解码失败:', error);
+            isPlaying = false;
+        }
+    };
+
+    // 停止播放并清理
+    const stopPlayback = () => {
+        if (audioContext) {
+            audioContext.close().then(() => {
+                audioContext = null;
+            });
+        }
+        audioQueue = [];
+        isPlaying = false;
+    };
+
+    return {
+        playAudioChunk,
+        stopPlayback
+    };
+}

+ 1138 - 0
src/components/videopage/DialogComponents.vue

@@ -0,0 +1,1138 @@
+<template>
+  <div>
+    <!-- 试题弹框 -->
+    <transition name="fade-scale">
+      <div
+        v-show="questionDialogVisible"
+        class="child-dialog-wrapper"
+        @click.self="handleCloseQuestionDialog"
+      >
+        <div class="child-dialog">
+          <div class="question-title">
+            <span class="question-icon">?</span>
+            <span v-html="currentQuestion.ccQuestContent"></span>
+          </div>
+          <!-- 选项区域 -->
+          <div
+            v-if="currentQuestion.ccQuestOption && currentQuestion.ccQuestOption.length > 0"
+            class="options-container"
+          >
+            <div
+              v-for="(option, index) in currentQuestion.ccQuestOption.split(',')"
+              :key="index"
+              class="question-option"
+            >
+              <el-radio
+                v-model="selectedOption"
+                :label="option"
+                :value="option"
+              >
+                <span>{{ option }}</span>
+              </el-radio>
+            </div>
+          </div>
+          <div v-else class="no-options">
+            <!-- 暂无选项 -->
+          </div>
+          <!-- 底部按钮 -->
+          <div class="dialog-footer">
+            <el-button
+              class="child-button confirm"
+              @click="handleSubmitAnswer"
+              >确定</el-button
+            >
+          </div>
+          <!-- 右侧小图标 -->
+          <div
+            v-if="currentQuestion.ccAiAnswer !== null"
+            class="ai-icon-container"
+            @click="handleAIClick"
+          >
+            <img
+              src="@/assets/images/xiaozhi.png"
+              alt="AI对话"
+              class="ai-icon"
+            />
+            <span class="ai-text">小智智能助手</span>
+          </div>
+        </div>
+      </div>
+    </transition>
+
+    <!-- AI对话弹框 -->
+    <div
+      v-show="showAIDialog"
+      class="ai-dialog-wrapper"
+      @click.self="showAIDialog = false"
+    >
+      <div class="ai-dialog">
+        <div class="ai-dialog-header">
+          <h3>
+            <img :src="auto" alt="" />
+            小智智能助手
+          </h3>
+          <el-button @click="showAIDialog = false" class="close-btn"
+            >×</el-button
+          >
+        </div>
+        <div class="ai-dialog-content">
+          <div class="ai-message-history" ref="messageContainer" @scroll="handleScroll">
+            <div
+              v-for="(message, index) in messageList"
+              :key="index"
+              :class="['message', message.type]"
+            >
+              <img
+                v-if="message.type === 'user'"
+                src="@/assets/images/user.png"
+                class="avatar user"
+              />
+              <img v-else src="@/assets/images/xiaozhi.png" class="avatar" />
+              <div
+                class="message-content"
+                v-if="message.type === 'user'"
+                v-html="message.content"
+              ></div>
+              <div class="message-content" v-else>
+                <MarkdownView class="left-text" :content="message.content" />
+              </div>
+            </div>
+          </div>
+          <!-- 弹框默认消息 -->
+          <DefaultMessage
+            class="default-messages"
+            :category="'ai_develop'"
+            :questTip="currentQuestion.ccAiQuestTip || ''"
+            @select-message="handleSelectMessage"
+          />
+          <!-- 消息输入框 -->
+          <el-input
+            v-model="prompt"
+            placeholder="输入问题..."
+            class="user-input"
+            @keyup.enter="handleSendByKeydown"
+          >
+          <!-- 语音输入 -->
+            <template #prepend>
+              <el-button 
+                @click="toggleSpeechInput"
+                size="small" 
+                :class="{ 'recording': isRecording }"
+                circle
+              >
+                <el-icon v-if="!isRecording"><Microphone /></el-icon>
+                <el-icon v-else><Mute /></el-icon>
+                <!-- 显示倒计时(仅录音时显示) -->
+                <span v-if="isRecording" class="countdown-text">{{ countdown }}s</span>
+              </el-button>
+            </template>
+            
+            <!-- 终止按钮和发送按钮条件渲染 -->
+            <template #append>
+              <!-- 终止问答按钮 -->
+              <div
+                v-if="conversationInProgress"
+                @click="stopStream"
+                class="stop-btn"
+                title="终止问答"
+              >
+                <img :src="stopicon" alt="停止" />
+              </div>
+              <!-- 发送按钮 -->
+              <el-button v-if="!conversationInProgress" @click="handleSendByButton" size="large" round
+                >发送</el-button
+              >
+            </template>
+          </el-input>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import {ref,onUnmounted, defineProps, defineEmits, onMounted, watch, nextTick} from 'vue'
+import { ElMessage } from 'element-plus'
+import { CreateDialogue, sendChatMessageStream } from '@/api/questions.js'
+import { teacherList } from '@/api/teachers.js'
+import DefaultMessage from '@/components/DefaultMessage/index.vue'
+import MarkdownView from '@/components/MarkdownView/index.vue'
+import { saveRecord } from '@/api/personalized/index.js'
+
+// 语音图标导入
+import { Microphone, Mute } from '@element-plus/icons-vue'
+
+// 终止
+import stopicon from '@/assets/icon/stopicon.png'
+
+// 导入图标
+import auto from '@/assets/icon/auto_awesome.png'
+
+// 定义props
+const props = defineProps({
+  questionDialogVisible: { type: Boolean, default: false },
+  currentQuestion: { type: Object, default: () => ({}) },
+  gradeId: { type: String, default: '' },
+  typeId: { type: String, default: '' },
+  courseId: { type: String, default: '' }
+})
+
+// 定义emits
+const emits = defineEmits(['closeQuestionDialog', 'submitAnswer'])
+
+// 内部状态
+const showAIDialog = ref(false)
+const selectedOption = ref(null)
+const messageList = ref([])
+const prompt = ref('')
+const messageContainer = ref(null)
+const aiQuestionCount = ref(0)
+const userScrolled = ref(false) //是否用户手动滚动
+const xZAiData = ref({})
+const activeConversationId = ref(null)
+const conversationInProgress = ref(false)
+const conversationInAbortController = ref()
+const receiveMessageFullText = ref('')
+const isComposing = ref(false)
+const inputTimeout = ref()
+const enableContext = ref(true)
+
+// tts
+import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
+const { playAudioChunk , stopPlayback  } = useAudioPlayer();
+
+// 语音输入响应式变量
+const isRecording = ref(false) // 录音状态
+const recognition = ref(null) // 语音识别实例
+const countdown = ref(0) // 倒计时剩余秒数
+const countdownTimer = ref(null) // 倒计时定时器
+
+// 处理选择的默认消息
+const handleSelectMessage = message => {
+  prompt.value = message
+}
+
+// 关闭试题弹框
+const handleCloseQuestionDialog = () => {
+  stopPlayback(); // 销毁语音读取
+  emits('closeQuestionDialog')
+}
+
+// 提交答案
+const handleSubmitAnswer = () => {
+  if (props.currentQuestion.ccQuestOption && props.currentQuestion.ccQuestOption.length > 0 && !selectedOption.value) {
+    ElMessage.warning('请选择一个选项')
+    return
+  }
+  emits('submitAnswer', { selectedOption: selectedOption.value })
+  selectedOption.value = null
+}
+
+// 处理 AI 助手点击事件
+const handleAIClick = async () => {
+  // 清空输入框
+  messageList.value = []
+  showAIDialog.value = true
+
+  // 创建对话
+  await createAiChart()
+
+  if (props.currentQuestion.ccQuestContent) {
+    // prompt.value = props.currentQuestion.ccQuestContent
+    sendMessage()
+
+    prompt.value = ''
+
+    console.log("handleAIClick", props.currentQuestion.ccAiAnswer)
+    // 执行发送
+    await doSendMessageStream({
+      conversationId: activeConversationId.value,
+      content: props.currentQuestion.ccQuestContent,
+      contentAnswer: props.currentQuestion.ccAiAnswer,
+    })
+  }
+}
+
+// 数字人接口
+const getXzAi = async () => {
+  try {
+    const grade = localStorage.getItem('selectedGrade') || ''
+    // 获取AI数据
+    const juniorAIRes = await teacherList({ category: grade + 'AI' })
+    const aiPerson = juniorAIRes.data.list.find(
+      person => person.name === '小智'
+    )
+    if (aiPerson) {
+      xZAiData.value = {
+        id: aiPerson.id,
+        name: aiPerson.name,
+        image: aiPerson.model2dPath,
+        message: aiPerson.systemMessage,
+        default: aiPerson.questTip
+      }
+    } else {
+      console.warn('未找到名为小智的数据')
+    }
+  } catch (error) {
+    console.error('获取年级AI数据失败:', error)
+  }
+}
+
+//创建对话
+const createAiChart = async () => {
+  // 先获取数字人接口
+  await getXzAi()
+  // 智能问答
+  await CreateDialogue({ roleId: xZAiData.value.id })
+    .then(res => {
+      console.log("创建会话:", res);
+      activeConversationId.value = res.data
+    })
+    .catch(error => {
+      console.error('请求出错:', error)
+    })
+}
+ 
+// 发送消息
+const sendMessage = async () => {
+  if (prompt.value.trim()) {
+    // 添加用户消息到历史记录
+    messageList.value.push({
+      type: 'user',
+      content: prompt.value
+    })
+
+    // 增加问答次数
+    aiQuestionCount.value++
+
+    // 保存AI问答次数
+    try {
+      await saveRecord({
+        brpNjId: props.gradeId,
+        brpType: 'aiCount',
+        brpProgress: aiQuestionCount.value
+      })
+    } catch (error) {
+      console.error('保存AI问答次数失败:', error)
+    }
+    // // 模拟 AI 回复
+    // const aiResponse = await simulateAIResponse(prompt.value)
+    // messageList.value.push({
+    //   type: 'ai',
+    //   content: aiResponse
+    // })
+
+    // 清空输入框
+    prompt.value = ''
+  }
+}
+
+
+// =========== 【语音录入】相关 ===========
+// 初始化语音识别
+const initSpeechRecognition = () => {
+  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
+  if (!SpeechRecognition) {
+    ElMessage.warning('当前浏览器不支持语音输入功能')
+    return null
+  }
+
+  const instance = new SpeechRecognition()
+  instance.lang = "zh-CN"
+  instance.interimResults = false
+
+  instance.onresult = (event) => {
+    if (event.results?.[0]?.[0]) {
+      prompt.value += event.results[0][0].transcript
+    }
+  }
+
+  // 识别器结束时清除定时器
+  instance.onend = () => {
+    clearInterval(countdownTimer.value)
+    isRecording.value = false
+    countdown.value = 0
+  }
+
+  instance.onerror = (event) => {
+    console.error("语音识别错误:", event.error)
+    clearInterval(countdownTimer.value) // 出错时清除定时器
+    isRecording.value = false
+    ElMessage.error('语音输入失败,请重试')
+    countdown.value = 0
+  }
+  return instance
+}
+
+// 切换录音状态
+const toggleSpeechInput = () => {
+  // 无论当前状态如何,先清除可能存在的旧定时器
+  clearInterval(countdownTimer.value)
+  countdownTimer.value = null
+
+  if (isRecording.value) {
+    // 手动停止时重置状态
+    countdown.value = 0
+    recognition.value?.stop()
+    isRecording.value = false
+  } else {
+    // 初始化倒计时前再次清除定时器(防止快速点击)
+    clearInterval(countdownTimer.value)
+    countdown.value = 10 // 重置为10秒
+
+    recognition.value = initSpeechRecognition()
+    if (!recognition.value) return
+
+    navigator.mediaDevices.getUserMedia({ audio: true })
+      .then(() => {
+        recognition.value.start()
+        isRecording.value = true
+
+        // 启动新的倒计时定时器
+        countdownTimer.value = setInterval(() => {
+          countdown.value--
+          if (countdown.value <= 0) {
+            clearInterval(countdownTimer.value) // 倒计时结束清除
+            recognition.value.stop()
+            isRecording.value = false
+            countdown.value = 0
+          }
+        }, 1000)
+      })
+      .catch((err) => {
+        console.error("麦克风权限获取失败:", err)
+        ElMessage.warning('请允许麦克风权限以使用语音输入')
+        // 出错时重置状态
+        isRecording.value = false
+        countdown.value = 0
+      })
+  }
+}
+
+
+// 模拟 AI 回复
+const simulateAIResponse = question => {
+  return new Promise(resolve => {
+    setTimeout(() => {
+      if (props.currentQuestion.ccAiAnswer) {
+        resolve(props.currentQuestion.ccAiAnswer)
+        return
+      }
+      // 若未匹配到自定义回复,给出默认回复
+      resolve(`您的问题是:${question},这是 AI 的回复示例。`)
+    }, 1000)
+  })
+}
+
+/** 处理来自 keydown 的发送消息 */
+const handleSendByKeydown = async event => {
+  // 判断用户是否在输入
+  if (isComposing.value) {
+    return
+  }
+  // 进行中不允许发送
+  if (conversationInProgress.value) {
+    return
+  }
+  const content = prompt.value?.trim()
+
+  if (event.key === 'Enter') {
+    if (event.shiftKey) {
+      // 插入换行
+      prompt.value += '\r\n'
+      event.preventDefault() // 防止默认的换行行为
+    } else {
+      // 发送消息
+      await doSendMessage(content)
+      event.preventDefault() // 防止默认的提交行为
+    }
+  }
+}
+
+/** 处理来自【发送】按钮的发送消息 */
+const handleSendByButton = () => {
+  doSendMessage(prompt.value?.trim())
+}
+
+/** 真正执行【发送】消息操作 */
+const doSendMessage = async content => {
+  // 校验
+  if (content.length < 1) {
+    console.error('发送失败,原因:内容为空!')
+    return
+  }
+
+  if (activeConversationId.value == null) {
+    console.error('还没创建对话,不能发送!')
+    return
+  }
+
+  // 清空输入框
+  prompt.value = ''
+  // 执行发送
+  await doSendMessageStream({
+    conversationId: activeConversationId.value,
+    content: content,
+    contentAnswer: null,
+  })
+}
+
+/** 真正执行【发送】消息操作 */
+const doSendMessageStream = async userMessage => {
+  // 创建 AbortController 实例,以便中止请求
+  conversationInAbortController.value = new AbortController()
+  // 标记对话进行中
+  conversationInProgress.value = true
+  // 设置为空
+  receiveMessageFullText.value = ''
+
+  try {
+    // 1.1 先添加两个假数据,等 stream 返回再替换
+    messageList.value.push({
+      id: -1,
+      conversationId: activeConversationId.value,
+      type: 'user',
+      content: userMessage.content,
+      createTime: new Date()
+    })
+    messageList.value.push({
+      id: -2,
+      conversationId: activeConversationId.value,
+      type: 'assistant',
+      content: '思考中...',
+      createTime: new Date()
+    })
+
+    // 销毁语音读取
+    stopPlayback();
+
+    // 2. 发送 event stream
+    let isFirstChunk = true // 是否是第一个 chunk 消息段
+    console.log("doSendMessageStream-userMessage", userMessage)
+    await sendChatMessageStream(
+      userMessage.conversationId,
+      userMessage.content,
+      userMessage.contentAnswer,
+      conversationInAbortController.value,
+      enableContext.value,
+      async res => {
+        const { code, data, msg } = JSON.parse(res.data)
+        if (code !== 0) {
+          console.log(`对话异常! ${msg}`)
+          return
+        }
+
+        if (data.eventType === 'TEXT') {
+
+          // 如果内容为空,就不处理。
+          if (data.receive?.content === '') {
+            return
+          }
+          receiveMessageFullText.value += data.receive.content
+          // 首次返回需要添加一个 message 到页面,后面的都是更新
+          if (isFirstChunk) {
+            isFirstChunk = false
+            // 弹出两个假数据
+            messageList.value.pop()
+            messageList.value.pop()
+            // 更新返回的数据
+            messageList.value.push(data.send)
+            messageList.value.push(data.receive)
+          } else {
+            //更新最后一条消息
+            if (messageList.value.length > 0) {
+              const lastMessage = messageList.value[messageList.value.length - 1]
+              if (lastMessage.id === data.receive.id) {
+                lastMessage.content = receiveMessageFullText.value
+              }
+            }
+          }
+        }
+        if (data.eventType === 'AUDIO') {
+          // 处理音频消息
+          await playAudioChunk(data.audioData);
+        }
+
+        // 添加此行确保触发滚动
+        scrollToBottom()
+      },
+      error => {
+        console.log(`对话异常! ${error}`)
+        stopStream()
+        // 需要抛出异常,禁止重试
+        throw error
+      },
+      () => {
+        console.log(`结束对话! `)
+        stopStream()
+      }
+    )
+  } catch (error) {
+    console.error('发送消息失败:', error)
+    stopStream()
+  }
+}
+
+/** 停止 stream 流式调用 */
+const stopStream = async () => {
+  // 如果 stream 进行中的 message,就需要调用 controller 结束
+  if (conversationInAbortController.value) {
+    conversationInAbortController.value.abort()
+  }
+  // 销毁语音读取
+  // stopPlayback();
+  // 设置为 false
+  conversationInProgress.value = false
+
+  console.log(`结束对话!更改状态: `,conversationInProgress.value)
+}
+
+/** 处理 prompt 输入变化 */
+const handlePromptInput = event => {
+  // 非输入法 输入设置为 true
+  if (!isComposing.value) {
+    // 回车 event data 是 null
+    if (event.data == null) {
+      return
+    }
+    isComposing.value = true
+  }
+  // 清理定时器
+  if (inputTimeout.value) {
+    clearTimeout(inputTimeout.value)
+  }
+  // 重置定时器
+  inputTimeout.value = setTimeout(() => {
+    isComposing.value = false
+  }, 400)
+}
+
+const onCompositionstart = () => {
+  isComposing.value = true
+}
+
+const onCompositionend = () => {
+  setTimeout(() => {
+    isComposing.value = false
+  }, 200)
+}
+
+// 监听props变化
+watch(() => props.questionDialogVisible, (newVal) => {
+  if (newVal && props.currentQuestion) {
+    // 重置选项
+    selectedOption.value = null
+  }
+})
+// 监听showAIDialog变化,在关闭时销毁语音读取
+watch(() => showAIDialog.value, (newVal) => {
+  if (!newVal) {
+    stopPlayback();
+  }
+})
+// 监听消息列表变化,自动滚动到底部
+watch(messageList, () => {
+  scrollToBottom()
+}, { deep: true })
+
+//处理滚动事件,判断用户是否手动滚动
+const handleScroll = () => {
+  if (messageContainer.value) {
+    const { scrollTop, scrollHeight, clientHeight } = messageContainer.value
+    // 当用户滚动距离底部超过50px时,认为是手动滚动
+    userScrolled.value = scrollTop + clientHeight < scrollHeight - 50
+  }
+}
+// 单独的滚动到底部函数
+const scrollToBottom = () => {
+
+  // 如果用户手动滚动过,不自动滚动
+  if (userScrolled.value) return
+
+  nextTick(() => {
+    if (messageContainer.value) {
+      // 强制重排以确保获取最新高度
+      messageContainer.value.scrollTop = messageContainer.value.scrollHeight
+      // 双重保险:使用requestAnimationFrame确保在浏览器重绘后执行
+      requestAnimationFrame(() => {
+        messageContainer.value.scrollTop = messageContainer.value.scrollHeight
+      })
+    }
+  })
+}
+onMounted(() => {
+  // 初始化
+})
+// 组件卸载时清理语音资源
+onUnmounted(() => {
+  stopPlayback();
+  // 确保在组件卸载时也停止 SSE 流
+  if (conversationInProgress.value) {
+    stopStream();
+  }
+});
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+@use 'sass:color'; // 引入 color 模块
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+// 定义儿童风格的蓝紫色调
+$primary-color: rgba(106, 90, 205, 0.52); // 主色调:蓝紫色
+$secondary-color: rgba(147, 112, 219, 0.66); // 辅助色:亮蓝紫色
+$accent-color: rgb(133, 89, 220); // 强调色:暗蓝紫色
+$light-color: #ffffff; // 浅色背景:淡紫色
+$text-color: #483d8b; // 文本颜色:靛蓝色
+
+// 儿童风格试题弹框样式
+.child-dialog {
+  .el-dialog__header {
+    display: none; // 隐藏原有的标题栏
+  }
+
+  .el-dialog__body {
+    padding: rpx(20);
+    position: relative;
+  }
+
+  .el-dialog__footer {
+    border-top: none;
+    padding: rpx(10) rpx(20);
+    text-align: center;
+    margin-top: auto; // 使底部按钮位于底部
+  }
+
+  .el-dialog__wrapper {
+    // 修改半透明背景色
+    background-color: rgba(0, 0, 0, 0.6);
+  }
+
+  .el-dialog {
+    border: none;
+    border-radius: rpx(20);
+    background: linear-gradient(
+      135deg,
+      $light-color,
+      #d8bfd8
+    ); // 柔和的蓝紫色渐变
+    overflow: hidden;
+    display: flex; // 添加 flex 布局
+    flex-direction: column; // 设置垂直布局
+    min-height: 0; // 防止子元素溢出
+    // 添加装饰元素
+    &::before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: rpx(10);
+      background: linear-gradient(90deg, $secondary-color, $accent-color);
+    }
+  }
+}
+
+// 问题标题样式
+.question-title {
+  padding: rpx(15);
+  border-radius: rpx(12);
+  margin-bottom: rpx(20);
+  color: #483d8b;
+  font-weight: bold;
+  font-size: rpx(12);
+  position: relative;
+  display: flex;
+  align-items: center;
+  text-align: left;
+  .question-icon {
+    background-color: $accent-color;
+    color: white;
+    width: rpx(24);
+    height: rpx(24);
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: rpx(10);
+    font-weight: bold;
+    box-shadow: 0 rpx(2) rpx(5) rgba($accent-color, 0.3);
+    flex-shrink: 0;  // 防止图标被压缩
+  }
+}
+
+// 选项容器样式
+.options-container {
+  margin-bottom: rpx(20);
+}
+
+// 问题选项样式
+.question-option {
+  margin: rpx(8) 0;
+  padding: rpx(10) rpx(15);
+  border-radius: rpx(12);
+  border: rpx(1) solid rgba($primary-color, 0.3);
+  transition: all 0.3s ease;
+  display: flex;
+  align-items: center;
+  background-color: white;
+  box-shadow: 0 rpx(2) rpx(5) rgba($primary-color, 0.05);
+
+  ::v-deep(.el-radio__label) {
+    color: $text-color;
+    margin-left: rpx(8);
+    flex: 1;
+    text-align: left;
+    // 增大字体大小
+    font-size: rpx(12);
+  }
+
+  // 选中时的样式变化
+  .el-radio__input.is-checked + .el-radio__label {
+    font-weight: bold;
+    color: $accent-color;
+  }
+
+  &:hover {
+    background-color: rgba($primary-color, 0.05);
+    border-color: rgba($primary-color, 0.5);
+    transform: translateY(-rpx(1));
+  }
+}
+
+// 暂无选项样式
+.no-options {
+  color: rgba($text-color, 0.7);
+  text-align: center;
+  padding: rpx(20);
+  font-size: rpx(12);
+}
+
+// 底部按钮样式
+.child-button {
+  min-width: rpx(70);
+  height: rpx(25);
+  border-radius: rpx(8);
+  font-size: rpx(12);
+  font-weight: 500;
+  transition: all 0.3s ease;
+  box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.1);
+
+  &.confirm {
+    background: linear-gradient(to bottom, #ab81ff, #8559dc);
+    border: none;
+    border-right: 15px;
+    color: white;
+
+    &:hover {
+      background: linear-gradient(
+        to bottom,
+        color.adjust(#ab81ff, $lightness: -5%),
+        color.adjust(#8559dc, $lightness: -5%)
+      );
+      transform: translateY(-rpx(1));
+      color: white;
+    }
+  }
+}
+
+// AI对话图标样式
+.ai-icon-container {
+  position: absolute;
+  bottom: rpx(10);
+  right: rpx(20);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  &:hover {
+    transform: translateY(-rpx(2));
+  }
+  .ai-icon {
+    width: rpx(30);
+    height: rpx(30);
+    margin-bottom: rpx(0);
+    // filter: drop-shadow(0 rpx(2) rpx(4) rgba($primary-color, 0.3));
+    // 添加过渡动画
+    transition: transform 0.3s ease;
+  }
+
+  // 悬浮时放大效果
+  .ai-icon:hover {
+    transform: scale(1.5);
+  }
+
+  .ai-text {
+    color: $text-color;
+    font-size: rpx(8);
+    background-color: rgba(255, 255, 255, 0.7);
+    padding: rpx(2) rpx(5);
+    border-radius: rpx(5);
+  }
+}
+
+// AI对话弹框样式
+.ai-dialog-wrapper {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  z-index: 1001;
+  pointer-events: none;
+}
+
+.ai-dialog {
+  border: none;
+  border-radius: rpx(15);
+  background: rgb(255, 255, 255, 0.8);
+  overflow: hidden;
+  padding: rpx(20);
+  width: 30%;
+  // 增加高度
+  height: 80%;
+  margin-right: rpx(50);
+  pointer-events: auto;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: rpx(10);
+  }
+}
+
+.ai-dialog-header {
+  position: relative;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-bottom: rpx(15);
+  margin-top: rpx(-10);
+
+  img {
+    width: rpx(15);
+  }
+
+  h3 {
+    color: black;
+    font-size: rpx(12);
+  }
+
+  .close-btn {
+    padding: 0;
+    width: rpx(20);
+    height: rpx(20);
+    font-size: rpx(16);
+    line-height: 1;
+    position: absolute;
+    background-color: transparent;
+    border: none;
+    margin-left: rpx(200);
+  }
+}
+
+.ai-dialog-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.ai-message-history {
+  flex: 1;
+  // 当内容超出容器高度时,显示垂直滚动条
+  overflow-y: auto;
+  margin-bottom: rpx(20);
+  // 可以根据实际情况调整最大高度
+  font-size: rpx(5);
+  max-height: 50vh;
+
+  .message {
+    display: flex;
+    align-items: flex-start;
+    margin-bottom: rpx(10);
+
+    &.user {
+      flex-direction: row-reverse;
+    }
+
+    .avatar {
+      width: rpx(30);
+      height: rpx(30);
+      border-radius: 50%;
+      margin: 0 rpx(10);
+    }
+
+    .user {
+      width: 15px;
+      height: 15px;
+    }
+
+    .message-content {
+      background-color: #ffffff;
+      font-size: rpx(5);
+      max-width: 80%;
+      text-align: left;
+      color: black;
+      box-shadow: 0 rpx(1) rpx(3) rgba(0, 0, 0, 0.05);
+    }
+  }
+  // 滚动条整体样式
+  &::-webkit-scrollbar {
+    width: rpx(4); // 滚动条宽度
+  }
+
+  // 滚动条滑块样式
+  &::-webkit-scrollbar-thumb {
+    background-color: $primary-color; // 滑块颜色
+    border-radius: rpx(4); // 滑块圆角
+  }
+
+  // 滚动条轨道样式
+  &::-webkit-scrollbar-track {
+    background-color: rgba($primary-color, 0.2); // 轨道颜色
+    border-radius: rpx(4); // 轨道圆角
+  }
+
+  .message {
+    display: flex;
+    align-items: flex-start;
+    margin-bottom: rpx(10);
+
+    &.user {
+      flex-direction: row-reverse;
+    }
+
+    .avatar {
+      width: rpx(30);
+      height: rpx(30);
+      border-radius: 50%;
+      margin: 0 rpx(10);
+    }
+
+    .message-content {
+      background-color: white;
+      padding: rpx(8) rpx(12);
+      border-radius: rpx(5);
+      font-size: rpx(8);
+      color: black;
+      max-width: 80%;
+      box-shadow: 0 rpx(1) rpx(3) rgba(0, 0, 0, 0.05);
+    }
+  }
+}
+
+// 终止按钮
+.stop-btn {
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  // padding: rpx(5);
+  img {
+    width: rpx(20);
+    height: rpx(20);
+  }
+}
+
+// 用户输入框样式
+.user-input {
+  gap: rpx(5); // 间距
+  ::v-deep(.el-input__wrapper) {
+    height: rpx(23);
+    border-radius: rpx(5);
+    border-color: rgba($primary-color, 0.3);
+
+    &:focus-within {
+      box-shadow: 0 0 0 rpx(1) rgba($primary-color, 0.5);
+    }
+  }
+
+  ::v-deep(.el-input__inner) {
+    font-size: rpx(10);
+    text-indent: 1em;
+  }
+  
+  // 语音按钮样式
+  ::v-deep(.el-input-group__prepend) {
+    width: rpx(15);
+    background: white;
+    border-radius: rpx(5);
+    text-align: center;
+  }
+  
+  ::v-deep(.el-input-group__prepend .el-button.recording) {
+    padding: rpx(5) rpx(10);
+    background: #fff;
+    border: none;
+    border-radius: rpx(5);
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    color: #dc3545;
+  }
+  
+  ::v-deep(.el-input-group__append) {
+    border: none;
+    background: linear-gradient(to bottom, #ab81ff, #8559dc);
+    border-radius: rpx(5);
+    color: white;
+    font-size: rpx(9);
+    border-left: none;
+  }
+}
+
+/* 定义淡入和缩放动画 */
+.fade-scale-enter-active,
+.fade-scale-leave-active {
+  transition: all 0.5s ease;
+}
+
+.fade-scale-enter-from,
+.fade-scale-leave-to {
+  opacity: 0.1;
+  transform: scale(0.9);
+}
+
+// 自定义试题弹框背景
+.child-dialog-wrapper {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.6); // 半透明背景
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 1000;
+}
+
+.child-dialog {
+  border: none;
+  min-width: 40%;
+  border-radius: rpx(15);
+  background: rgb(255, 255, 255, 0.8); // 柔和的蓝紫色渐变
+  overflow: hidden;
+  padding: rpx(10);
+  position: relative;
+}
+
+.default-messages {
+  margin-top: rpx(-10);
+  margin-bottom: rpx(5);
+}
+
+
+</style>

+ 391 - 0
src/components/videopage/VideoPlayer.vue

@@ -0,0 +1,391 @@
+<template>
+  <div class="video-container">
+    <div class="box-video">
+       <!-- 视频 -->
+      <template v-if="contentType === 'video'">
+        <video
+          class="full-box-video"
+          ref="videoRef"
+          :controls="true"
+          @timeupdate="handleTimeUpdate"
+          @seeked="handleSeeked"
+          @ended="handleVideoEnded"
+        ></video>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import {
+  ref,
+  onMounted,
+  onBeforeUnmount,
+  defineProps,
+  defineEmits,
+  watch,
+  nextTick
+} from 'vue'
+
+import { videoPlay as Vue3VideoPlay } from 'vue3-video-play'
+import Hls from 'hls.js'
+import { ElMessage } from 'element-plus'
+// 导入全局年级id
+import { globalState } from '@/utils/globalState.js'
+// 导入图标
+import leftImg from '@/assets/icon/backward.png'
+import rightImg from '@/assets/icon/f-backward.png'
+import { saveRecord } from '@/api/personalized/index.js'
+
+
+// 定义props
+const props = defineProps({
+  contentType: { type: String, required: true }, // contentType类型
+  videoPath: { type: String }, // 变为可选
+  courseId: { type: String, required: true },
+  typeId: { type: String, required: true }, 
+  courseConfigList: { type: Array, default: () => [] },
+  allIndices: { type: Array, default: () => [] },
+  currentIndex: { type: String, required: true }
+})
+
+// 定义emits
+const emits = defineEmits(['timeUpdate', 'videoEnded', 'switchVideo'])
+
+// 视频引用
+const videoRef = ref(null)
+// HLS实例
+const hlsRef = ref(null)
+// 记录已经暂停过的时间点索引
+const pausedIndices = ref([])
+// 记录已经保存的进度百分比
+const savedProgress = ref([])
+// 节流时间间隔(毫秒)
+const THROTTLE_TIME = 3000
+// 上次播放进度
+const lastPlayProgress = ref(0)
+// 定义进度数组
+const targetProgresses = [10, 50, 100]
+
+// 定义节流函数
+const throttle = (fn, delay) => {
+  let lastCall = 0
+  return function (...args) {
+    const now = Date.now()
+    if (now - lastCall >= delay) {
+      lastCall = now
+      return fn.apply(this, args)
+    }
+  }
+}
+
+// 保存进度(带节流)
+const saveProgress = throttle(async (progress, currentTime) => {
+  try {
+    // 保存到localStorage,下次加载视频续播
+    localStorage.setItem(
+      `videoProgress_${props.courseId}`,
+      JSON.stringify({
+        progress: progress,
+        currentTime: currentTime,
+        timestamp: Date.now()
+      })
+    )
+    // 保存视频进度接口
+    await saveRecord({
+      brpNjId: globalState.getGradeId(),
+      brpCtId: props.typeId,
+      brpCourseId: props.courseId,
+      brpType: 'course',
+      brpProgress: progress
+    })
+    savedProgress.value.push(progress)
+  } catch (error) {
+    console.error(`保存进度失败:`, error)
+  }
+}, THROTTLE_TIME)
+
+// 处理视频时间更新事件
+const handleTimeUpdate = ev => {
+  if (!videoRef.value) return
+  const currentTime = parseInt(ev.target.currentTime)
+  const duration = videoRef.value.duration || 0
+  const progressPercentage =
+    duration > 0 ? Math.round((currentTime / duration) * 100) : 0
+
+  // 更新最后播放进度
+  lastPlayProgress.value = progressPercentage
+
+  // 检查是否达到目标进度点且尚未保存
+  targetProgresses.some(target => {
+    const isNearTarget = Math.abs(progressPercentage - target) <= 2
+    const isNotSaved = !savedProgress.value.includes(target)
+    if (isNearTarget && isNotSaved) {
+      // 保存目标进度
+      saveProgress(target, currentTime)
+      return true
+    }
+    return false
+  })
+
+  // 使用节流保存进度
+  saveProgress(progressPercentage, currentTime)
+
+  // 触发父组件的时间更新事件
+  emits('timeUpdate', { currentTime, progressPercentage })
+
+  if (!props.courseConfigList.length) return
+  props.courseConfigList.forEach(courseCofig => {
+    //暂停时间
+    let time = courseCofig.ccTime
+    // 检查是否到达时间点且还未暂停过
+    if (currentTime === time && !pausedIndices.value.includes(time)) {
+      videoRef.value.pause()
+      // 记录暂停时间
+      pausedIndices.value.push(currentTime)
+      // 只有当存在问题内容时才触发弹窗
+      if (courseCofig.ccQuestContent) {
+        // 触发父组件显示试题
+        emits('timeUpdate', {
+          currentTime,
+          progressPercentage,
+          courseConfig: courseCofig
+        })
+      }
+    }
+  })
+}
+
+// 视频完成拖动进度条时触发的方法
+const handleSeeked = () => {
+  pausedIndices.value = []
+}
+
+// 添加视频结束事件处理
+const handleVideoEnded = () => {
+  // 视频结束时保存100%进度
+  if (!savedProgress.value.includes(100)) {
+    saveProgress(100, videoRef.value.duration)
+  }
+  // emits('videoEnded')
+}
+
+// 在视频加载完成后设置上次播放进度
+const setLastPlayPosition = () => {
+  if (!videoRef.value) return
+  try {
+    const savedData = localStorage.getItem(`videoProgress_${props.courseId}`)
+    if (savedData) {
+      const { currentTime, progress } = JSON.parse(savedData)
+      if (currentTime && !isNaN(currentTime)) {
+        videoRef.value.currentTime = currentTime
+        lastPlayProgress.value = progress
+        // 检查是否已有保存的进度点
+        if (progress >= 10) savedProgress.value.push(10)
+        if (progress >= 50) savedProgress.value.push(50)
+        if (progress >= 100) savedProgress.value.push(100)
+      }
+    }
+  } catch (error) {
+    console.error('读取上次播放进度失败:', error)
+  }
+}
+
+// 初始化视频播放器
+const initVideoPlayer = () => {
+  if (props.contentType !== 'video') return
+  // 使用nextTick确保DOM已经更新
+  nextTick(() => {
+    if (!videoRef.value) {
+      console.error('视频元素未找到')
+      return
+    }
+
+    // 清理之前的HLS实例
+    if (hlsRef.value) {
+      hlsRef.value.destroy()
+      hlsRef.value = null
+    }
+
+    // 检查视频路径是否是m3u8格式
+    if (props.videoPath && props.videoPath.toLowerCase().endsWith('.m3u8')) {
+      // 使用HLS播放
+      if (Hls.isSupported()) {
+        hlsRef.value = new Hls()
+        hlsRef.value.loadSource(props.videoPath)
+        hlsRef.value.attachMedia(videoRef.value)
+        hlsRef.value.on(Hls.Events.MANIFEST_PARSED, () => {
+          tryPlayVideo()
+        })
+        hlsRef.value.on(Hls.Events.ERROR, (event, data) => {
+          console.error('HLS错误:', data)
+          ElMessage.error('视频加载失败,请稍后重试')
+        })
+      } else if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
+        // 对于不支持HLS但支持原生m3u8的浏览器
+        videoRef.value.src = props.videoPath
+        tryPlayVideo()
+      } else {
+        ElMessage.error('您的浏览器不支持播放m3u8格式视频')
+      }
+    } else {
+      // 普通视频播放
+      videoRef.value.src = props.videoPath
+      tryPlayVideo()
+    }
+  })
+}
+
+// 尝试播放视频,处理浏览器自动播放限制
+const tryPlayVideo = () => {
+  // 确保videoRef存在
+  if (!videoRef.value) {
+    console.error('视频元素未找到')
+    return
+  }
+
+  // 在视频加载完成后设置上次播放进度
+  setTimeout(() => {
+    setLastPlayPosition()
+  }, 1000)
+}
+
+
+
+// 组件挂载时
+onMounted(() => {
+  initVideoPlayer()
+})
+
+// 监听contentType和videoPath变化
+watch([() => props.contentType, () => props.videoPath], () => {
+  // 当contentType变为video或videoPath变化时,重新初始化
+  if (props.contentType === 'video') {
+    initVideoPlayer()
+  }
+})
+
+// 组件卸载时
+onBeforeUnmount(() => {
+  if (hlsRef.value) {
+    hlsRef.value.destroy()
+    hlsRef.value = null
+  }
+})
+
+</script>
+
+<style scoped lang="scss">
+@use 'sass:math';
+// 定义rpx转换函数
+@function rpx($px) {
+  @return math.div($px, 750) * 100vw;
+}
+
+.box-video {
+  width: 100%;
+  height: rpx(300);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  .d-player-wrap {
+    height: rpx(289);
+    width: 68.5%;
+    border-radius: rpx(12);
+    object-fit: cover;
+  }
+}
+.full-box-video {
+  width: 70%;
+  height: 100%;
+  object-fit: cover;
+  border-radius: rpx(12);
+}
+.ppt-box{
+   width: 100%;
+  height: rpx(300);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  position: relative;
+}
+.ppt-page-info {
+  position: absolute;
+  bottom: rpx(10); // 距离底部10rpx
+  left: 50%; // 水平居中
+  transform: translateX(-50%); // 水平居中调整
+  background-color: rgba(0, 0, 0, 0.5);
+  color: white;
+  padding: rpx(3) rpx(8);
+  border-radius: rpx(4);
+  font-size: rpx(8);
+  z-index: 10;
+}
+.ppt-container {
+  width: 70%;
+  height: 100%;
+  border-radius: rpx(12);
+  overflow: hidden; 
+}
+
+.ppt-container ::v-deep(.pptx-preview-wrapper) {
+  // 滚动条整体样式
+  &::-webkit-scrollbar {
+    width: rpx(0); // 滚动条宽度
+    height: rpx(0);
+  }
+
+  // 滚动条滑块样式
+  &::-webkit-scrollbar-thumb {
+    background-color: #e2ddfc; // 滑块颜色
+    border-radius: rpx(4); // 滑块圆角
+  }
+
+  // 滚动条轨道样式
+  &::-webkit-scrollbar-track {
+    background-color: rgba(143, 116, 255, 0.2); // 轨道颜色
+    border-radius: rpx(4); // 轨道圆角
+  }
+  // border-radius: rpx(12);
+}
+
+
+.ppt-navigation {
+  position: absolute;
+  bottom: rpx(18);
+  width: 70%;
+  display: flex;
+  justify-content: center;
+  gap: rpx(20);
+  padding: 0 rpx(10);
+  box-sizing: border-box;
+}
+
+.ppt-btn {
+  background-color: rgb(255, 255, 255, 0.5);
+  color: white;
+   border: 1px white solid;
+  border-radius: rpx(12);
+  font-size: rpx(7);
+  cursor: pointer;
+  transition: background-color 0.3s;
+  display: flex; // 添加这一行
+  align-items: center; // 添加这一行以确保按钮内文本垂直居中
+  justify-content: center; // 添加这一行以确保按钮内文本水平居中
+  height: rpx(15);
+}
+
+.ppt-btn:hover {
+  background-color: rgba(0, 0, 0, 0.7);
+}
+
+.ppt-prev-btn, .ppt-next-btn {
+  width: rpx(50);
+}
+
+
+/* 隐藏 Chrome 视频控件的渐变背景等默认样式 */
+video::-webkit-media-controls-panel {
+  background: transparent !important; /* 去掉背景渐变,设为透明 */
+}
+</style>

+ 2 - 0
src/main.js

@@ -2,6 +2,7 @@ import { createApp } from 'vue'
 import './style.css'
 import ElementPlus from 'element-plus'
 import 'element-plus/dist/index.css'
+
 import App from './App.vue'
 import router from './router'
 
@@ -9,3 +10,4 @@ const app = createApp(App)
 app.use(ElementPlus)
 app.use(router)
 app.mount('#app')
+

+ 0 - 10
src/router/index.js

@@ -51,20 +51,10 @@ const routes = [
     path: '/ai-questions',
     component: () => import('../views/AIQuestions.vue')
   },
-  // AI初体验
-  {
-    path: '/ai-initial-experience',
-    component: () => import('../views/AIInitialExperience.vue')
-  },
   // 发展历程
   {
     path: '/ai-develop',
     component: () => import('../views/AIDevelop.vue')
-  },
-  // 高年级
-  {
-    path: '/senior-grade',
-    component: () => import('../views/SeniorGrade.vue')
   }
 ]
 const router = createRouter({

Dosya farkı çok büyük olduğundan ihmal edildi
+ 236 - 982
src/views/AIDevelop.vue


+ 190 - 65
src/views/AIGeneralCourse.vue

@@ -78,6 +78,16 @@
               </template>
             </el-dropdown>
           </div>
+          <!-- 实操课按钮 -->
+          <div class="new-button-container">
+            <el-button 
+              type="primary" 
+              @click="handleNewButtonClick"
+              :style="{ background: showPracticalCourse ? 'linear-gradient(to bottom, #ffefb0, #ffcc00)' : '#fee78a' }"
+            >
+              {{ showPracticalCourse ? '返回通识课' : '实操课' }}
+            </el-button>
+          </div>
         </div>
         <div class="inner-box right-box">
           <div class="top-right-box">
@@ -93,7 +103,7 @@
               <template #prefix>
                 <el-icon class="el-input__icon"><search /></el-icon>
               </template>
-              <!-- 添加下拉项模板 -->
+              <!-- 下拉项模板 -->
               <template #popper-append-to-body>
                 <el-option
                 class="scrollbar"
@@ -111,7 +121,7 @@
       <div class="box-2">
         <div
           class="small-box"
-          v-for="(outlineData, index) in classOutlineData"
+          v-for="(outlineData, index) in (showPracticalCourse ? ClassOutlineScData : classOutlineData)"
           :key="index"
           @click="goToAIExperience(outlineData)"
         >
@@ -123,7 +133,7 @@
             }"
           ></div>
           <div class="additional-text">
-            0{{ outlineData.ctTypeSort }} {{ outlineData.ctType }}
+            {{ outlineData.ctTypeSort }} {{ outlineData.ctType }}
           </div>
         </div>
       </div>
@@ -134,8 +144,8 @@
 <script setup>
 import { ref, onMounted, computed, watch } from 'vue'
 
-import { ClassList, ClassOutline } from '@/api/class.js'
-// 添加 Element Plus 组件引入
+import { ClassList, ClassOutline,ClassOutlineSc } from '@/api/class.js'
+//  Element Plus 组件引入
 import {
   ArrowDown,
   ArrowRightBold,
@@ -158,6 +168,54 @@ const selectedGrade = ref('')
 
 // 添加抽屉显示状态
 const drawerVisible = ref(true)
+
+// 实操课
+const ClassOutlineScData = ref([])
+// 状态变量,跟踪当前显示的是通识课还是实操课
+const showPracticalCourse = ref(false)
+
+// 统一函数来获取课程大纲数据 通识课/实操课
+const fetchClassOutline = async (classId) => {
+  try {
+    // 保存通识课数据
+    const res = await ClassOutline(classId)
+    console.log(res);
+    if (res.code === 0) {
+      classOutlineData.value = res.data
+      classOutlineData.value.map((item, index) => {
+        item.ctTypeSort = index + 1
+        item.ctTypeSort = item.ctTypeSort > 9 ? item.ctTypeSort : "0" + item.ctTypeSort
+      })
+    }
+    // 保存实操课数据
+    const Scres = await ClassOutlineSc(classId)
+    console.log(Scres);
+    if (Scres.code === 0) {
+      ClassOutlineScData.value = Scres.data
+      ClassOutlineScData.value.map((item, index) => {
+        item.ctTypeSort = index + 1
+        item.ctTypeSort = item.ctTypeSort > 9 ? item.ctTypeSort : "0" + item.ctTypeSort
+      })
+    }
+  } catch (error) {
+    console.error('获取课程大纲数据失败:', error)
+  }
+}
+
+// 实操课
+const handleNewButtonClick = async() => {
+   // 检查是否有实操课数据
+  if (!showPracticalCourse.value && ClassOutlineScData.value.length === 0) {
+    // 实操课没有数据的时候显示提示
+    Message().notifyWarning('目前暂未开放此课程', true)
+    return
+  }
+  // 切换状态
+  showPracticalCourse.value = !showPracticalCourse.value
+  // 保存状态到localStorage
+  localStorage.setItem('showPracticalCourse', showPracticalCourse.value.toString())
+}
+
 // 处理下拉菜单选择
 const handleGradeSelect = command => {
   selectedGrade.value = command
@@ -168,11 +226,16 @@ const handleGradeSelect = command => {
   // 存储年级id
   if (selectedItem) {
     localStorage.setItem('selectedGradeId', selectedItem.id)
-  }
-  if (selectedItem) {
-    ClassOutline(selectedItem.id).then(res => {
-      if (res.code === 0) {
-        classOutlineData.value = res.data
+    // 获取课程大纲
+    fetchClassOutline(selectedItem.id).then(() => {
+      // 检查是否当前显示的是实操课但没有实操课数据
+      if (showPracticalCourse.value && ClassOutlineScData.value.length === 0) {
+        // 切换回通识课
+        showPracticalCourse.value = false
+        // 显示提示
+        Message().notifyWarning('目前暂未开放此课程', true)
+        // 更新localStorage中的状态
+        localStorage.setItem('showPracticalCourse', 'false')
       }
     })
   }
@@ -183,7 +246,7 @@ const toggleDrawer = () => {
 }
 // 获取年级
 const classData = ref([])
-// 添加接口返回的数据引用,用于存储 ClassOutline 结果
+// 接口返回的数据引用,用于存储 ClassOutline 结果
 const classOutlineData = ref([])
 
 const fetchCtTypes = async () => {
@@ -201,12 +264,8 @@ const fetchCtTypes = async () => {
         classData.value.find(item => item.ctType === selectedGrade.value) ||
         classData.value[0]
       if (selectedItem) {
-        ClassOutline(selectedItem.id).then(res => {
-          console.log(res);
-          if (res.code === 0) {
-            classOutlineData.value = res.data
-          }
-        })
+        // 使用新函数获取课程大纲
+        fetchClassOutline(selectedItem.id)
       }
     }
   } catch (error) {
@@ -214,7 +273,7 @@ const fetchCtTypes = async () => {
   }
 }
 
-// 添加获取课程标题
+// 获取课程标题
 const getCourseTitle = index => {
   if (
     classOutlineData.value.length > 0 &&
@@ -228,8 +287,9 @@ const getCourseTitle = index => {
 
 // 课程标题数组,依赖修改后的 getCourseTitle 函数 修改课程标题数组为 computed 属性
 const courseTitles = computed(() => {
-  return classOutlineData.value.map((item, index) => {
-    return `0${index + 1} ${item.ctType}`;
+  const data = showPracticalCourse.value ? ClassOutlineScData.value : classOutlineData.value
+  return data.map(item => {
+    return `${item.ctTypeSort}${item.ctType}`;
   });
 })
 
@@ -241,33 +301,40 @@ onMounted(() => {
   if (title) {
     pageTitle.value = title
   }
+  // 从localStorage读取状态
+  const savedState = localStorage.getItem('showPracticalCourse')
+  if (savedState !== null) {
+    showPracticalCourse.value = savedState === 'true'
+  }
 })
 
 import { Message } from '@/utils/message/Message.js'
 
 // 搜索框
 const SearchInput = ref('')
-// 添加搜索建议查询方法
+// 搜索建议查询方法
 const querySearch = (queryString, cb) => {
+  const data = showPracticalCourse.value ? ClassOutlineScData.value : classOutlineData.value
   const results = queryString
-    ? classOutlineData.value.filter(item => {
+    ? data.filter(item => {
         return item.ctType.toLowerCase().includes(queryString.toLowerCase())
       })
-    : classOutlineData.value
+    : data
   cb(results)
 }
-// 添加搜索选择处理方法
+// 搜索选择处理方法
 const handleSearchSelect = item => {
   goToAIExperience(item)
-    // 添加以下代码清空输入框
+    // 清空输入框
   SearchInput.value = '';
 }
 // 修改过滤逻辑,直接使用classOutlineData
 const filteredTitles = computed(() => {
+  const data = showPracticalCourse.value ? ClassOutlineScData.value : classOutlineData.value
   if (!SearchInput.value) {
-    return classOutlineData.value
+    return data
   }
-  return classOutlineData.value.filter(title =>
+  return data.filter(title =>
     title.ctType.toLowerCase().includes(SearchInput.value.toLowerCase())
   )
 })
@@ -280,15 +347,16 @@ const goBack = () => {
 }
 
 const goToAIExperience = outlineData => {
-
-  // if (outlineData.ctTypeSort === 2) {
-    router.push({
-      path: '/ai-develop', // 跳转视频页面
-      query: { typeId: outlineData.id, typeName: outlineData.ctType }
-    })
-  // } else {
-  //   Message().notifyWarning(localStorage.getItem('userName') === "aiTest" ? '您的账号并未开放此课程!' : '演示版未开放此课程!', true)
-  // }
+  if (localStorage.getItem('userName') === "aiTest") {
+    if (localStorage.getItem('selectedGradeId') !== "1" || outlineData.ctTypeSort !== "02") {
+      Message().notifyWarning('您的账号并未开放此课程!', true)
+      return
+    }
+  }
+  router.push({
+    path: '/ai-develop', // 跳转视频页面
+    query: { typeId: outlineData.id, typeName: outlineData.ctType }
+  })
 }
 </script>
 
@@ -339,11 +407,26 @@ const goToAIExperience = outlineData => {
   width: rpx(135);
   height: 100%;
   flex-grow: 1;
-  position: relative;
-  background: linear-gradient(to bottom, #001169, #8a78d0);
+  background: linear-gradient(to bottom, hsl(230, 100%, 21%), #8a78d0);  position: relative;
   overflow-y: auto; /* 添加垂直滚动条 */
   max-height: 100%; /* 设置最大高度 */
   transition: all 0.3s ease;
+  // 自定义滚动条样式
+  &::-webkit-scrollbar {
+    width: rpx(0); // 滚动条宽度
+  }
+  &::-webkit-scrollbar-track {
+    background-color: rgba(255, 255, 255, 0.1); // 滚动条轨道背景色
+    border-radius: rpx(2); // 滚动条轨道圆角
+  }
+  &::-webkit-scrollbar-thumb {
+    background-color: rgba(255, 255, 255, 0.3); // 滚动条滑块颜色
+    border-radius: rpx(2); // 滚动条滑块圆角
+    transition: background-color 0.3s ease; // 滑块颜色过渡效果
+  }
+  &::-webkit-scrollbar-thumb:hover {
+    background-color: rgba(255, 255, 255, 0.5); // 鼠标悬停时的滑块颜色
+  }
 }
 .icon-expand {
   width: rpx(8);
@@ -354,7 +437,7 @@ const goToAIExperience = outlineData => {
   position: absolute;
   top: 50%;
   transform: translateY(-50%);
-  cursor: pointer; // 添加鼠标指针样式
+  cursor: pointer; // 鼠标指针样式
   // 修改裁剪路径使右侧边缘垂直无缝贴合
   clip-path: polygon(0 0, 100% 15%, 100% 90%, 0 100%);
   display: flex;
@@ -390,18 +473,19 @@ const goToAIExperience = outlineData => {
   color: white;
   font-size: rpx(9);
   margin-left: rpx(10);
+  white-space: nowrap; /* 防止文字换行 */
 }
 .mb-2 img {
   width: rpx(15);
   height: rpx(15);
   vertical-align: middle;
   margin-top: rpx(-2);
-  margin-left: rpx(-5);
+  margin-left: 0;
 }
 .el-menu-item {
   width: rpx(115);
   // height: rpx(20);
-  margin-bottom: rpx(10);
+  margin-bottom: rpx(5);
   border-radius: rpx(6);
   color: white;
   font-size: rpx(8);
@@ -422,7 +506,8 @@ const goToAIExperience = outlineData => {
 .drawer-box {
   position: absolute;
   display: flex;
-  align-items: center;
+  // align-items: center;
+  margin-top: rpx(30);
   height: 100%;
   width: 100%;
 }
@@ -484,20 +569,46 @@ const goToAIExperience = outlineData => {
 }
 .left-box {
   position: relative;
-  justify-content: flex-start;
-  align-items: flex-start;
-  flex: 1; // 设置左侧盒子占比为 2
+  justify-content: left;
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: rpx(5); // 间距控制
+}
+.new-button-container {
+  display: flex;
+  align-items: center;
+  margin-left: rpx(5); 
+}
+.new-button-container .el-button {
+  width: rpx(60);
+  height: rpx(15);
+  background-color: #fee78a;
+  border: 1px white solid;
+  box-shadow: 0 4px 8px rgb(0, 0, 0, 0.2);
+  color: black;
+  border: none;
+  border-radius: rpx(12);
+  font-size: rpx(8);
+  transition: transform 0.3s ease, box-shadow 0.3s ease;
+}
+.new-button-container .el-button:hover {
+  transform: scale(1.05);
+  background: linear-gradient(
+    to bottom,
+    #fee78a,
+    #ffce1b
+  );
+  box-shadow: 0 6px 12px rgb(0, 0, 0, 0.3);
 }
 .box-icon {
-  width: 100%;
   height: 100%;
-  flex: 0.6;
-  display: flex; // 添加 flex 布局
+  display: flex;
   align-items: center; // 垂直居中
   color: black; // 设置图标颜色为白色
   padding-left: rpx(15);
   font-size: rpx(10); // 设置图标大小,可按需调整
-  cursor: pointer; // 添加鼠标指针样式
+  cursor: pointer; // 鼠标指针样式
   z-index: 999;
 }
 .box-icon .left-icon {
@@ -513,12 +624,9 @@ const goToAIExperience = outlineData => {
   color: white;
 }
 .dropdown-box {
-  width: 100%;
   height: 100%;
-  display: flex; // 添加 flex 布局;
-  flex: 1;
+  display: flex; // flex 布局;
   align-items: center; // 垂直居中;
-  // padding-right: rpx(0); // 添加右侧内边距;
 }
 .dropdown-box .el-button {
   width: rpx(60); // 设置按钮宽度;
@@ -540,7 +648,6 @@ const goToAIExperience = outlineData => {
 }
 .dropdown-menu {
   width: rpx(100);
-  // height: rpx(60);
   border-radius: rpx(5);
   border: 1px white solid;
   background-color: rgb(255, 255, 255, 0.5);
@@ -567,15 +674,17 @@ const goToAIExperience = outlineData => {
   ); /* 设置悬停、聚焦、点击状态下的背景色 */
 }
 .right-box {
-  flex: 2;
+  flex: 1;
   position: relative; // 添加相对定位;
+  // background-color: #fff;
+  display: flex;
+  justify-content: right;
+  align-items: center;
 }
 .top-right-box {
-  position: absolute; // 添加绝对定位
-  margin-left: rpx(260); // 调整右边距离
-  width: rpx(100);
+   width: rpx(130);
   display: flex;
-  justify-content: flex-end;
+  justify-content: flex;
 }
 .top-right-box {
   ::v-deep(.el-input__wrapper) {
@@ -597,6 +706,10 @@ const goToAIExperience = outlineData => {
   ::v-deep(.el-input__inner) {
     color: black;
   }
+  ::v-deep(.el-input--prefix){
+    width: rpx(100);
+    text-align: right;
+  }
 }
 // 搜索
 .search-input {
@@ -613,15 +726,28 @@ const goToAIExperience = outlineData => {
 
 .box-2 {
   width: 100%;
-  flex: 1;
-  box-shadow: 0 4px 8px rgba(202, 52, 52, 0.1);
+  // flex: 1;
   box-sizing: border-box;
-  // padding: 0 rpx(30); // 添加左右内边距
   display: flex; // 确保子元素水平排列
   flex-wrap: wrap; // 允许子元素换行;
-  align-content: center; // 顶部对齐;
   cursor: pointer; // 添加鼠标指针样式
-  margin-top: rpx(-20);
+  // margin: rpx(10) 0; 
+  overflow-y: auto;
+}
+// Chrome、Edge等浏览器的滚动条样式
+.box-2::-webkit-scrollbar {
+  width: rpx(2);
+}
+.box-2::-webkit-scrollbar-track {
+  background: transparent; // 设置滚动条轨道背景
+  border-radius: rpx(3); // 设置滚动条轨道圆角
+}
+.box-2::-webkit-scrollbar-thumb {
+  background: linear-gradient(to bottom, hsl(230, 100%, 21%), #8a78d0);
+  border-radius: rpx(3); // 设置滚动条滑块圆角
+}
+.box-2::-webkit-scrollbar-thumb:hover {
+  background: linear-gradient(to bottom, hsl(230, 100%, 21%), #8a78d0);
 }
 .small-box {
   flex: 0 0 calc(33.333% - rpx(10)); // 每个小盒子占三分之一宽度,减去间距
@@ -649,7 +775,6 @@ const goToAIExperience = outlineData => {
   box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
 }
 .additional-text {
-  margin-top: auto;
   margin-bottom: rpx(4);
   font-size: rpx(8);
 }

+ 3 - 5
src/views/AILaboratory.vue

@@ -45,6 +45,7 @@
           <div class="people-title">{{ person.name }}</div>
         </div>
       </div>
+      
     </div>
   </div>
 </template>
@@ -97,10 +98,8 @@ const peopleList = ref([])
 onMounted(async () => {
   try {
     grade.value = route.query.grade || localStorage.getItem('selectedGrade')
-    console.log(grade)
     // 获取小学低年级数据
     const juniorRes = await teacherList({ category: grade.value })
-    console.log(juniorRes)
     peopleList.value = juniorRes.data.list.map(person => ({
       id: person.id,
       name: person.name,
@@ -108,7 +107,6 @@ onMounted(async () => {
       message: person.systemMessage,
       default: person.questTip
     }))
-    console.log(peopleList.value)
   } catch (error) {
     console.error('获取小学低年级数据失败:', error)
   }
@@ -120,9 +118,9 @@ const navigateToAIQuestions = person => {
     path: '/ai-questions',
     query: {
       ...person,
-      from: 'ai-laboratory'  // 添加来源标识
+      from: 'ai-laboratory',  // 添加来源标识
+      category: grade.value
     },
-    category: grade.value
   })
 }
 

+ 3 - 4
src/views/AIPainting.vue

@@ -254,14 +254,12 @@ const messageCount = ref(0)
 onMounted(async () => {
     // 从全局状态初始化年级ID
   gradeId.value = globalState.initGradeId()
-  console.log(gradeId.value);
   try{
     const res = await saveRecord({
         brpNjId: gradeId.value,
         brpType: "aiCount",
         brpProgress: 1
       });
-      console.log(res);
   }catch(error){
     console.error('保存记录失败:', error);
   }
@@ -302,7 +300,7 @@ const sendMessage = async() => {
     }
 
     CreatePainting({
-      "modelId": 56,
+      "modelId": 57,
       "prompt":content,
       "width":1024,
       "height":1024
@@ -436,6 +434,7 @@ const inProgressTimerFun = () => {
   width: rpx(135);
   height: 100%;
   background: linear-gradient(to bottom, #001169, #8a78d0);
+
 }
 .home-container {
   position: fixed;
@@ -457,7 +456,7 @@ const inProgressTimerFun = () => {
 .left-group {
   width: rpx(135);
   height: 100%;
-  background: linear-gradient(to bottom, #001169, #b4a8e1);
+  background: linear-gradient(to bottom, #001169, #8a78d0);
 }
 .mb-2 {
   color: black;

+ 293 - 53
src/views/AIQuestions.vue

@@ -41,7 +41,7 @@
           <!-- AI对话框 -->
           <div class="chat-dialog">
             <!-- 对话消息列表 -->
-            <div class="message-list">
+            <div class="message-list" ref="messageListRef" @scroll="handleScroll">
               <div v-for="(item, index) in messageList" :key="index">
                 <!-- AI消息 -->
                 <div class="ai-message" v-if="item.type !== 'user'">
@@ -70,7 +70,33 @@
                 placeholder="问我任何问题..."
                 @keyup.enter="handleSendByKeydown"
               />
-              <button @click="handleSendByButton">发送</button>
+              <!-- 添加语音输入按钮 -->
+              <button
+                  @click="toggleSpeechInput"
+                  class="speech-btn"
+                  :class="{ 'recording': isRecording }"
+              >
+                <el-icon v-if="!isRecording"><Microphone /></el-icon>
+                <el-icon v-else><Mute /></el-icon>
+                <!-- 显示倒计时(仅录音时显示) -->
+                <span v-if="isRecording" class="countdown-text">{{ countdown }}s</span>
+              </button>
+
+              <!-- 终止问答按钮 -->
+              <div
+                v-if="conversationInProgress"
+                @click="stopStream"
+                class="stop-btn"
+                title="终止问答"
+              >
+                <img :src="stopicon" alt="停止" />
+              </div>
+              <button
+                v-if="!conversationInProgress"
+                @click="handleSendByButton"
+              >
+                发送
+              </button>
             </div>
           </div>
         </div>
@@ -80,13 +106,15 @@
 </template>
 
 <script setup>
-import { ref, onMounted, computed,watch } from "vue";
+import { ref, onMounted,onUnmounted, computed, watch, nextTick } from "vue";
 import { CreateDialogue, sendChatMessageStream } from "@/api/questions.js";
 import { useRouter, useRoute } from "vue-router";
-import { saveRecord } from '@/api/personalized/index.js'
+import { saveRecord } from "@/api/personalized/index.js";
 // 导入全局状态
-import { globalState } from '@/utils/globalState.js'
+import { globalState } from "@/utils/globalState.js";
 
+// 终止按钮
+import stopicon from "@/assets/icon/stopicon.png";
 
 import MarkdownView from "@/components/MarkdownView/index.vue";
 import {
@@ -102,18 +130,23 @@ import {
   Picture,
   Tickets,
   User,
+  Search, // 使用Search图标作为替代
 } from "@element-plus/icons-vue";
 
 import DefaultMessage from "@/components/DefaultMessage/index.vue";
 
-// 导入图片
-// import question from '@/assets/icon/question.png'
-// import painting from '@/assets/icon/painting.png'
-// import human from '@/assets/icon/human.png'
+
+// 语音图标
+import { Microphone, Mute } from "@element-plus/icons-vue";
 
 import LeftPanel from "@/components/LeftPanel.vue";
 const leftPanelRef = ref(null);
 
+// 语音输入响应式变量
+const isRecording = ref(false); // 录音状态
+const recognition = ref(null); // 语音识别实例
+const countdown = ref(0); // 倒计时剩余秒数
+const countdownTimer = ref(null); // 倒计时定时器
 
 // 默认消息控制
 const showDefaultMessages = ref(true);
@@ -123,7 +156,7 @@ const handleDefaultMessageSelect = (message) => {
   showDefaultMessages.value = false;
 };
 
-// 添加抽屉显示状态
+// 抽屉显示状态
 const drawerVisible = ref(true);
 // 添加切换抽屉显示状态的函数
 const toggleDrawer = () => {
@@ -136,6 +169,8 @@ const handleClose = () => {};
 
 // 返回上一页
 const goBack = () => {
+  // 停止语音播放
+  stopPlayback();
   router.push("/ai-laboratory");
 };
 const router = useRouter();
@@ -174,11 +209,13 @@ const textRoleRunning = ref(false); // Typing speed in milliseconds
 const isComposing = ref(false); // 判断用户是否在输入
 const conversationInAbortController = ref(); // 对话进行中 abort 控制器(控制 stream 对话)
 const inputTimeout = ref(); // 处理输入中回车的定时器
-const prompt = ref(); // prompt
+const prompt = ref(""); // prompt
 const enableContext = ref(true); // 是否开启上下文
 // 接收 Stream 消息
 const receiveMessageFullText = ref("");
 const receiveMessageDisplayedText = ref("");
+const messageListRef = ref(null);
+const userScrolled = ref(false)//是否用户手动滚动
 
 // =========== 【聊天对话】相关 ===========
 
@@ -197,7 +234,90 @@ const getConversation = async (id) => {
   activeConversationModelPath.value = personImage.value;
 };
 
-// =========== 【发送消息】相关 ===========
+// =========== 【语音录入】相关 ===========
+// 初始化语音识别
+const initSpeechRecognition = () => {
+  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+  if (!SpeechRecognition) {
+    alert("当前浏览器不支持语音输入功能");
+    return null;
+  }
+
+  const instance = new SpeechRecognition();
+  instance.lang = 'zh-CN';
+  instance.interimResults = false;
+
+  instance.onresult = (event) => {
+    if (event.results?.[0]?.[0]) {
+      prompt.value += event.results[0][0].transcript;
+    }
+  };
+
+  //识别器结束时清除定时器
+  instance.onend = () => {
+    clearInterval(countdownTimer.value);
+    isRecording.value = false;
+    countdown.value = 0;
+  };
+
+  instance.onerror = (event) => {
+    console.error('语音识别错误:', event.error);
+    clearInterval(countdownTimer.value); // 出错时清除定时器
+    isRecording.value = false;
+    Message().error('语音输入失败,请重试!', true)
+    countdown.value = 0;
+  };
+
+  return instance;
+};
+
+
+// 切换录音状态
+const toggleSpeechInput = () => {
+  // 无论当前状态如何,先清除可能存在的旧定时器
+  clearInterval(countdownTimer.value);
+  countdownTimer.value = null;
+
+  if (isRecording.value) {
+    // 手动停止时重置状态
+    countdown.value = 0;
+    recognition.value?.stop();
+    isRecording.value = false;
+  } else {
+    // 初始化倒计时前再次清除定时器(防止快速点击)
+    clearInterval(countdownTimer.value);
+    countdown.value = 10; // 重置为10秒
+
+    recognition.value = initSpeechRecognition();
+    if (!recognition.value) return;
+
+    navigator.mediaDevices.getUserMedia({ audio: true })
+      .then(() => {
+        recognition.value.start();
+        isRecording.value = true;
+
+        // 启动新的倒计时定时器
+        countdownTimer.value = setInterval(() => {
+          countdown.value--;
+          if (countdown.value <= 0) {
+            clearInterval(countdownTimer.value); // 倒计时结束清除
+            recognition.value.stop();
+            isRecording.value = false;
+            countdown.value = 0;
+          }
+        }, 1000);
+      })
+      .catch((err) => {
+        console.error("麦克风权限获取失败:", err);
+        alert("请允许麦克风权限以使用语音输入");
+        // 出错时重置状态
+        isRecording.value = false;
+        countdown.value = 0;
+      });
+  }
+};
+
+// =========== 【聊天对话】相关 ===========
 
 /** 处理来自 keydown 的发送消息 */
 const handleSendByKeydown = async (event) => {
@@ -259,12 +379,12 @@ const onCompositionend = () => {
 
 // 保存记录
 // 年级ID相关
-const gradeId = ref('')
+const gradeId = ref("");
 // 添加消息计数器变量
-const messageCount = ref(0)
+const messageCount = ref(0);
 onMounted(() => {
-   // 从全局状态初始化年级ID
-  gradeId.value = globalState.initGradeId()
+  // 从全局状态初始化年级ID
+  gradeId.value = globalState.initGradeId();
 });
 
 /** 真正执行【发送】消息操作 */
@@ -278,17 +398,17 @@ const doSendMessage = async (content) => {
     console.error("还没创建对话,不能发送!");
     return;
   }
-   // 递增消息计数器
-  messageCount.value++
+  // 递增消息计数器
+  messageCount.value++;
   // 发送saveRecord请求 保存消息次数
-  try{
+  try {
     await saveRecord({
-        brpNjId: gradeId.value,
-        brpType: "aiCount",
-        brpProgress: messageCount.value
-      });
-  }catch(error){
-    console.error('保存记录失败:', error);
+      brpNjId: gradeId.value,
+      brpType: "aiCount",
+      brpProgress: messageCount.value,
+    });
+  } catch (error) {
+    console.error("保存记录失败:", error);
   }
   // 清空输入框
   prompt.value = "";
@@ -299,6 +419,13 @@ const doSendMessage = async (content) => {
   });
 };
 
+
+import { useAudioPlayer } from '@/api/tts/useAudioPlayer';
+import {Message} from "@/utils/message/Message.js";
+
+// 解构 stopPlayback 方法
+const { playAudioChunk,stopPlayback } = useAudioPlayer();
+
 /** 真正执行【发送】消息操作 */
 const doSendMessageStream = async (userMessage) => {
   // 创建 AbortController 实例,以便中止请求
@@ -325,15 +452,18 @@ const doSendMessageStream = async (userMessage) => {
       createTime: new Date(),
     });
 
-    // 1.3 开始滚动
+    // 1.2 开始滚动
     textRoll();
 
     // 2. 发送 event stream
     let isFirstChunk = true; // 是否是第一个 chunk 消息段
 
+    // 销毁语音读取
+    stopPlayback();
+
     await sendChatMessageStream(
       userMessage.conversationId,
-      userMessage.content,
+      userMessage.content, null,
       conversationInAbortController.value,
       enableContext.value,
       async (res) => {
@@ -342,21 +472,30 @@ const doSendMessageStream = async (userMessage) => {
           console.log(`对话异常! ${msg}`);
           return;
         }
-        // 如果内容为空,就不处理。
-        // if (data.receive.content === '') {
-        //   return
-        // }
-        receiveMessageFullText.value =
-          receiveMessageFullText.value + data.receive.content;
-        // 首次返回需要添加一个 message 到页面,后面的都是更新
-        if (isFirstChunk) {
-          isFirstChunk = false;
-          // 弹出两个假数据
-          activeMessageList.value.pop();
-          activeMessageList.value.pop();
-          // 更新返回的数据
-          activeMessageList.value.push(data.send);
-          activeMessageList.value.push(data.receive);
+
+        // 根据事件类型处理
+        if (data.eventType === "TEXT") {
+          // 如果内容为空,就不处理。
+          if (data.receive?.content === "") {
+            return;
+          }
+
+          // 处理文本消息
+          receiveMessageFullText.value += data.receive.content;
+
+          // 首次返回需要添加一个 message 到页面,后面的都是更新
+          if (isFirstChunk) {
+            isFirstChunk = false;
+            // 弹出两个假数据
+            activeMessageList.value.pop();
+            activeMessageList.value.pop();
+            // 更新返回的数据
+            activeMessageList.value.push(data.send);
+            activeMessageList.value.push(data.receive);
+          }
+        } else if (data.eventType === "AUDIO") {
+          // 处理音频消息
+          await playAudioChunk(data.audioData);
         }
       },
       (error) => {
@@ -366,10 +505,14 @@ const doSendMessageStream = async (userMessage) => {
         throw error;
       },
       () => {
+        console.log(`结束对话! `)
         stopStream();
       }
     );
-  } catch {}
+  } catch (error) {
+    console.error('发送消息失败:', error)
+    stopStream()
+  }
 };
 
 /** 停止 stream 流式调用 */
@@ -380,6 +523,8 @@ const stopStream = async () => {
   }
   // 设置为 false
   conversationInProgress.value = false;
+
+  console.log(`结束对话!更改状态: `,conversationInProgress.value)
 };
 
 /**
@@ -407,15 +552,31 @@ const messageList = computed(() => {
 
 // ============== 【消息滚动】相关 =============
 
+//处理滚动事件,判断用户是否手动滚动
+const handleScroll = () => {
+  if (messageListRef.value) {
+    const { scrollTop, scrollHeight, clientHeight } = messageListRef.value
+    // 当用户滚动距离底部超过50px时,认为是手动滚动
+    userScrolled.value = scrollTop + clientHeight < scrollHeight - 50
+  }
+}
+
 /** 滚动到 message 底部 */
-const scrollToBottom = async (isIgnore) => {
-  // if (messageRef.value) {
-  // messageRef.value.scrollToBottom(isIgnore)
-  // }
+const scrollToBottom = async (isIgnore = false) => {
+  // 如果用户手动滚动过,不自动滚动
+  if (userScrolled.value) return
+
+  await nextTick();
+  if (messageListRef.value) {
+    requestAnimationFrame(() => {
+      messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
+    });
+  }
 };
 
 /** 自提滚动效果 */
 const textRoll = async () => {
+
   let index = 0;
   try {
     // 只能执行一次
@@ -454,6 +615,7 @@ const textRoll = async () => {
         const lastMessage =
           activeMessageList.value[activeMessageList.value.length - 1];
         lastMessage.content = receiveMessageDisplayedText.value;
+
         // 滚动到住下面
         await scrollToBottom();
         // 重新设置任务
@@ -473,6 +635,16 @@ const textRoll = async () => {
   } catch {}
 };
 
+
+// 监听消息列表变化,自动滚动到底部
+watch(
+    () => messageList.value,
+    () => {
+      scrollToBottom();
+    },
+    { deep: true }
+);
+
 /** 初始化 **/
 onMounted(async () => {
   if (personId.value) {
@@ -485,16 +657,49 @@ onMounted(async () => {
       .catch((error) => {
         console.error("请求出错:", error);
       });
-
     await getConversation(personId.value);
   }
-
   // 获取列表数据
   // activeMessageListLoading.value = true
 });
 
-
-
+// 路由参数变化监听
+watch(
+  () => route.query,
+  (newQuery, oldQuery) => {
+    // 只有当id变化时才更新数据,避免不必要的刷新
+    if (newQuery.id && newQuery.id !== oldQuery?.id) {
+       // 停止语音播放
+      stopPlayback();
+      // 更新相关数据
+      personId.value = newQuery.id;
+      personName.value = newQuery.name;
+      personIntroduce.value = newQuery.message;
+      personImage.value = newQuery.image;
+      selectedImage.value = newQuery.image;
+
+      // 重新初始化对话
+      CreateDialogue({ roleId: newQuery.id })
+        .then((res) => {
+          activeConversationId.value = res.data;
+        })
+        .catch((error) => {
+          console.error("请求出错:", error);
+        });
+
+      getConversation(newQuery.id);
+
+      // 重置消息列表和默认消息显示状态
+      activeMessageList.value = [];
+      showDefaultMessages.value = true;
+    }
+  },
+  { immediate: true, deep: true }
+);
+// 组件卸载时清理语音资源
+onUnmounted(() => {
+  stopPlayback();
+});
 </script>
 
 <style scoped lang="scss">
@@ -707,7 +912,42 @@ onMounted(async () => {
 .input-section {
   display: flex;
   padding: rpx(10);
-  gap: rpx(10);
+  gap: rpx(5);
+
+  .speech-btn {
+    padding: rpx(5) rpx(10);
+    background: #fff;
+    border: 1px solid #ffce1b;
+    border-radius: rpx(5);
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+
+    &.recording {
+      background: #ffeeba;
+      border-color: #ffc107;
+
+      .el-icon {
+        color: #dc3545;
+      }
+    }
+
+    .el-icon {
+      font-size: rpx(8);
+      color: #666;
+    }
+  }
+
+  // 终止按钮样式
+  .stop-btn {
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    img {
+      width: rpx(20);
+      height: rpx(20);
+    }
+  }
 }
 .input-section input {
   flex: 1;
@@ -728,6 +968,6 @@ onMounted(async () => {
   font-size: rpx(7);
   border-radius: rpx(5);
   cursor: pointer;
-  box-shadow: 0 4px 8px rgba(202, 52, 52, 0.3);
+  box-shadow: 0 0px 2px rgba(0, 0, 0, 0.3);
 }
 </style>

+ 41 - 17
src/views/Login.vue

@@ -17,13 +17,13 @@
           label-width="0px"
           class="input-item"
         >
-          <el-form-item prop="tenantName">
+          <!-- <el-form-item prop="tenantName">
             <el-input
               v-model="loginData.loginForm.tenantName"
               :prefix-icon="HomeFilled"
               placeholder="租户"
             />
-          </el-form-item>
+          </el-form-item> -->
           <el-form-item prop="username">
             <el-input
               v-model="loginData.loginForm.username"
@@ -54,7 +54,7 @@
               label="记住我"
               size="large"
             />
-            <a href="javascript:;" class="forgot-password">忘记密码?</a>
+            <!-- <a href="javascript:;" class="forgot-password">忘记密码?</a> -->
           </div>
       </div>
     </div>
@@ -79,14 +79,14 @@ const loginData = ref({
     tenantName: import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT || '',
     username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '',
     password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '',
-    rememberMe: true // 默认记录我。如果不需要,可手动修改
+    rememberMe: false // 记住
   }
 })
 
 const loginLoading = ref(false)
 const loading = ref() // ElLoading.service 返回的实例
-const tenantId = ref('')
-// 添加登录状态标识
+const tenantId = ref('1')
+// 登录状态标识
 const isLoggedIn = ref(false)
 
 // 输入框校验
@@ -120,37 +120,44 @@ const handleLogin = async params => {
     if (valid) {
       loginLoading.value = true
       try {
-        await getTenantId()
+        // await getTenantId()
         const loginDataLoginForm = { ...loginData.value.loginForm }
         // 构建包含 headers 的请求参数
         const res = await login(
           { 'Tenant-Id': tenantId.value },
           loginDataLoginForm
         )
-        console.log('登录响应:', res) // 添加日志输出
         if (!res) {
           return
         }
         //【补充】校验登录状态
-        //成功-失败-提示文字
+        // 成功-失败-提示文字
         // 校验登录状态,返回数据中 code 为 200 表示成功
         if (res.code === 0) {
           ElMessage.success('登录成功')
           isLoggedIn.value = true
-          // 存储登录状态
+          // 存储登录状态(无论是否勾选记住我都保存基本登录状态)
+          localStorage.setItem('isLoggedIn', 'true')
+          localStorage.setItem('token', res.data.accessToken)
+          
           if (loginData.value.loginForm.rememberMe) {
-            localStorage.setItem('isLoggedIn', 'true')
-            localStorage.setItem('token', res.data.accessToken)
             localStorage.setItem('userName', loginData.value.loginForm.username)
-
+            // 保存租户名称和密码
+            localStorage.setItem('tenantName', loginData.value.loginForm.tenantName)
+            localStorage.setItem('password', loginData.value.loginForm.password)
             // 根据账号类型设置可查看的课程小节数
             if (loginData.value.loginForm.username === 'aiTest') {
               localStorage.setItem('maxCourseSections', '5')
             } else if (loginData.value.loginForm.username === 'aiAdmin') {
               localStorage.setItem('maxCourseSections', 'all')
             }
+          } else {
+            // 如果没有勾选记住我,清除之前信息
+            localStorage.removeItem('password')
+            localStorage.removeItem('userName')
+            localStorage.removeItem('tenantName')
+            localStorage.removeItem('maxCourseSections')
           }
-
           loading.value = ElLoading.service({
             lock: true,
             text: '正在加载系统中...',
@@ -174,9 +181,26 @@ const handleLogin = async params => {
   })
 }
 
-// 在组件挂载时检查登录状态
+// 在组件挂载时检查登录状态和恢复登录信息
 onMounted(() => {
-  const storedStatus = localStorage.getItem('isLoggedIn')
+  const storedStatus = localStorage.getItem('isLoggedIn') // isLoggedIn
+  const storedTenantName = localStorage.getItem('tenantName')
+  const storedUserName = localStorage.getItem('userName')
+  const storedPassword = localStorage.getItem('password')
+  
+  // 恢复登录信息到输入框
+  if (storedTenantName) {
+    loginData.value.loginForm.tenantName = storedTenantName
+  }
+  if (storedUserName) {
+    loginData.value.loginForm.username = storedUserName
+  }
+  if (storedPassword) {
+    loginData.value.loginForm.password = storedPassword
+    loginData.value.loginForm.rememberMe = true
+  }
+  
+  // 检查登录状态,如果已登录则直接跳转到首页
   if (storedStatus === 'true') {
     isLoggedIn.value = true
     router.push('/home')
@@ -250,7 +274,7 @@ onMounted(() => {
 }
 .el-form-item ::v-deep(.el-form-item__error) {
   top: 0;
-  padding-top: rpx(20);
+  padding-top: rpx(25);
 }
 .input-item .el-button {
   width: rpx(150);

+ 3 - 3
src/views/personalized/Personalized.vue

@@ -103,7 +103,6 @@ onMounted(async()=>{
   gradeId.value = globalState.initGradeId()
   try {
     const res = await getReport({ brpNjId: gradeId.value });
-    console.log(res);
     // 赋值三个数据以及评语
     aiCount.value = res.aiCount;
     njCourseConfigProgress.value = res.njCourseConfigProgress;
@@ -296,7 +295,7 @@ onMounted(async()=>{
 .demo-progress {
   padding-left: rpx(10);
   padding-right: rpx(10);
-  padding-bottom: rpx(3);
+  padding-bottom: rpx(1);
 }
 
 .demo-progress .el-progress--line,
@@ -305,6 +304,7 @@ onMounted(async()=>{
 }
 
 ::v-deep(.el-progress-bar__outer) {
+  height: rpx(10);
   background-color: #fff;
 }
 
@@ -323,7 +323,7 @@ onMounted(async()=>{
 .progress-desc {
   font-size: rpx(8);
   color: black;
-  margin-bottom: rpx(0);
+  margin-bottom: rpx(3);
   text-align: left;
 }
 

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor