Преглед изворни кода

Merge branch 'refactor' of http://gogs.seec.seecoder.cn/FanYanPeng/EAIFrontend into refactor

lalala пре 1 година
родитељ
комит
80f5feaed9
30 измењених фајлова са 4003 додато и 356 уклоњено
  1. 50 15
      package-lock.json
  2. 5 2
      package.json
  3. 37 0
      src/apis/ai_speaking_assignment.ts
  4. 1 1
      src/apis/assignment.ts
  5. 119 0
      src/apis/doubao.ts
  6. 46 0
      src/apis/media.ts
  7. BIN
      src/assets/ashleigh.png
  8. BIN
      src/assets/gavin.png
  9. 1 0
      src/assets/global.css
  10. BIN
      src/assets/laura.png
  11. BIN
      src/assets/ryan.png
  12. BIN
      src/assets/user.png
  13. 18 0
      src/router/index.ts
  14. 16 0
      src/types/dto.ts
  15. 1 0
      src/types/query.ts
  16. 7 2
      src/types/vo.ts
  17. 1444 0
      src/views/AiSpeakingPage/SpeakingPage.vue
  18. 1109 0
      src/views/AiSpeakingPage/SpeakingRecord.vue
  19. 249 0
      src/views/AiSpeakingPage/component/VoiceAnalysis.vue
  20. 1 0
      src/views/CorrectPage/AssignmentPage.vue
  21. 22 9
      src/views/CorrectPage/CorrectAssignmentPage.vue
  22. 4 2
      src/views/CorrectPage/CoursePage.vue
  23. 2 0
      src/views/CourseSquare/component/Course/index.vue
  24. 376 0
      src/views/CourseSquare/component/CourseDetails/component/AssignmentDialogue/AiSpeakingAssignmentDialog.vue
  25. 355 0
      src/views/CourseSquare/component/CourseDetails/component/AssignmentDialogue/WritingAssignmentDialogue.vue
  26. 73 316
      src/views/CourseSquare/component/CourseDetails/component/CourseHomeworkTable.vue
  27. 10 2
      src/views/CourseSquare/component/CourseDetails/index.vue
  28. 31 4
      src/views/HomeworkPage/component/HomeworkDetail.vue
  29. 15 3
      src/views/HomeworkPage/index.vue
  30. 11 0
      src/views/LoginPage/component/Register.vue

+ 50 - 15
package-lock.json

@@ -8,13 +8,16 @@
       "name": "eai-frontend-vue",
       "version": "0.0.0",
       "dependencies": {
+        "@element-plus/icons-vue": "^2.3.1",
         "@onlyoffice/document-editor-vue": "^1.4.0",
         "@vue-office/docx": "^1.6.2",
         "@vueup/vue-quill": "^1.0.0-alpha.40",
         "axios": "^1.7.2",
         "dayjs": "^1.11.13",
         "docx-preview": "^0.3.2",
-        "element-plus": "^2.8.1",
+        "echarts": "^5.6.0",
+        "element-plus": "^2.9.7",
+        "js-audio-recorder": "^1.0.7",
         "mitt": "^3.0.1",
         "pinia": "^2.1.7",
         "pinia-plugin-persistedstate": "^3.2.1",
@@ -33,7 +36,7 @@
         "eslint": "^8.57.0",
         "eslint-plugin-vue": "^9.23.0",
         "npm-run-all2": "^6.1.2",
-        "sass": "^1.77.5",
+        "sass": "^1.86.1",
         "typescript": "~5.4.0",
         "vite": "^5.2.8",
         "vue-tsc": "^2.0.11"
@@ -676,6 +679,7 @@
       "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.4.1.tgz",
       "integrity": "sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==",
       "dev": true,
+      "optional": true,
       "dependencies": {
         "detect-libc": "^1.0.3",
         "is-glob": "^4.0.3",
@@ -2093,6 +2097,7 @@
       "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-1.0.3.tgz",
       "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
       "dev": true,
+      "optional": true,
       "bin": {
         "detect-libc": "bin/detect-libc.js"
       },
@@ -2145,10 +2150,19 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
+      "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      }
+    },
     "node_modules/element-plus": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.8.1.tgz",
-      "integrity": "sha512-p11/6w/O0+hGvPhiN3jrcgh+XG+eg5jZlLdQVYvcPHZYhhCh3J3YeZWW1JO/REPES1vevkboT6VAi+9wHA8Dsg==",
+      "version": "2.9.7",
+      "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.9.7.tgz",
+      "integrity": "sha512-6vjZh5SXBncLhUwJGTVKS5oDljfgGMh6J4zVTeAZK3YdMUN76FgpvHkwwFXocpJpMbii6rDYU3sgie64FyPerQ==",
       "dependencies": {
         "@ctrl/tinycolor": "^3.4.1",
         "@element-plus/icons-vue": "^2.3.1",
@@ -2158,7 +2172,7 @@
         "@types/lodash-es": "^4.17.6",
         "@vueuse/core": "^9.1.0",
         "async-validator": "^4.2.5",
-        "dayjs": "^1.11.3",
+        "dayjs": "^1.11.13",
         "escape-html": "^1.0.3",
         "lodash": "^4.17.21",
         "lodash-es": "^4.17.21",
@@ -2883,9 +2897,9 @@
       "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
     },
     "node_modules/immutable": {
-      "version": "4.3.7",
-      "resolved": "https://registry.npmmirror.com/immutable/-/immutable-4.3.7.tgz",
-      "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.1.tgz",
+      "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
       "dev": true
     },
     "node_modules/import-fresh": {
@@ -3026,6 +3040,11 @@
       "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
       "dev": true
     },
+    "node_modules/js-audio-recorder": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/js-audio-recorder/-/js-audio-recorder-1.0.7.tgz",
+      "integrity": "sha512-JiDODCElVHGrFyjGYwYyNi7zCbKk9va9C77w+zCPMmi4C6ix7zsX2h3ddHugmo4dOTOTCym9++b/wVW9nC0IaA=="
+    },
     "node_modules/js-yaml": {
       "version": "4.1.0",
       "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -3277,7 +3296,8 @@
       "version": "7.1.1",
       "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
       "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
-      "dev": true
+      "dev": true,
+      "optional": true
     },
     "node_modules/normalize-wheel-es": {
       "version": "1.2.0",
@@ -3819,14 +3839,13 @@
       "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
     },
     "node_modules/sass": {
-      "version": "1.80.1",
-      "resolved": "https://registry.npmmirror.com/sass/-/sass-1.80.1.tgz",
-      "integrity": "sha512-9lBwDZ7j3y/1DKj5Ec249EVGo5CVpwnzIyIj+cqlCjKkApLnzsJ/l9SnV4YnORvW9dQwQN+gQvh/mFZ8CnDs7Q==",
+      "version": "1.86.1",
+      "resolved": "https://registry.npmmirror.com/sass/-/sass-1.86.1.tgz",
+      "integrity": "sha512-Yaok4XELL1L9Im/ZUClKu//D2OP1rOljKj0Gf34a+GzLbMveOzL7CfqYo+JUa5Xt1nhTCW+OcKp/FtR7/iqj1w==",
       "dev": true,
       "dependencies": {
-        "@parcel/watcher": "^2.4.1",
         "chokidar": "^4.0.0",
-        "immutable": "^4.0.0",
+        "immutable": "^5.0.2",
         "source-map-js": ">=0.6.2 <2.0.0"
       },
       "bin": {
@@ -3834,6 +3853,9 @@
       },
       "engines": {
         "node": ">=14.0.0"
+      },
+      "optionalDependencies": {
+        "@parcel/watcher": "^2.4.1"
       }
     },
     "node_modules/semver": {
@@ -4023,6 +4045,11 @@
         "typescript": ">=4.2.0"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
@@ -4331,6 +4358,14 @@
       "funding": {
         "url": "https://github.com/sponsors/sindresorhus"
       }
+    },
+    "node_modules/zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
+      "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
     }
   }
 }

+ 5 - 2
package.json

@@ -12,13 +12,16 @@
     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
   },
   "dependencies": {
+    "@element-plus/icons-vue": "^2.3.1",
     "@onlyoffice/document-editor-vue": "^1.4.0",
     "@vue-office/docx": "^1.6.2",
     "@vueup/vue-quill": "^1.0.0-alpha.40",
     "axios": "^1.7.2",
     "dayjs": "^1.11.13",
     "docx-preview": "^0.3.2",
-    "element-plus": "^2.8.1",
+    "echarts": "^5.6.0",
+    "element-plus": "^2.9.7",
+    "js-audio-recorder": "^1.0.7",
     "mitt": "^3.0.1",
     "pinia": "^2.1.7",
     "pinia-plugin-persistedstate": "^3.2.1",
@@ -37,7 +40,7 @@
     "eslint": "^8.57.0",
     "eslint-plugin-vue": "^9.23.0",
     "npm-run-all2": "^6.1.2",
-    "sass": "^1.77.5",
+    "sass": "^1.86.1",
     "typescript": "~5.4.0",
     "vite": "^5.2.8",
     "vue-tsc": "^2.0.11"

+ 37 - 0
src/apis/ai_speaking_assignment.ts

@@ -0,0 +1,37 @@
+import axiosInstance from "@/apis/axios.config";
+import type {AISpeakingAssignmentDTO} from "@/types/dto";
+
+const ASSIGNMENT_PREFIX = "/api/aispeaking"
+
+const aiSpeakingAssignmentApis = {
+    createAiSpeakingAssignment(courseId:number, assignmentDTO: AISpeakingAssignmentDTO){
+        return axiosInstance.post(ASSIGNMENT_PREFIX, assignmentDTO, {
+            params: {
+                courseId
+            }
+        });
+    },
+
+    getAiSpeakingAssignmentById(assignmentId: number){
+        return axiosInstance.get(`${ASSIGNMENT_PREFIX}/byAssignment/${assignmentId}`)
+    },
+    
+    updateAiSpeakingAssignment(assignmentId:number, assignmentDTO:AISpeakingAssignmentDTO){
+        return axiosInstance.post(`${ASSIGNMENT_PREFIX}/update`, assignmentDTO, {
+            params: {
+                assignmentId
+            }
+        })
+    },
+    deleteAiAssignmentById(teacherId:number, assignmentId:number){
+        return axiosInstance.delete(`${ASSIGNMENT_PREFIX}`, {
+            params: {
+                teacherId,
+                assignmentId
+            }
+        })
+
+    }
+}
+
+export default aiSpeakingAssignmentApis

+ 1 - 1
src/apis/assignment.ts

@@ -34,7 +34,7 @@ const assignmentApis = {
         })
     },
     updateAssignment(assignmentId:number, assignmentDTO:AssignmentDTO){
-        return axiosInstance.put(`${ASSIGNMENT_PREFIX}`, assignmentDTO, {
+        return axiosInstance.post(`${ASSIGNMENT_PREFIX}/update`, assignmentDTO, {
             params: {
                 assignmentId
             }

+ 119 - 0
src/apis/doubao.ts

@@ -0,0 +1,119 @@
+import axiosInstance from "@/apis/axios.config";
+const SPEAKING_PREFIX = "/api/doubao"
+
+const doubaoApis = {
+
+    startChat:(userId: Number, assignmentId: Number, userQuestion: String, voiceCode: Number) =>{
+        return axiosInstance.get(`${SPEAKING_PREFIX}/startChat`, {
+            params: {
+                userId,
+                assignmentId,
+                userQuestion,
+                voiceCode
+            }
+        });
+    },
+    sendMessage:(userId: Number, assignmentId: Number, userQuestion: String, audioUrl: String, audioTime: Number, voiceCode: Number) => {
+        return axiosInstance.get(`${SPEAKING_PREFIX }/sendMessage`, {
+            params: {
+                userId,
+                assignmentId,
+                userQuestion,
+                audioUrl,
+                audioTime,
+                voiceCode
+            }
+        });
+    },
+
+    endChat:(userId: Number, assignmentId: Number) =>{
+        return axiosInstance.get(`${SPEAKING_PREFIX }/endChat`, {
+            params: {
+                userId,
+                assignmentId
+            }
+        });
+    },
+
+    getChatHistory:(userId: Number, assignmentId: Number) =>{
+        return axiosInstance.get(`${SPEAKING_PREFIX}/getChatHistory`, {
+            params: {
+                userId,
+                assignmentId
+            }
+        });
+    },
+
+    deleteChatHistory:(userId : number, assignmentId: Number, userQuestion: String, voiceCode: Number) => {
+        return  axiosInstance.delete(`${SPEAKING_PREFIX }/deleteChatHistory`, {
+            params: {
+                userId,
+                assignmentId,
+                userQuestion,
+                voiceCode
+            }
+        });
+    },
+
+    saveSigment:(userId : number, assignmentId: Number, sigmentUrl: String, duration: Number) => {
+        return  axiosInstance.get(`${SPEAKING_PREFIX }/saveSigment`, {
+            params: {
+                userId,
+                assignmentId,
+                sigmentUrl,
+                duration
+            }
+        });
+    },
+    getSigment:(userId : number, assignmentId: Number) => {
+        return  axiosInstance.get(`${SPEAKING_PREFIX }/getSigment`, {
+            params: {
+                userId,
+                assignmentId
+            }
+        });
+    },
+
+    getRecord:(userId : Number, assignmentId: Number) => {
+        return  axiosInstance.get(`${SPEAKING_PREFIX }`, {
+            params: {
+                userId,
+                assignmentId
+            }
+        });
+    },
+
+    getAudioScore:(userId: Number, assignmentId: Number, index: Number)=>{
+        return axiosInstance.get(`${SPEAKING_PREFIX}/audioScore`, {
+            params: {
+                userId,
+                assignmentId,
+                index
+            }
+        })
+    },
+    getAudioSentiment:(userId: Number, assignmentId: Number, index: Number)=>{
+        return axiosInstance.get(`${SPEAKING_PREFIX}/audioSentiment`, {
+            params: {
+                userId,
+                assignmentId,
+                index
+            }
+        })
+    },
+    getRestricData:(userId: Number, assignmentId: Number)=>{
+        return axiosInstance.get(`${SPEAKING_PREFIX}/restrictData`, {
+            params: {
+                userId,
+                assignmentId
+            }
+        })
+    }
+
+
+
+
+   
+}
+
+export default doubaoApis

+ 46 - 0
src/apis/media.ts

@@ -0,0 +1,46 @@
+import { useStore } from './../store/index';
+import axiosInstance from "./axios.config";
+
+const MEDIA_PREFIX = "/api/media"
+
+const mediaApis = {
+
+    getAudioScore:(userId: Number, assignmentId: Number, index: Number)=>{
+        return axiosInstance.get(`${MEDIA_PREFIX}/audioScore`, {
+            params: {
+                userId,
+                assignmentId,
+                index
+            }
+        })
+    },
+    getAudioSentiment:(userId: Number, assignmentId: Number, index: Number)=>{
+        return axiosInstance.get(`${MEDIA_PREFIX}/audioSentiment`, {
+            params: {
+                userId,
+                assignmentId,
+                index
+            }
+        })
+    },
+    getSpeakingSuggestion:(userId: Number, assignmentId: Number) => {
+        return axiosInstance.get(`${MEDIA_PREFIX}/speakingSuggestion`, {
+            params: {
+                userId,
+                assignmentId
+            }
+        })
+    },
+    getVideoSentiment:(userId: Number, assignmentId: Number) => {
+        return axiosInstance.get(`${MEDIA_PREFIX}/videoSentiment`, {
+            params: {
+                userId,
+                assignmentId
+            }
+        })
+    }
+
+   
+}
+
+export default mediaApis

BIN
src/assets/ashleigh.png


BIN
src/assets/gavin.png


+ 1 - 0
src/assets/global.css

@@ -14,3 +14,4 @@ body, html, #app {
     --system-color: #597D3B;
     --admin-color: #90d7ec
 }
+

BIN
src/assets/laura.png


BIN
src/assets/ryan.png


BIN
src/assets/user.png


+ 18 - 0
src/router/index.ts

@@ -79,6 +79,24 @@ const router = createRouter({
           name: 'homeworkDetail',
           component: () => import("@/views/HomeworkPage/component/HomeworkDetail.vue")
         },
+        {
+          path: 'assignment/:assignmentId/aispeaking',
+          name: 'aispeaking',
+          component: () => import('../views/AiSpeakingPage/SpeakingPage.vue'),
+          props: (route) => ({
+            assignmentId: Number(route.query.assignmentId)
+          })
+        },
+        {
+          path: 'assignment/:assignmentId/airecord',
+          name: 'airecord',
+          component: () => import('../views/AiSpeakingPage/SpeakingRecord.vue'),
+          props: (route) => ({
+            userId: Number(route.query.userId),
+            assignmentId: Number(route.query.assignmentId),
+            assignmentName: route.query.assignmentName || ''
+          })
+        },
         {
           path: 'assignment/:assignmentId/edit',
           name: 'edit',

+ 16 - 0
src/types/dto.ts

@@ -3,18 +3,33 @@ import {AssignmentStatus, Role} from './enums';
 export interface AssignmentDTO {
     assignmentName: string; // 作业名称不能缺失
     description: string; // 作业描述不能缺失
+    type:string, //作业类型不能缺失
     descriptionFile?: string; // 作业要求,Word/Pdf文件 (可选)
     attachments?: string[]; // 作业附件 (可选)
     startTime: string; // 起始时间不能缺失 (ISO 8601 日期时间字符串)
     endTime: string; // 结束时间不能缺失 (ISO 8601 日期时间字符串)
 }
 
+export interface AISpeakingAssignmentDTO {
+    assignmentName: string; // 作业名称不能缺失
+    description?: string; // 作业描述不能缺失
+    descriptionFile?:string;
+    type:string,//作业类型不能缺失
+    startTime: string; // 起始时间不能缺失 (ISO 8601 日期时间字符串)
+    endTime: string; // 结束时间不能缺失 (ISO 8601 日期时间字符串)
+    minDuration:number;//最小时长
+    minInteraction:number;//最小交流次数
+    surveyLinks:string[],
+    surveyDuration:number
+}
+
 export interface AssignmentQueryDTO {
     assignmentId?: number;
     teacherId?: number;
     courseId?: number;
     assignmentName?: string;
     status?: AssignmentStatus;
+    type:string;
 }
 
 export interface EnrollDTO {
@@ -49,6 +64,7 @@ export interface UserRegisterDTO {
     name: string; // 真实姓名不能缺失
     password: string; // 密码(加密存储)不能缺失
     officialEmail: string; // 南大官邮,xxx@smail.nju.edu.cn 不能缺失
+    phone: string;
     verifyCode: string; // 验证码不能缺失
     officialNumber: string; // 南大学号、工号不能缺失
     role: Role; // 用户角色:学生、教师、管理员不能缺失

+ 1 - 0
src/types/query.ts

@@ -14,6 +14,7 @@ export interface AssignmentQuery {
     courseId:number;
     assignmentName:string;
     status:string;
+    type:string;
 }
 
 export interface EngagementQuery {

+ 7 - 2
src/types/vo.ts

@@ -38,6 +38,8 @@ export interface IAssignment {
 
     description: string;
 
+    type:string;
+
     descriptionFile: string|null; // 作业要求,Word/Pdf文件
 
     attachments: string[]|null; // 作业附件
@@ -64,6 +66,8 @@ export interface AssignmentVO {
 
     description: string;
 
+    type:string;
+
     descriptionFile: string|null; // 作业要求,Word/Pdf文件
 
     attachments: string[]|null; // 作业附件
@@ -83,13 +87,14 @@ export interface AssignmentVO {
 
 export interface IAssignmentTableColumn {
     title: string;
-    dataIndex: 'desc' | 'publisher' | 'startDate' | 'endDate' | 'details' | 'writing' | 'assignmentId' | 'teacherId';
-    key: 'desc' | 'publisher' | 'startDate' | 'endDate' | 'details' | 'writing' | 'assignmentId' | 'teacherId';
+    dataIndex: 'desc' |'type' | 'publisher' | 'startDate' | 'endDate' | 'details' | 'writing' | 'assignmentId' | 'teacherId';
+    key: 'desc' | 'type' | 'publisher' | 'startDate' | 'endDate' | 'details' | 'writing' | 'assignmentId' | 'teacherId';
     width: number
 }
 
 export interface IAssignmentTableItem {
     desc: string;
+    type:string;
     publisher: string;
     startDate: string;
     endDate: string;

+ 1444 - 0
src/views/AiSpeakingPage/SpeakingPage.vue

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

+ 1109 - 0
src/views/AiSpeakingPage/SpeakingRecord.vue

@@ -0,0 +1,1109 @@
+<template>
+  <div class="container">
+    <div class="header-container">
+      <!-- 返回按钮 -->
+      <el-button style="color:#597D3B" class="back-button" type="text" @click="goBack">
+        <el-icon><Back /></el-icon>
+      </el-button>
+      <!-- 标题 -->
+    </div>
+    <!-- 基本信息卡片 -->
+    <el-card class="compact-card">
+      <template #header>
+        <h1 class="page-title">{{props.assignmentName}}</h1>
+        <div class="card-header">整体信息</div>
+        <div class="card-time">
+          提交时间:{{
+            new Date(speakingAIDialogue.committedTime).toLocaleString()
+          }}
+        </div>
+      </template>
+      <span class="info-label" style="margin-left:3%">数据统计</span>
+      <div class="time-info">
+        <div class="word-count-box">
+          <div class="label">作业时长</div>
+          <span class="value"
+            >{{
+              Number(speakingAIDialogue.totalVideoTime).toFixed(2) || 0
+            }}
+            s</span
+          >
+        </div>
+        <div class="word-count-box">
+          <div class="label">录音时长</div>
+          <span class="value"
+            >{{
+              Number(speakingAIDialogue.totalSpeakingTime).toFixed(2) || 0
+            }}
+            s</span
+          >
+        </div>
+        <div class="word-count-box">
+          <div class="label">总单词数</div>
+          <span class="value">{{ Number(speakingAIDialogue.totalUserWordCount) || 0 }}</span>
+        </div>
+        <div class="word-count-box">
+          <div class="lable">交流次数</div>
+          <span class="value">{{ Number(speakingAIDialogue.chatTimes) }}</span>
+        </div>
+      </div>
+      <div class="time-info">
+        <div class="word-count-box">
+          <div class="label">发言时长占比</div>
+          <div class="value">{{(Number(speakingAIDialogue.totalUserWordCount)/ Number(speakingAIDialogue.totalAssistantWordCount) * 100).toFixed(0) }}%</div>
+        </div>
+        <div class="word-count-box">
+          <div class="label">每分钟单词数</div>
+          <div class="value">{{(Number(speakingAIDialogue.totalUserWordCount)/ Number(speakingAIDialogue.totalSpeakingTime) * 60).toFixed(0) }}</div>
+        </div>
+        <div class="word-count-box">
+          <div class="label">平均句长</div>
+          <div class="value">{{(Number(speakingAIDialogue.totalUserWordCount)/ Number(speakingAIDialogue.chatTimes)).toFixed(0) }}</div>
+        </div>
+      </div>
+      <!-- 新增的图表容器 -->
+      <div class="info-item">
+        <span class="info-label">口语评分</span>
+        <div
+          ref="avgScoreChart"
+          class="chart-container"
+          style="width: 100%; height: 300px"
+        ></div>
+      </div>
+      <div class="info-item">
+        <span class="info-label">录像片段</span>
+        <div class="video-segments">
+          <div
+            v-for="(url, index) in speakingAIDialogue.audioSigment"
+            :key="index"
+            class="video-item"
+          >
+            <video
+              :src="url.split('?')[0]"
+              controls
+              class="video-player"
+            ></video>
+            <div class="duration-label">
+              Duration: {{ getDuration(index) }}s
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="info-item">
+        <span class="info-label">录像情感</span>
+        <div class="video-segments" v-loading="isVideoEmotionLoading" 
+        element-loading-text="拼命加载中..."
+        element-loading-spinner="el-icon-loading"
+        element-loading-background="rgba(0, 0, 0, 0.1)">
+          <video
+            v-if="speakingAIDialogue.videoAnalysis"
+            :src="speakingAIDialogue.videoAnalysis.merged_video_path"
+            controls
+            class="video-player"
+          ></video>
+          <div
+            ref="emotionChartRef"
+            style="margin-left:40%; width:400px; height: 300px"
+          ></div>
+        </div>
+      </div>
+    </el-card>
+    <el-card class="compact-card">
+      <template #header>
+        <div class="card-header">建议信息</div>
+        <el-table
+        :data="formattedSuggestions"
+        stripe
+        v-loading="isSuggestionLoading"
+        element-loading-text="拼命加载中..."
+        element-loading-spinner="el-icon-loading"
+        element-loading-background="rgba(0, 0, 0, 0.1)"
+      >
+        <el-table-column prop="title" label="方向" width="120px">
+          <template #default="{ row }">
+            <span class="highlight-title">{{ row.title }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="items" label="建议项">
+          <template #default="{ row }">
+            <span>{{ String(row.items).split("-").join("\n") }}</span>
+          </template>
+        </el-table-column>
+      </el-table>
+      </template>
+
+    </el-card>
+
+    <!-- 对话条目卡片 -->
+    <el-card class="compact-card">
+      <template #header>
+        <div class="card-header">对话条目</div>
+      </template>
+      <el-table
+        :data="speakingAIDialogue.dialogues.slice(1, -2)"
+        stripe
+        style="width: 100%; min-width: 800px"
+        :row-class-name="getRowClassName"
+      >
+        <el-table-column prop="role" label="Role" width="130">
+          <template #default="scope">
+            <span
+              v-if="scope.row.role == 'assistant'"
+              >ASSISTANT</span
+            >
+            <span v-else>
+              USER
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column label="Content" width="400">
+          <template #default="scope">
+            {{
+              scope.row.content +
+              (scope.row.audioSentiment ? ` ${scope.row.audioSentiment}` : "")
+            }}
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="wordCount"
+          label="Count"
+          width="80"
+        ></el-table-column>
+        <el-table-column label="Audio" width="350">
+          <template #default="scope">
+            <audio
+              controls
+              style="width: 300px; height: 40px"
+              v-if="scope.row.audioUrl"
+            >
+              <source :src="scope.row.audioUrl" type="audio/mp3" />
+              <source :src="scope.row.audioUrl" type="audio/wav" />
+              您的浏览器不支持音频播放。
+            </audio>
+          </template>
+        </el-table-column>
+        <el-table-column label="Timestamp" width="200px">
+          <template #default="scope">
+          <div style="font-size: 15px">
+              {{ new Date(scope.row.timestamp).toLocaleString() }}
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column label="Score">
+          <template #default="scope">
+            <div
+              :ref="(el) => setChartRef(el, scope.$index)"
+              class="chart-container"
+            ></div>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 老师评语卡片 -->
+    <el-card class="compact-card">
+      <template #header>
+        <div class="card-header">作业评价</div>
+      </template>
+      <div class="teacher-comment">
+        <!-- 学生角色:展示老师的评价和分数 -->
+        <template v-if="ROLE === 'STUDENT'">
+          <div
+            v-if="engagement.remark || engagement.score"
+            class="comment-container"
+          >
+            <div class="comment-item">
+              <span class="comment-label">评语:</span>
+              <span class="comment-content">{{
+                engagement?.remark || "暂无评语"
+              }}</span>
+            </div>
+            <div class="comment-item">
+              <span class="comment-label">评分:</span>
+              <el-rate
+                v-model="engagement.score"
+                :max="10"
+                show-score
+                disabled
+                score-template="{value} 分"
+              />
+            </div>
+          </div>
+          <el-empty v-else description="暂无老师评语" />
+        </template>
+
+        <!-- 老师角色:展示评价框和评分器 -->
+        <template v-else>
+          <el-input
+            v-model="engagement.remark"
+            type="textarea"
+            :rows="6"
+            style="width: 1000px"
+            placeholder="请输入评语"
+            class="comment-input"
+          />
+          <div class="score-input">
+            <span class="score-label">评分:</span>
+            <el-rate
+              v-model="engagement.score"
+              :max="10"
+              show-score
+              score-template="{value} 分"
+            />
+          </div>
+          <el-button style="background-color:#597D3B;color:white" @click="submitComment" class="submit-button"
+            >提交评语</el-button
+          >
+        </template>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount, nextTick, defineProps } from "vue";
+import doubaoApis from "@/apis/doubao";
+import engagementApis from "@/apis/engagement";
+import mediaApis from "@/apis/media";
+import * as echarts from "echarts";
+import { ElEmpty, ElMessage } from "element-plus";
+import { Back, Loading } from "@element-plus/icons-vue"; // 引入 Element UI
+import { useRouter } from "vue-router";
+import {useStore} from "@/store";
+
+const router = useRouter();
+const store = useStore();
+const props = defineProps({
+  userId: {
+    type: Number,
+    required: true,
+    default: null
+  },
+  assignmentId: {
+    type: Number,
+    required: true,
+    default: null
+  },
+  assignmentName: {
+    type: String,
+    default: '未命名作业'
+  }
+});
+const speakingAIDialogue = ref({
+  id: "",
+  assignmentId: null,
+  userId: null,
+  suggestion: "",
+  videoUrl: "",
+  audioSigment: [],
+  audioDuration: [],
+  hasCommitted: false,
+  dialogues: [],
+  totalVideoTime: 0,
+  totalSpeakingTime: 0,
+  totalWordCount: 0,
+  chatTimes: 0,
+  accuracyScoreAvg: 0,
+  fluencyScoreAvg: 0,
+  integrityScoreAvg: 0,
+  standardScoreAvg: 0,
+  totalScoreAvg: 0,
+  videoAnalysis: {
+    merged_video_path: "",
+    emotion_percentage_dict: [],
+  },
+});
+
+const engagement = ref({
+  score: 0,
+  remark: "",
+});
+
+const isSuggestionLoading = ref(true);
+const isVideoEmotionLoading = ref(true);
+
+const emotionChartRef = ref(null);
+let emotionChartInstance = null;
+const createEmotionPieChart = (emotionList) => {
+  console.log(emotionList);
+  if (!emotionChartRef.value) return;
+  // 如果已经存在图表实例,先销毁
+  if (emotionChartInstance) {
+    emotionChartInstance.dispose();
+  }
+  // 初始化图表
+  emotionChartInstance = echarts.init(emotionChartRef.value);
+  const option = {
+    title: {
+      text: "情感分析结果",
+      left: "center",
+    },
+    tooltip: {
+      trigger: "item",
+    },
+    legend: {
+      orient: "vertical",
+      left: "-1%",
+      
+    },
+    series: [
+      {
+        name: "情感占比",
+        type: "pie",
+        radius: "50%",
+        data: emotionList.map((item) => ({
+          value: item.percentage,
+          name: item.emotion,
+        })),
+        emphasis: {
+          itemStyle: {
+            shadowBlur: 10,
+            shadowOffsetX: 0,
+            shadowColor: "rgba(0, 0, 0, 0.5)",
+          },
+        },
+        
+      },
+    ],
+  };
+  emotionChartInstance.setOption(option);
+  window.addEventListener("resize", () => emotionChartInstance.resize());
+};
+
+// 提交评语
+const submitComment = async () => {
+  if (!engagement.value.remark || engagement.value.score === 0) {
+    ElMessage.warning("请填写评语和评分");
+    return;
+  }
+  try {
+    // 调用 API 提交评语和评分
+    const response = await engagementApis.engagementCorrect(
+      props.userId,
+      props.assignmentId,
+      {
+        score: engagement.value.score,
+        remark: engagement.value.remark,
+      }
+    );
+    if (response.data.code === 200) {
+      ElMessage.success("评语提交成功");
+    } else {
+      ElMessage.error("评语提交失败");
+    }
+  } catch (error) {
+    console.error("提交评语失败:", error);
+    ElMessage.error("网络错误,请重试");
+  }
+};
+
+const chartRefs = ref([]); // 用于存储图表容器的引用
+const charts = ref([]); // 用于存储 ECharts 实例
+
+// 动态绑定 ref 并渲染图表
+const setChartRef = (el, index) => {
+  if (el) {
+    chartRefs.value[index + 1] = el;
+
+    if (speakingAIDialogue.value.role == "user") {
+      renderChart(index, speakingAIDialogue.value.dialogues[index]);
+    }
+  }
+};
+// 渲染图表
+const renderChart = (index, data) => {
+  nextTick(() => {
+    const chartDom = chartRefs.value[index];
+    if (!chartDom) return;
+
+    // 如果已经存在图表实例,先销毁
+    if (charts.value[index]) {
+      charts.value[index].dispose();
+    }
+
+    // 初始化图表
+    charts.value[index] = echarts.init(chartDom);
+    const option = {
+      tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
+      grid: {
+        left: "0%",
+        right: "20%",
+        bottom: "5%",
+        top: "5%",
+        containLabel: true,
+      },
+      xAxis: {
+        type: "value",
+        max: 100,
+        axisLabel: { show: false }, // 隐藏 x 轴标签
+      },
+      yAxis: {
+        type: "category",
+        data: ["准确度", "流利度", "完整性", "标准度", "总分"],
+        axisTick: { show: false }, // 隐藏刻度
+      },
+      series: [
+        {
+          name: "得分",
+          type: "bar",
+          barWidth: "30%",
+          data: [
+            data.accuracyScore,
+            data.fluencyScore,
+            data.integrityScore,
+            data.standardScore,
+            data.totalScore,
+          ],
+          label: {
+            show: true,
+            position: "right",
+            formatter: "{c}",
+            color: "#1E88E5",
+            fontSize: 10, // 调整字体大小
+          },
+          itemStyle: {
+            barBorderRadius: [5, 5, 5, 5],
+            color: new echarts.graphic.LinearGradient(1, 0, 0, 0, [
+              { offset: 0, color: "#90caf9" },
+              { offset: 1, color: "#1E88E5" },
+            ]),
+            shadowBlur: 3, // 减小阴影
+            shadowColor: "rgba(0, 0, 0, 0.1)",
+          },
+          animationDuration: 1000,
+        },
+      ],
+    };
+
+    // 设置图表配置
+    charts.value[index].setOption(option);
+
+    // 监听窗口大小变化,调整图表尺寸
+    window.addEventListener("resize", () => charts.value[index].resize());
+  });
+};
+
+const avgScoreChart = ref(null); // 用于存储图表容器的引用
+let chartInstance = null; // 用于存储 ECharts 实例
+
+// 渲染平均值图表
+const renderAvgScoreChart = () => {
+  if (!avgScoreChart.value) return;
+
+  // 如果已经存在图表实例,先销毁
+  if (chartInstance) {
+    chartInstance.dispose();
+  }
+
+  // 初始化图表
+  chartInstance = echarts.init(avgScoreChart.value);
+  const option = {
+    tooltip: {
+      trigger: "item",
+      formatter: "{a} <br/>{b}: {c} ({d}%)",
+    },
+    graphic: [
+      // 总分标注
+      {
+        type: "text",
+        left: "11%", // 调整位置
+        top: "86%", // 调整位置
+        style: {
+          text: "总分",
+          fontSize: 14,
+          fill: "#333", // 文字颜色
+        },
+      },
+      // 准确度标注
+      {
+        type: "text",
+        left: "28%", // 调整位置
+        top: "48%", // 调整位置
+        style: {
+          text: "准确度",
+          fontSize: 14,
+          fill: "#333", // 文字颜色
+        },
+      },
+      // 流利度标注
+      {
+        type: "text",
+        left: "28%", // 调整位置
+        top: "87%", // 调整位置
+        style: {
+          text: "流利度",
+          fontSize: 14,
+          fill: "#333", // 文字颜色
+        },
+      },
+      // 完整性标注
+      {
+        type: "text",
+        left: "43%", // 调整位置
+        top: "48%", // 调整位置
+        style: {
+          text: "完整性",
+          fontSize: 14,
+          fill: "#333", // 文字颜色
+        },
+      },
+      // 标准度标注
+      {
+        type: "text",
+        left: "43%", // 调整位置
+        top: "87%", // 调整位置
+        style: {
+          text: "标准度",
+          fontSize: 14,
+          fill: "#333", // 文字颜色
+        },
+      },
+    ],
+    series: [
+      // 左侧大圆形百分比图(总分)
+      {
+        name: "总分",
+        type: "pie",
+        radius: ["50%", "60%"], // 环形图,内半径 50%,外半径 70%
+        center: ["12%", "50%"],
+        data: [
+          {
+            value: speakingAIDialogue.value.totalScoreAvg,
+            name: "总分",
+            label: {
+              show: true,
+              position: "center",
+              fontSize: 20,
+              formatter: function (params) {
+                // 将百分比值转换为整数
+                return Math.round(params.percent);
+              },
+            },
+            itemStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: "rgb(84,112,198)" },
+                { offset: 0.6, color: "rgb(84,112,198)" },
+              ]),
+            },
+          },
+          {
+            value: 100 - speakingAIDialogue.value.totalScoreAvg, // 剩余部分
+            name: "",
+            itemStyle: {
+              color: "#eee",
+            },
+          },
+        ],
+      },
+      // 右侧上方第一个小圆形百分比图(准确度)
+      {
+        name: "准确度",
+        type: "pie",
+        radius: ["20%", "28%"],
+        center: ["30%", "30%"],
+        data: [
+          {
+            value: speakingAIDialogue.value.accuracyScoreAvg,
+            name: "准确度",
+            label: {
+              show: true,
+              position: "center",
+              fontSize: 14,
+              formatter: function (params) {
+                // 将百分比值转换为整数
+                return Math.round(params.percent);
+              },
+            },
+            itemStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: "rgb(115,192,222)" },
+                { offset: 1, color: "rgb(115,192,222)" },
+              ]),
+            },
+          },
+          {
+            value: 100 - speakingAIDialogue.value.accuracyScoreAvg,
+            name: "",
+            itemStyle: {
+              color: "#eee",
+            },
+          },
+        ],
+      },
+      // 右侧上方第二个小圆形百分比图(流利度)
+      {
+        name: "流利度",
+        type: "pie",
+        radius: ["20%", "28%"],
+        center: ["30%", "70%"],
+        data: [
+          {
+            value: speakingAIDialogue.value.fluencyScoreAvg,
+            name: "流利度",
+            label: {
+              show: true,
+              position: "center",
+              fontSize: 14,
+              formatter: function (params) {
+                // 将百分比值转换为整数
+                return Math.round(params.percent);
+              },
+            },
+            itemStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: "rgb(250,200,88)" },
+                { offset: 1, color: "rgb(250,200,88)" },
+              ]),
+            },
+          },
+          {
+            value: 100 - speakingAIDialogue.value.fluencyScoreAvg,
+            name: "",
+            itemStyle: {
+              color: "#eee",
+            },
+          },
+        ],
+      },
+      // 右侧下方第一个小圆形百分比图(完整性)
+      {
+        name: "完整性",
+        type: "pie",
+        radius: ["20%", "28%"],
+        center: ["45%", "30%"],
+        data: [
+          {
+            value: speakingAIDialogue.value.integrityScoreAvg,
+            name: "完整性",
+            label: {
+              show: true,
+              position: "center",
+              fontSize: 14,
+              formatter: function (params) {
+                // 将百分比值转换为整数
+                return Math.round(params.percent);
+              },
+            },
+            itemStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: "rgb(252,132,82)" },
+                { offset: 1, color: "rgb(252,132,82)" },
+              ]),
+            },
+          },
+          {
+            value: 100 - speakingAIDialogue.value.integrityScoreAvg,
+            name: "",
+            itemStyle: {
+              color: "#eee",
+            },
+          },
+        ],
+      },
+      // 右侧下方第二个小圆形百分比图(标准度)
+      {
+        name: "标准度",
+        type: "pie",
+        radius: ["20%", "28%"],
+        center: ["45%", "70%"],
+        data: [
+          {
+            value: speakingAIDialogue.value.standardScoreAvg,
+            name: "标准度",
+            label: {
+              show: true,
+              position: "center",
+              fontSize: 14,
+              formatter: function (params) {
+                // 将百分比值转换为整数
+                return Math.round(params.percent);
+              },
+            },
+            itemStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: "rgb(59,162,114)" },
+                { offset: 1, color: "rgb(59,162,114)" },
+              ]),
+            },
+          },
+          {
+            value: 100 - speakingAIDialogue.value.standardScoreAvg,
+            name: "",
+            itemStyle: {
+              color: "#eee",
+            },
+          },
+        ],
+      },
+    ],
+  };
+
+  // 设置图表配置
+  chartInstance.setOption(option);
+
+  // 监听窗口大小变化,调整图表尺寸
+  window.addEventListener("resize", () => chartInstance.resize());
+};
+
+// 获取格式化后的时长
+const getDuration = (index) => {
+  const duration = speakingAIDialogue.value.audioDuration[index];
+  return Number(duration).toFixed(2);
+};
+
+const formattedSuggestions = ref([]);
+
+const parseSuggestion = (suggestion) => {
+  const sections = [];
+  const parts = suggestion.split(/\n(?=\d+\.)/);
+  parts.forEach((part) => {
+    const splitIndex = part.indexOf(":");
+    if (splitIndex !== -1) {
+      const title = part.slice(2, splitIndex).trim();
+      const items = part
+        .slice(splitIndex + 1)
+        .trim()
+        .split("\n - ");
+      sections.push({
+        title,
+        items,
+      });
+    }
+  });
+  return sections;
+};
+
+const getRowClassName = ({ row }) => {
+  return row.role === "ASSISTANT"
+    ? "assistant-row"
+    : row.role === "USER"
+    ? "user-row"
+    : "";
+};
+
+// 组件销毁时清理图表实例
+onBeforeUnmount(() => {
+  if (chartInstance) {
+    chartInstance.dispose();
+  }
+  charts.value.forEach((chart) => chart.dispose());
+  window.removeEventListener("resize", () => {});
+});
+
+const goBack = () => {
+  router.go(-1);
+};
+
+const ROLE = store.user.role;
+// 在数据加载完成后渲染图表
+onMounted(async () => {
+  try {
+    const response = await doubaoApis.getRecord(
+      props.userId,
+      props.assignmentId
+    );
+    console.log(response.data.data)
+    speakingAIDialogue.value = response.data.data;
+    if (speakingAIDialogue.value.suggestion) {
+      formattedSuggestions.value = parseSuggestion(
+        speakingAIDialogue.value.suggestion
+      );
+      isSuggestionLoading.value = false;
+    } else {
+      mediaApis
+        .getSpeakingSuggestion(
+          speakingAIDialogue.value.userId,
+          speakingAIDialogue.value.assignmentId
+        )
+        .then((res) => {
+          speakingAIDialogue.value.suggestion = res.data.data;
+          formattedSuggestions.value = parseSuggestion(
+            speakingAIDialogue.value.suggestion
+          );
+          isSuggestionLoading.value = false;
+        });
+    }
+    if (speakingAIDialogue.value.videoAnalysis == null) {
+      mediaApis
+        .getVideoSentiment(
+          speakingAIDialogue.value.userId,
+          speakingAIDialogue.value.assignmentId
+        )
+        .then((res) => {
+          speakingAIDialogue.value.videoAnalysis = res.data.data;
+          isVideoEmotionLoading.value = false
+          createEmotionPieChart(speakingAIDialogue.value.videoAnalysis);
+        });
+    } else{
+      isVideoEmotionLoading.value = false
+      createEmotionPieChart(
+        speakingAIDialogue.value.videoAnalysis.emotion_percentage_dict
+      );
+    }
+    const engagementRes = await engagementApis.getEngagement(
+      props.userId,
+      props.assignmentId
+    );
+
+    engagement.value = {
+      score: engagementRes.data.data.score || 0,
+      remark: engagementRes.data.data.remark || "",
+    };
+    // 渲染每个对话的图表
+    nextTick(() => {
+      renderAvgScoreChart();
+      speakingAIDialogue.value.dialogues.slice(0).forEach((dialogue, index) => {
+        if (dialogue.totalScore !== null) {
+          renderChart(index, dialogue);
+        }
+      });
+    });
+  } catch (error) {
+    console.error("获取数据失败:", error);
+  }
+});
+</script>
+
+
+<style scoped>
+.container{
+  margin:auto;
+  margin-top:30px;
+  width:1600px;
+}
+.header-container {
+  display: flex; /* 使用 Flex 布局 */
+  align-items: center; /* 垂直居中 */
+  margin-bottom: 20px; /* 根据需要调整间距 */
+}
+
+.back-button {
+  margin-right: 30%; /* 按钮与标题之间的间距 */
+  font-size: 50px; /* 图标大小 */
+  color: #409eff; /* 按钮颜色,与 Element UI 主题色一致 */
+}
+
+.chart-container {
+  width: 180px; /* 调整宽度 */
+  height: 100px; /* 调整高度 */
+}
+
+.page-title{
+  text-align: center;
+  font-size:50px;
+  margin-bottom: 20px;
+}
+
+.card-header {
+  font-weight: bold;
+  margin-bottom: 30px;
+  font-size: 23px;
+  text-align: center;
+}
+
+.card-time {
+  font-size: 12px;
+  color: gray;
+  text-align: center;
+}
+
+.info-item {
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  margin-left:50px;
+}
+
+.info-label {
+  font-weight: bold;
+  margin-right: -10px;
+  min-width: 100px;
+  text-align: left;
+  font-size: 18px;
+}
+
+.suggestion-list {
+  margin-left: 20px;
+}
+
+.suggestion-section {
+  margin-bottom: 20px;
+}
+
+.section-title {
+  margin-bottom: 10px;
+  font-weight: 600;
+  text-align: left;
+}
+
+.suggestion-items {
+  list-style: disc;
+  padding-left: 10px;
+  font-size: 15px;
+  font-weight: 400;
+  text-align: left;
+}
+
+.suggestion-items li {
+  margin-bottom: 5px;
+}
+
+.audio-segments {
+  display: grid;
+  gap: 15px;
+}
+
+.video-segments {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 15px;
+}
+
+.video-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 10px;
+  margin-top:20px;
+}
+
+.video-player {
+  width: 350px;
+  height: 280px;
+  margin-left: 30px;
+}
+
+.duration-label {
+  color: #666;
+  font-size: 0.9em;
+}
+
+.el-table {
+  margin-top: 20px;
+}
+
+.el-table th,
+.el-table td {
+  text-align: left;
+}
+
+.el-table__body td {
+  padding: 10px;
+}
+
+.time-info {
+  display: flex;
+  gap: 30px;
+}
+
+.time-info .info-item {
+  margin-bottom: 20;
+}
+
+.audio-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 5px;
+}
+
+.duration-label {
+  color: #666;
+  font-size: 0.8em;
+}
+
+.assistant-row {
+  background-color: #f0f9eb; /* 浅绿色背景 */
+}
+
+.user-row {
+  background-color: #f0f7ff; /* 浅蓝色背景 */
+}
+
+.teacher-comment {
+  padding: 20px;
+  text-align: center;
+}
+
+.comment-item {
+  margin-bottom: 10px;
+  text-align: left;
+}
+
+.comment-label {
+  font-weight: bold;
+  margin-right: 10px;
+}
+
+.comment-input {
+  margin-bottom: 20px;
+}
+
+.score-input {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.score-label {
+  font-weight: bold;
+  margin-left: 40%;
+}
+
+.submit-button {
+  width: 30%;
+  margin-top: 50px;
+}
+
+.survey-link {
+  text-align: center;
+  padding: 20px;
+}
+
+.survey-button {
+  display: inline-block;
+  margin-top: 10px;
+  padding: 10px 20px;
+  background-color: #409eff;
+  color: white;
+  text-decoration: none;
+  border-radius: 4px;
+  transition: background-color 0.3s;
+}
+
+.survey-button:hover {
+  background-color: #66b1ff;
+}
+
+.highlight-title {
+  font-weight: bold;
+  font-size: 14px; /* 可以根据需要调整字体大小 */
+}
+
+.word-count-box {
+  background-color: #f0f0f0; /* 浅灰色背景 */
+  border-radius: 10px; /* 圆角 */
+  padding: 20px; /* 内边距 */
+  text-align: center; /* 文字居中 */
+  font-family: Arial, sans-serif; /* 字体 */
+  width: 150px; /* 盒子宽度 */
+  margin-top:20px;
+  margin-left:50px;
+}
+
+.label {
+  font-size: 16px; /* 标签文字大小 */
+  color: #333; /* 标签文字颜色 */
+  margin-bottom: 10px; /* 与数据的间距 */
+}
+
+.value {
+  font-size: 24px; /* 数据文字大小 */
+  color: #000; /* 数据文字颜色 */
+  font-weight: bold; /* 加粗 */
+}
+
+.el-card {
+  margin-bottom: 30px !important;
+}
+</style>

+ 249 - 0
src/views/AiSpeakingPage/component/VoiceAnalysis.vue

@@ -0,0 +1,249 @@
+<template>
+    <div class="voice-analysis-container" ref="voiceAnalysisContainerRef">
+      <h3>实时语音分析</h3>
+      <h5> 😊 😐 😔 😡 😰 🤢 😮</h5>
+      <!-- 使用 el-empty 表示空状态 -->
+      <el-empty v-if="analysisResults.length === 0" description="暂无分析结果" />
+      <div v-else>
+        <div v-for="(result, index) in analysisResults" :key="index" class="analysis-result">
+          <!-- 横向条形图容器 -->
+          <p class="content">{{result.content}}<span style="margin-left:10px">{{result.emotion || ''}}</span></p>
+          <div ref="chartRefs" class="chart-container"></div>
+          <p style="font-size:14px; font-weight:bold;margin-right:70%">各单词得分</p>
+          <div class="sentence">
+            <span
+                v-for="(word, index) in result.wordScoreList"
+                :key="index"
+                class="word-tag"
+            >
+                <span class="word">{{ word.word }}</span>
+                <span class="score" :style="{ color: getWordColor(word.totalScore) }">{{ word.totalScore }}</span>
+            </span>
+            </div>
+        </div>
+      </div>
+    </div>
+  </template>
+  
+  <script setup>
+  import { ref, onMounted, onBeforeUnmount, nextTick, defineProps } from "vue";
+  import * as echarts from "echarts";
+  import { ElEmpty } from "element-plus"; // 引入 Element Plus 的 Empty 组件
+  import doubaoApis from "@/apis/doubao";
+  import fileApis from "@/apis/file";
+import { filterFields } from "element-plus/es/components/form/src/utils";
+
+
+  const props = defineProps({
+    userId: {
+    type: Number,
+    required: true,
+    },
+    assignmentId: {
+      type: Number,
+      required: true,
+    }
+  })
+  const analysisResults = ref([
+    {
+      content: "My favorite animal is dog",
+      accuracyScore: 99.9,
+      fluencyScore: 85.5,
+      integrityScore: 92.3,
+      standardScore: 88.7,
+      totalScore: 91.6,
+      wordScoreList: [
+        { word: "My", totalScore: 95 },
+        { word: "favorite", totalScore: 90 },
+        { word: "animal", totalScore: 85 },
+        { word: "is", totalScore: 98 },
+        { word: "dog", totalScore: 92 },
+      ],
+    },
+  ]);
+  
+  const chartRefs = ref([]);
+  const charts = ref([]);
+  const voiceAnalysisContainerRef = ref(null);
+  const scrollToBottom = () => {
+      nextTick(() => {
+        if (voiceAnalysisContainerRef) {
+          voiceAnalysisContainerRef.scrollTop = voiceAnalysisContainerRef.scrollHeight;
+        }
+      });
+  };
+
+  const addAnalysisResult = (result) => {
+    analysisResults.value.push(result);
+    nextTick(() => {
+      renderChart(analysisResults.value.length - 1);
+      scrollToBottom();
+    });
+  };
+  
+  const renderChart = (index) => {
+    nextTick(() => {
+      const chartDom = chartRefs.value[index];
+      if (!chartDom) return;
+      if (charts.value[index]) {
+        charts.value[index].dispose();
+      }
+      charts.value[index] = echarts.init(chartDom);
+      const option = {
+        tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
+        grid: { left: "1%", right: "10%", bottom: "2%", containLabel: true },
+        xAxis: {
+          type: "value",
+          max: 100,
+          axisLabel: { formatter: "{value}%" },
+          splitLine: { show: false },
+        },
+        yAxis: {
+          type: "category",
+          data: ["准确度", "流利度", "完整性", "标准度", "总分"],
+          axisTick: { show: false },
+        },
+        series: [
+          {
+            name: "得分",
+            type: "bar",
+            barWidth: "40%",
+            data: [
+              analysisResults.value[index].accuracyScore,
+              analysisResults.value[index].fluencyScore,
+              analysisResults.value[index].integrityScore,
+              analysisResults.value[index].standardScore,
+              analysisResults.value[index].totalScore,
+            ],
+            label: {
+              show: true,
+              position: "right",
+              formatter: "{c}",
+              color: "#1E88E5",
+            },
+            itemStyle: {
+              barBorderRadius: [5, 5, 5, 5],
+              color: new echarts.graphic.LinearGradient(1, 0, 0, 0, [
+                { offset: 0, color: "#90caf9" },
+                { offset: 1, color: "#90caf9" },
+              ]),
+              shadowBlur: 10,
+              shadowColor: "rgba(0, 0, 0, 0.1)",
+            },
+            animationDuration: 1000,
+          },
+        ],
+      };
+  
+      charts.value[index].setOption(option);
+      window.addEventListener("resize", () => charts.value[index].resize());
+    });
+  };
+  
+  const getWordColor = (score) => {
+    if (score >= 86) return "#a5d6a7"; // Light green for Excellent
+    if (score >= 70) return "#80cbc4"; // Light teal for Good
+    if (score >= 50) return "#fff59d"; 
+    return "#ffcdd2"; // Light red for Poor
+  };
+  
+  onMounted(() => {
+    doubaoApis
+    .getChatHistory(props.userId, props.assignmentId)
+    .then((res) => {
+      const dialogues = res.data.data
+      // 过滤掉 role 为 assistant 且 totalScore 为 null 的对话
+      const filteredDialogues = dialogues.filter(
+        (dialogue) => dialogue.role !== 'ASSISTANT' && dialogue.totalScore !== null
+      );
+      // 将过滤后的数据赋值给 analysisResults
+    analysisResults.value =
+      filteredDialogues.map((dialogue) => ({
+        content: dialogue.content,
+        accuracyScore: dialogue.accuracyScore,
+        fluencyScore: dialogue.fluencyScore,
+        integrityScore: dialogue.integrityScore,
+        standardScore: dialogue.standardScore,
+        totalScore: dialogue.totalScore,
+        wordScoreList: dialogue.wordScoreList,
+        emotion:dialogue.audioSentiment
+      }))
+    nextTick(() => {
+      analysisResults.value.forEach((_, index) => renderChart(index));
+    });
+  })
+});
+  
+  onBeforeUnmount(() => {
+    charts.value.forEach((chart) => chart.dispose());
+    window.removeEventListener("resize", () => {});
+  });
+  
+  defineExpose({ addAnalysisResult, voiceAnalysisContainerRef });
+  </script>
+  
+  <style scoped>
+  .voice-analysis-container {
+    padding: 20px;
+    background-color: #e3f2fd;
+    height: 100%;
+    box-sizing: border-box;
+    overflow-y: auto;
+    min-width: 200px;
+    scrollbar-width: none; /* Firefox */
+    text-align: center;
+  }
+
+  
+  .analysis-result {
+    margin-top: 20px;
+    padding: 15px;
+    background-color: #ffffff;
+    border-radius: 10px;
+    
+    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+  }
+
+  .content{
+    border:none;
+    background-color: #f1f8e9;
+  }
+  
+  .chart-container {
+    width: 100%;
+    height: 210px;
+    margin-top: -60px;
+  }
+  
+  .sentence {
+    font-size: 16px;
+    font-weight: bold;
+    display: flex;
+    flex-wrap: wrap;
+  }
+  
+  .word-tag {
+    position: relative;
+    margin-right: 8px;
+    margin-bottom: 5px;
+    padding: 0px 2px;
+    border-radius: 5px;
+    background-color: rgba(0, 0, 0, 0.05);
+    display: inline-block;
+  }
+  
+  .word {
+    color: #90caf9;
+    display: block;
+    text-align: center;
+    margin-bottom: 5px;
+    font-size: 16px;
+  }
+  
+  .score {
+    font-size: 14px;
+    font-weight: bold;
+    display: block;
+    text-align: center;
+  }
+  </style>

+ 1 - 0
src/views/CorrectPage/AssignmentPage.vue

@@ -40,6 +40,7 @@
       </template>
       <el-table-column type="index"/>
       <el-table-column prop="assignmentName" label="作业名称" width="400"/>
+      <el-table-column prop="type" label="类型" width="100"/>
       <!-- <el-table-column prop="description" label="描述" width="500"/> -->
       <el-table-column prop="endTime" label="截止时间" sortable/>
       <el-table-column prop="status" label="状态" sortable>

+ 22 - 9
src/views/CorrectPage/CorrectAssignmentPage.vue

@@ -123,15 +123,28 @@ import {Refresh, ZoomIn, ArrowLeft} from "@element-plus/icons-vue";
   }
 
   const handleCorrect = (assignmentId:number, studentId:number) => {
-    router.push(`/home/myCorrect/assignment/${assignmentId}`)
-    //第二次路由跳转至教师批改界面,并传递了该作业的srudentId
-    router.push({
-      path:
-          `/home/assignment/${assignmentId}/correct`,
-      query: {
-        studentId: studentId
-      }
-    })
+    if(assignmentInfo.type === '写作'){
+      router.push(`/home/myCorrect/assignment/${assignmentId}`)
+      //第二次路由跳转至教师批改界面,并传递了该作业的srudentId
+      router.push({
+        path:
+            `/home/assignment/${assignmentId}/correct`,
+        query: {
+          studentId: studentId
+        }
+      })
+    } else if(assignmentInfo.type == 'AI口语'){
+      router.push({
+      path: `/home/assignment/${assignmentId}/airecord`,
+      query:{
+        userId: studentId,
+        assignmentId: assignmentId,
+        assignmentName: assignmentInfo.assignmentName
+      }}).then(() => {
+        window.location.reload() // 强制刷新页面
+      })
+    }
+    
     // alert(studentId)
   }
 

+ 4 - 2
src/views/CorrectPage/CoursePage.vue

@@ -116,8 +116,10 @@
       query: {
         courseId: courseId,
       }
-    });
-    // location.reload()
+    })
+    .then(() => {
+      window.location.reload() // 强制刷新页面
+    })
   }
 
   const fetchData = (query: CourseQuery) => {

+ 2 - 0
src/views/CourseSquare/component/Course/index.vue

@@ -76,6 +76,8 @@
         title: props.title,
         type: props.type
       }
+    }).then(() => {
+      window.location.reload();
     })
   }
 

+ 376 - 0
src/views/CourseSquare/component/CourseDetails/component/AssignmentDialogue/AiSpeakingAssignmentDialog.vue

@@ -0,0 +1,376 @@
+<template>
+    <el-dialog v-model="AISpeakingVisible" :title="dialogConfig.title" 
+               @close="closeDialog"
+               :before-close="closeDialog">
+      <el-form :model="newAssignmentInfo" label-width="auto">
+        <el-form-item label="口语话题">
+          <el-input v-model="newAssignmentInfo.assignmentName" placeholder="请输入口语话题(英文)"></el-input>
+        </el-form-item>
+        <el-form-item label="话题描述">
+          <el-input v-model="newAssignmentInfo.description" placeholder="请输入话题具体描述"></el-input>
+        </el-form-item>
+        <el-form-item label="描述文件">
+          <el-upload
+            ref="uploadDescribeRef"
+            action="/api/file"
+            multiple
+            v-model:file-list="describeFileList"
+            :limit="1"
+            :on-success="handleDescribeSuccess"
+            :on-error="handleDescribeError"
+            :on-exceed="handleDescribeExceed"
+            :on-change="handleDescribeChange"
+            accept=".doc, .docx"
+            :auto-upload="false"
+          >
+            <el-button type="primary">点击上传</el-button>
+            <template #tip>
+              <div class="el-upload__tip">
+                doc/docx 文件不大于 500kb
+              </div>
+            </template>
+          </el-upload>
+        </el-form-item>
+        <el-form-item label="开始时间">
+          <el-date-picker v-model="newAssignmentInfo.startTime" type="datetime" placeholder="开始时间" style="width: 180px"/>
+        </el-form-item>
+        <el-form-item label="截止时间">
+          <el-date-picker v-model="newAssignmentInfo.endTime" type="datetime" placeholder="截止时间" style="width: 180px"/>
+        </el-form-item>
+        <el-form-item label="最少时长"  style="width=300px">
+        <el-input-number
+          v-model="newAssignmentInfo.minDuration"
+          :min="1"
+          :max="120"
+          placeholder="请输入最少分钟数"
+          style="width:200px"
+         />
+        </el-form-item>
+         <!-- 最少交流次数 -->
+        <el-form-item label="最少交流">
+          <el-input-number
+            v-model="newAssignmentInfo.minInteraction"
+            :min="1"
+            :max="50"
+            placeholder="请输入最少交流数"
+            style="width:200px"
+          />
+        </el-form-item>
+         <!-- 调查问卷链接 -->
+         <el-form-item label="问卷链接">
+          <div v-for="(link, index) in newAssignmentInfo.surveyLinks" :key="index" class="link-item">
+            <el-input
+              v-model="newAssignmentInfo.surveyLinks[index]"
+              placeholder="请输入调查问卷链接"
+              style="width: 150%; margin-right: 10px"
+            ></el-input>
+            <el-icon @click="removeSurveyLink(index)"
+              :disabled="newAssignmentInfo.surveyLinks.length === 1"><Delete/></el-icon>
+          </div>
+          <el-button type="primary" @click="addSurveyLink">添加链接</el-button>
+        </el-form-item>
+        <el-form-item label="投放间隔">
+          <el-input-number
+            v-model="newAssignmentInfo.surveyDuration"
+            :min="1"
+            :max="50"
+            placeholder="请输入问卷投放间隔(分钟)"
+            style="width:300px"
+          />
+        </el-form-item>
+        <div class="dialog-footer" style="margin-top:40px;margin-left: 40%">
+          <el-button @click="closeDialog">取消</el-button>
+          <el-button type="primary" @click="handleSubmit()">确定</el-button>
+                  <!-- TODO:有点问题 总是删除失败 -->
+          <el-button v-if="dialogConfig.type==='edit'" type="danger" @click="confirmDelete()">删除作业</el-button>
+        </div>
+      </el-form>
+    </el-dialog>
+  </template>
+  
+    
+    <script lang="ts" setup>
+  import { reactive,ref, defineProps, watch, onMounted, inject } from "vue";
+  import type { AssignmentDTO, AISpeakingAssignmentDTO } from '@/types/dto';
+  import useApis from "@/apis";
+  import aiSpeakingApis from "@/apis/ai_speaking_assignment"
+  import { Delete} from "@element-plus/icons-vue"; // 引入图标
+  import {
+    ElMessage,
+    ElNotification,
+    type UploadFile,
+    type UploadFiles,
+    type UploadProps,
+    type UploadUserFile,
+    ElMessageBox
+  } from "element-plus";
+  import {useFormatDate} from "@/hooks/useFormatDate";
+  const {formatDate} = useFormatDate()
+  const apis = useApis();
+  import {useStore} from "@/store";
+  const store = useStore()
+  const doRefresh:Function = inject("reload")
+  const props = defineProps({
+      AISpeakingVisible: Boolean,
+      dialogConfig: Object,
+      courseId: Number,
+      assignmentId: Number
+    });
+  const AISpeakingVisible = ref(false)
+  let uploadDescribeRef = ref()
+  const describeFileList = ref([]);
+  
+  const newAssignmentInfo = reactive<AISpeakingAssignmentDTO>({
+    assignmentName: "",
+    description: "",
+    descriptionFile: '',
+    type: "",
+    startTime: "",
+    endTime: "",
+    minDuration:null,
+    minInteraction:null,
+    surveyLinks:[''],
+    surveyDuration:null
+  });
+  
+  const initializeDialog = () => {
+      if (props.dialogConfig.type === 'edit') {
+        aiSpeakingApis.getAiSpeakingAssignmentById(props.dialogConfig.assignmentId).then((res) => {
+          const assignment = res.data.data
+          Object.assign(newAssignmentInfo, {
+            assignmentName: assignment.assignmentName,
+            description: assignment.description,
+            descriptionFile:assignment.descriptionFile,
+            type: assignment.type,
+            startTime: assignment.startTime,
+            endTime: assignment.endTime,
+            minDuration: assignment.minDuration,
+            minInteraction :assignment.minInteraction,
+            surveyLinks: assignment.surveyLinks || [""], // 确保 surveyLinks 不为空
+            surveyDuration:assignment.surveyDuration
+      });
+        });
+      } else {
+        cleanNewAssignmentInfo();
+      }
+    };
+  
+    const cleanNewAssignmentInfo = () => {
+      Object.assign(newAssignmentInfo, {
+        assignmentName: '',
+        description: '',
+        descriptionFile:'',
+        type: '',
+        startTime: '',
+        endTime: '',
+        minDuration:null,
+        minInteraction: null,
+        surveyLinks:[''],
+        surveyDuration:null
+      });
+    };
+
+    // 添加问卷链接
+const addSurveyLink = () => {
+  newAssignmentInfo.surveyLinks.push(""); // 添加一个空链接
+};
+
+// 删除问卷链接
+const removeSurveyLink = (index) => {
+  if (newAssignmentInfo.surveyLinks.length > 1) {
+    newAssignmentInfo.surveyLinks.splice(index, 1); // 删除指定索引的链接
+  }
+};
+
+const handleDescribeSuccess = (response: any) => {
+    newAssignmentInfo.descriptionFile = response.data;
+  };
+  
+  const handleDescribeError = () => {
+    ElMessage.error('上传描述文件失败');
+  };
+  
+  
+  const handleDescribeExceed = (files: any) => {
+    ElMessage.error('上传文件超过限制');
+  };
+
+  
+  //选择文件
+  const handleDescribeChange = (file,fileList) => {
+    // console.log(file)
+    const fileImgType = file.name.match(/\.([^\.]+)$/)[1];  //匹配文件格式(最后一个'.'后的格式)或者匹配file.raw.type
+    const isImageType = ['doc','docx'];
+    const isLt500KB = file.size / 1024  < 500;  //判断图片格式与大小
+    if (isImageType.indexOf(fileImgType)==-1) {
+      ElMessage.error('文件仅支持png、jpg格式')  //限制文件类型
+      uploadDescribeRef.value.clearFiles()  //清空上传文件
+      return false;
+    }
+    if (!isLt500KB) {
+      ElMessage.error('文件不能超过500kb!')  //限制文件大小
+      uploadDescribeRef.value.clearFiles()  //清空上传文件
+      return false;
+    }
+    apis.uploadFile(file.raw)
+        .then((res) => {
+          newAssignmentInfo.descriptionFile = res.data.data
+        })
+        .catch((err) => {
+          console.log(err)
+        })
+  }
+  
+  const handleSubmit = async () => {
+    if(newAssignmentInfo.assignmentName === '') {
+      ElNotification({title: '口语话题不得为空',type: 'error',})
+      return
+    }
+    if(newAssignmentInfo.description === '') {
+      ElNotification({title: '话题描述不得为空',type: 'error',})
+      return
+    }
+
+    if(newAssignmentInfo.startTime === '') {
+      ElNotification({title: '开始时间不得为空',type: 'error',})
+      return
+    }
+    if(newAssignmentInfo.endTime === '') {
+      ElNotification({title: '结束时间不得为空',type: 'error',})
+      return
+    }
+    if(newAssignmentInfo.startTime >= newAssignmentInfo.endTime) {
+      ElNotification({title: '结束时间不得在起始时间之前',type: 'error',})
+      return
+    }
+    newAssignmentInfo.endTime = formatDate(newAssignmentInfo.endTime);
+    newAssignmentInfo.startTime = formatDate(newAssignmentInfo.startTime);
+    newAssignmentInfo.type = 'AI口语'
+      if(props.dialogConfig.type == 'add'){
+        aiSpeakingApis.createAiSpeakingAssignment(props.courseId, newAssignmentInfo)
+        .then((res)=>{
+          if(res.data.code === 200){
+            ElNotification({
+              title: '新增成功',
+              type: 'success',
+            }) 
+            location.reload()
+          } else {
+            ElNotification({
+              title: '新增失败',
+              type: 'error',
+            })
+          }
+          AISpeakingVisible.value = false
+        })
+        .catch((err)=> {
+          console.log(err);
+          ElNotification({
+              title: `新增失败${err}`,
+              type: 'error',
+            })
+            AISpeakingVisible.value = false
+        })
+      }else if (props.dialogConfig.type === 'edit') {
+        aiSpeakingApis.updateAiSpeakingAssignment(props.dialogConfig.assignmentId, newAssignmentInfo)
+        .then((res)=>{
+          if(res.data.code === 200){
+            ElNotification.success('修改成功')
+            location.reload()
+          } else {
+            ElNotification.error('修改失败')
+          }
+          AISpeakingVisible.value = false
+        })
+        .catch((err)=> {
+          console.log(err);
+          ElNotification.error(`新增失败 ${err}`)
+          AISpeakingVisible.value = false
+        })
+      }
+      AISpeakingVisible.value = false;
+      
+    }
+
+    //传teacherId:number, assignmentId:number
+  const confirmDelete = () => {
+    ElMessageBox.confirm('确定要删除这个作业吗?', '警告', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    }).then(() => {
+      deleteAssignment();
+    }).catch(() => {
+      // 用户选择取消,什么都不做
+    });
+  };
+
+  const deleteAssignment = () => {
+    aiSpeakingApis.deleteAiAssignmentById(store.user.id,props.dialogConfig.assignmentId)
+      .then((res: any) => {
+        if (res.data.code === 200) {
+          ElMessage({
+            type: 'success',
+            message: '删除成功!'
+          });
+          // 这里可以调用刷新数据的方法
+          doRefresh();
+        } else {
+          ElMessage({
+            type: 'error',
+            message: '删除失败!'+res.data.message
+          });
+        }
+      })
+      .catch((err) => {
+        console.log(err);
+        ElMessage({
+          type: 'error',
+          message: '请求出错!'
+        });
+      });
+  };
+  
+  
+  const emit = defineEmits(['update:AISpeakingVisible']);
+  const closeDialog = () => {
+    AISpeakingVisible.value = false;
+    cleanNewAssignmentInfo();
+    emit('update:AISpeakingVisible', false); // 触发父组件更新
+  };
+  
+  // 监听父组件传递的 AISpeakingVisible 值
+  watch(() => props.AISpeakingVisible, (newVal) => {
+    AISpeakingVisible.value = newVal; // 同步父组件的值
+    if (newVal) {
+      initializeDialog();  // 弹出对话框时初始化
+    } else {
+      cleanNewAssignmentInfo();  // 关闭时清理数据
+    }
+  });
+  
+  onMounted(() => {
+    AISpeakingVisible.value = props.AISpeakingVisible; // 初始值同步
+  });
+  
+  
+  </script>
+    
+  <style scoped>
+  .link-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+  margin-right:100px;
+}
+
+.link-item .el-button {
+  margin-left: 10px;
+} 
+  </style>
+  
+  <script lang="ts">
+  export default {
+    name: "AISpeakingAssignmentDialogue"
+  }
+</script>

+ 355 - 0
src/views/CourseSquare/component/CourseDetails/component/AssignmentDialogue/WritingAssignmentDialogue.vue

@@ -0,0 +1,355 @@
+<template>
+    <el-dialog v-model="writingVisible" :title="dialogConfig.title"  
+    @close="closeDialog"
+    :before-close="closeDialog">
+      <el-form :model="newAssignmentInfo" label-width="auto">
+        <el-form-item label="作业名称">
+          <el-input v-model="newAssignmentInfo.assignmentName" placeholder="请输入名称"></el-input>
+        </el-form-item>
+        <el-form-item label="作业描述">
+          <el-input v-model="newAssignmentInfo.description" placeholder="请输入描述"></el-input>
+        </el-form-item>
+        <el-form-item label="开始时间">
+          <el-date-picker v-model="newAssignmentInfo.startTime" type="datetime" placeholder="开始时间" style="width: 180px"/>
+        </el-form-item>
+        <el-form-item label="截止时间">
+          <el-date-picker v-model="newAssignmentInfo.endTime" type="datetime" placeholder="截止时间" style="width: 180px"/>
+        </el-form-item>
+        <el-form-item label="描述文件">
+          <el-upload
+            ref="uploadDescribeRef"
+            action="/api/file"
+            multiple
+            v-model:file-list="describeFileList"
+            :limit="1"
+            :on-success="handleDescribeSuccess"
+            :on-error="handleDescribeError"
+            :on-exceed="handleDescribeExceed"
+            :on-change="handleDescribeChange"
+            accept=".doc, .docx"
+            :auto-upload="false"
+          >
+            <el-button type="primary">点击上传</el-button>
+            <template #tip>
+              <div class="el-upload__tip">
+                doc/docx 文件不大于 500kb
+              </div>
+            </template>
+          </el-upload>
+        </el-form-item>
+        <el-form-item label="附件">
+          <el-upload
+            ref="uploadPictureRef"
+            action="/api/file"
+            multiple
+            v-model:file-list="pictureFileList"
+            :limit="1"
+            :on-success="handlePictureSuccess"
+            :on-error="handlePictureError"
+            :on-exceed="handlePictureExceed"
+            :on-change="handlePictureChange"
+            accept=".doc, .docx"
+            :auto-upload="false"
+          >
+            <el-button type="primary">点击上传</el-button>
+            <template #tip>
+              <div class="el-upload__tip">
+                doc/docx 文件不大于 500kb
+              </div>
+            </template>
+          </el-upload>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="closeDialog">取消</el-button>
+          <el-button type="primary" @click="handleSubmit">确定</el-button>
+                  <!-- TODO:有点问题 总是删除失败 -->
+          <el-button v-if="dialogConfig.type==='edit'" type="danger" @click="confirmDelete()">删除作业</el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </template>
+  
+  <script lang="ts" setup>
+  import { ref, reactive, watch, defineProps, defineEmits, onMounted, inject } from 'vue';
+  import { dialogEmits, ElMessage, ElNotification, ElMessageBox } from 'element-plus';
+  import { AssignmentDTO } from '@/types/dto';
+  import useApis from "@/apis";
+  import {useFormatDate} from "@/hooks/useFormatDate";
+  const {formatDate} = useFormatDate()
+  import {useStore} from "@/store";
+  const apis = useApis()
+  const store = useStore()
+  const doRefresh:Function = inject("reload")
+  const props = defineProps({
+    writingVisible: Boolean,
+    dialogConfig: Object,
+    courseId: Number
+  });
+  let uploadDescribeRef = ref()
+  let uploadPictureRef = ref()
+  const writingVisible = ref(false)
+  const newAssignmentInfo = reactive<AssignmentDTO>({
+    assignmentName: '',
+    description: '',
+    type: '',
+    descriptionFile: '',
+    attachments: [''],
+    startTime: '',
+    endTime: '',
+  });
+  
+  const describeFileList = ref([]);
+  const pictureFileList = ref([]);
+  
+  const initializeDialog = () => {
+    if (props.dialogConfig.type === 'edit') {
+      // 通过 ID 获取作业详细信息并填充
+      apis.getAssignmentById(props.dialogConfig.assignmentId).then((res) => {
+      const assignment = res.data.data
+        Object.assign(newAssignmentInfo, {
+          assignmentName: assignment.assignmentName,
+          description: assignment.description,
+          type: assignment.type,
+          descriptionFile: assignment.descriptionFile,
+          attachments: assignment.attachments,
+          startTime: assignment.startTime,
+          endTime: assignment.endTime,
+    });
+      });
+    } else {
+      cleanNewAssignmentInfo();
+    }
+  };
+  
+  const handleDescribeSuccess = (response: any) => {
+    newAssignmentInfo.descriptionFile = response.data;
+  };
+  
+  const handlePictureSuccess = (response: any) => {
+    newAssignmentInfo.attachments[0] = response.data;
+  };
+  
+  const handleDescribeError = () => {
+    ElMessage.error('上传描述文件失败');
+  };
+  
+  const handlePictureError = () => {
+    ElMessage.error('上传附件失败');
+  };
+  
+  const handleDescribeExceed = (files: any) => {
+    ElMessage.error('上传文件超过限制');
+  };
+  
+  const handlePictureExceed = (files: any) => {
+    ElMessage.error('上传文件超过限制');
+  };
+  
+  //选择文件
+  const handleDescribeChange = (file,fileList) => {
+    // console.log(file)
+    const fileImgType = file.name.match(/\.([^\.]+)$/)[1];  //匹配文件格式(最后一个'.'后的格式)或者匹配file.raw.type
+    const isImageType = ['doc','docx'];
+    const isLt500KB = file.size / 1024  < 500;  //判断图片格式与大小
+    if (isImageType.indexOf(fileImgType)==-1) {
+      ElMessage.error('文件仅支持png、jpg格式')  //限制文件类型
+      uploadDescribeRef.value.clearFiles()  //清空上传文件
+      return false;
+    }
+    if (!isLt500KB) {
+      ElMessage.error('文件不能超过500kb!')  //限制文件大小
+      uploadDescribeRef.value.clearFiles()  //清空上传文件
+      return false;
+    }
+    apis.uploadFile(file.raw)
+        .then((res) => {
+          newAssignmentInfo.descriptionFile = res.data.data
+        })
+        .catch((err) => {
+          console.log(err)
+        })
+  }
+   
+  
+  const handlePictureChange = (file,fileList) => {
+    // console.log(file)
+    const fileImgType = file.name.match(/\.([^\.]+)$/)[1];  //匹配文件格式(最后一个'.'后的格式)或者匹配file.raw.type
+    // const isImageType = ['png','jpg'];
+    const isImageType = ['doc','docx'];
+    const isLt500KB = file.size / 1024  < 500;  //判断图片格式与大小
+    if (isImageType.indexOf(fileImgType)==-1) {
+      ElMessage.error('文件仅支持png、jpg格式')  //限制文件类型
+      uploadDescribeRef.value.clearFiles()  //清空上传文件
+      return false;
+    }
+    if (!isLt500KB) {
+      ElMessage.error('文件不能超过500kb!')  //限制文件大小
+      uploadDescribeRef.value.clearFiles()  //清空上传文件
+      return false;
+    }
+    apis.uploadFile(file.raw)
+        .then((res) => {
+          newAssignmentInfo.attachments[0] = res.data.data
+        })
+        .catch((err) => {
+          console.log(err)
+        })
+  }
+  
+  const handleSubmit = async () => {
+    if(newAssignmentInfo.assignmentName === '') {
+      ElNotification({title: '作业名称不得为空',type: 'error',})
+      return
+    }
+    if(newAssignmentInfo.description === '') {
+      ElNotification({title: '作业描述不得为空',type: 'error',})
+      return
+    }
+
+    if(newAssignmentInfo.startTime === '') {
+      ElNotification({title: '开始时间不得为空',type: 'error',})
+      return
+    }
+    if(newAssignmentInfo.endTime === '') {
+      ElNotification({title: '结束时间不得为空',type: 'error',})
+      return
+    }
+    if(newAssignmentInfo.startTime >= newAssignmentInfo.endTime) {
+      ElNotification({title: '结束时间不得在起始时间之前',type: 'error',})
+      return
+    }
+    newAssignmentInfo.endTime = formatDate(newAssignmentInfo.endTime);
+    newAssignmentInfo.startTime = formatDate(newAssignmentInfo.startTime);
+    if (props.dialogConfig.type === 'add') {
+      newAssignmentInfo.type = '写作'
+      apis.createAssignment(props.courseId, newAssignmentInfo)
+      .then((res)=>{
+        if(res.data.code === 200){
+          ElNotification({
+            title: '新增成功',
+            type: 'success',
+          })
+          location.reload()
+        } else {
+          ElNotification({
+            title: '新增失败',
+            type: 'error',
+          })
+        }
+        writingVisible.value = false
+      })
+      .catch((err)=> {
+        console.log(err);
+        ElNotification({
+            title: `新增失败${err}`,
+            type: 'error',
+          })
+          writingVisible.value = false
+      })
+    } else if (props.dialogConfig.type === 'edit') {
+      apis.updateAssignment(props.dialogConfig.assignmentId, newAssignmentInfo)
+      .then((res)=>{
+        if(res.data.code === 200){
+          ElNotification.success('修改成功')
+          location.reload()
+        } else {
+          ElNotification.error('修改失败')
+        }
+        writingVisible.value = false
+      })
+      .catch((err)=> {
+        console.log(err);
+        ElNotification.error(`新增失败 ${err}`)
+        writingVisible.value = false
+      })
+    }
+    writingVisible.value = false;
+  };
+
+  //传teacherId:number, assignmentId:number
+  const confirmDelete = () => {
+    ElMessageBox.confirm('确定要删除这个作业吗?', '警告', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    }).then(() => {
+      deleteAssignment();
+    }).catch(() => {
+      // 用户选择取消,什么都不做
+    });
+  };
+
+  const deleteAssignment = () => {
+    apis.deleteAssignmentById(store.user.id,props.dialogConfig.assignmentId)
+      .then((res: any) => {
+        if (res.data.code === 200) {
+          ElMessage({
+            type: 'success',
+            message: '删除成功!'
+          });
+          // 这里可以调用刷新数据的方法
+          doRefresh();
+        } else {
+          ElMessage({
+            type: 'error',
+            message: '删除失败!'+res.data.message
+          });
+        }
+      })
+      .catch((err) => {
+        console.log(err);
+        ElMessage({
+          type: 'error',
+          message: '请求出错!'
+        });
+      });
+  };
+  
+  const cleanNewAssignmentInfo = () => {
+    Object.assign(newAssignmentInfo, {
+      assignmentName: '',
+      description: '',
+      type: '',
+      descriptionFile: '',
+      attachments: [''],
+      startTime: '',
+      endTime: '',
+    });
+  };
+  const emit = defineEmits(['update:writingVisible']);
+  const closeDialog = () => {
+    writingVisible.value = false;
+    cleanNewAssignmentInfo()
+    emit('update:writingVisible', false); 
+  };
+ 
+  watch(() => props.writingVisible, (newVal) => {
+  writingVisible.value = newVal;  // 监听父组件的 visible 变化,确保同步
+  if (newVal) {
+    initializeDialog();  // 每次弹出对话框时初始化
+  } else {
+    cleanNewAssignmentInfo();  // 关闭时清理数据
+  }
+});
+  onMounted (()=>{
+    writingVisible.value = props.writingVisible
+  })
+  </script>
+  
+  <style scoped>
+  .dialog-footer {
+    text-align: right;
+  }
+  
+  </style>
+
+  <script lang="ts">
+  export default {
+    name: "WritingAssignmentDialogue"
+  }
+</script>
+
+
+  

+ 73 - 316
src/views/CourseSquare/component/CourseDetails/component/CourseHomeworkTable.vue

@@ -6,7 +6,7 @@
 <template>
   <el-card>
     <template v-if="props.role == 'teachers'">
-      <el-button color="#597d3b" @click="cleanNewAssignmentInfo(); changeDialog('add')" v-if="props.isShowNewHomework">新建作业</el-button>
+      <el-button color="#597d3b" @click="isTypeDialogVisible=true" v-if="props.isShowNewHomework">新建作业</el-button>
     </template>
     <template v-if="props.tableData.length > 0">
       <el-table :data="props.tableData" stripe style="width: 100%" height="100%" show-overflow-tooltip>
@@ -29,10 +29,10 @@
             </el-button>
           </template>
         </el-table-column>
-        <el-table-column :label="role == 'teachers'? '编辑':'写作'" width="60">
+        <el-table-column :label="role == 'teachers'? '编辑':'开始'" width="60">
           <template #default="scope">
             <el-button @mouseover="handleMouse('over', scope.row.teacherId, scope.row.endDate)" @mouseleave="handleMouse('leave', scope.row.teacherId, scope.row.endDate)"
-                color="#597d3b" circle @click="handleFunctionButton(scope.row.assignmentId)" :disabled="props.disabled || isDisabled(scope.row.teacherId, scope.row.endDate)">
+                color="#597d3b" circle @click="handleFunctionButton(scope.row.assignmentId,scope.row.type)" :disabled="props.disabled || isDisabled(scope.row.teacherId, scope.row.endDate)">
               <el-icon :size="18">
                 <Edit />
               </el-icon>
@@ -46,101 +46,32 @@
     </template>
   </el-card>
 
-  <el-dialog v-model="dialogConfig.visible" :title="dialogConfig.title">
-    <el-form :model="newAssignmentInfo" label-width="auto">
-      <!--      <el-form-item label="课程图片">-->
-      <el-form-item label="作业名称">
-        <el-input v-model="newAssignmentInfo.assignmentName" placeholder="请输入名称"></el-input>
-      </el-form-item>
-      <el-form-item label="作业描述">
-        <el-input v-model="newAssignmentInfo.description" placeholder="请输入描述"></el-input>
-      </el-form-item>
-      <el-form-item label="开始时间">
-        <el-date-picker v-model="newAssignmentInfo.startTime" type="datetime" placeholder="截止时间" style="width: 180px"/>
-      </el-form-item>
-      <el-form-item label="截止时间">
-        <el-date-picker v-model="newAssignmentInfo.endTime" type="datetime" placeholder="截止时间" style="width: 180px"/>
-      </el-form-item>
-      <el-form-item label="描述文件" v-if="dialogConfig.type == 'edit'">
-        <el-upload
-            ref="uploadDescribeRef"
-            action= "/api/file"
-            multiple
-            v-model:file-list="describeFileList"
-            :limit="1"
-            :on-success="handleDescribeSuccess"
-            :on-error="handleDescribeError"
-            :on-exceed="handleDescribeExceed"
-            :on-change="handleDescribeChange"
-            accept=".doc, .docx"
-            :auto-upload="false"
-        >
-          <el-button type="primary">点击上传</el-button>
-          <template #tip>
-            <div class="el-upload__tip">
-              doc/docx 文件不大于 500kb
-            </div>
-          </template>
-        </el-upload>
-      </el-form-item>
-      <el-form-item label="附件" v-if="dialogConfig.type == 'edit'">
-        <el-upload
-            ref="uploadPictureRef"
-            action= "/api/file"
-            multiple
-            v-model:file-list="pictureFileList"
-            :limit="1"
-            :on-success="handlePictureSuccess"
-            :on-error="handlePictureError"
-            :on-exceed="handlePictureExceed"
-            :on-change="handlePictureChange"
-            accept=".doc, .docx"
-            :auto-upload="false"
-        >
-          <el-button type="primary">点击上传</el-button>
-          <template #tip>
-            <div class="el-upload__tip">
-              doc/docx 文件不大于 500kb
-            </div>
-          </template>
-        </el-upload>
-      </el-form-item>
-<!--      <el-form-item label="作业图片" v-if="dialogConfig.type == 'edit'">-->
-<!--        <el-upload-->
-<!--            ref="uploadPictureRef"-->
-<!--            drag-->
-<!--            action= "http://127.0.0.1:8080/api/file"-->
-<!--            multiple-->
-<!--            v-model:file-list="pictureFileList"-->
-<!--            :limit="1"-->
-<!--            :on-success="handlePictureSuccess"-->
-<!--            :on-error="handlePictureError"-->
-<!--            :on-exceed="handlePictureExceed"-->
-<!--            :on-change="handlePictureChange"-->
-<!--            list-type="picture-card"-->
-<!--            accept=".png, .jpg"-->
-<!--            :auto-upload="false"-->
-<!--        >-->
-<!--          <el-icon class="el-icon&#45;&#45;upload"><upload-filled /></el-icon>-->
-<!--          <div class="el-upload__text">-->
-<!--            拖拽上传或<em>点击此处上传</em>-->
-<!--          </div>-->
-<!--          <template #tip>-->
-<!--            <div class="el-upload__tip">-->
-<!--              jpg/png 文件不大于 500kb-->
-<!--            </div>-->
-<!--          </template>-->
-<!--        </el-upload>-->
-<!--      </el-form-item>-->
-    </el-form>
-    <template #footer>
-      <div class="dialog-footer">
-        <el-button @click="dialogConfig.visible = false; cleanNewAssignmentInfo()">取消</el-button>
-        <el-button type="primary" @click="handleSubmit">确定</el-button>
-        <!-- TODO:有点问题 总是删除失败 -->
-        <el-button type="danger" @click="confirmDelete()">删除作业</el-button>
-      </div>
-    </template>
+
+
+  <writing-assignment-dialogue
+      v-model:writingVisible="writingVisible"
+      :dialogConfig="dialogConfig"
+      :courseId="props.courseId"
+    />
+  <ai-speaking-assignment-dialogue
+      v-model:AISpeakingVisible="AISpeakingVisible"
+      :dialogConfig="dialogConfig"
+      :courseId="props.courseId"
+  />
+
+  <el-dialog
+    v-model="isTypeDialogVisible"
+    title="选择作业类型"
+    style="width:450px"
+  >
+    <el-radio-group v-model="assignmentType">
+      <el-radio label="写作">写作</el-radio>
+      <el-radio label="AI口语">AI口语</el-radio>
+    </el-radio-group>
+    <div class="dialog-footer" style="margin-top:40px;margin-left:30%;">
+      <el-button @click="isTypeDialogVisible = false">取消</el-button>
+      <el-button color="#597d3b" type="primary" @click="createAssignmentDialog()">确定</el-button>
+    </div>
   </el-dialog>
   <ViewsWordEditor :alert="alertProps" :docxInfo="docxInfo"></ViewsWordEditor>
 </template>
@@ -163,9 +94,10 @@ import {defineProps, inject, onMounted, reactive, ref} from 'vue';
 } from "element-plus";
   import {useFormatDate} from "@/hooks/useFormatDate";
   import ViewsWordEditor from "@/components/ViewsWordEditor/ViewsWordEditor.vue";
+  import WritingAssignmentDialogue from './AssignmentDialogue/WritingAssignmentDialogue.vue';
+  import AiSpeakingAssignmentDialogue from './AssignmentDialogue/AiSpeakingAssignmentDialog.vue';
   import {useStore} from "@/store";
   import {Search, Edit, UploadFilled, Plus} from "@element-plus/icons-vue"
-import { userInfo } from 'os';
 
   const props = defineProps<{
     tableData: IAssignmentTableItem[],
@@ -183,118 +115,51 @@ import { userInfo } from 'os';
   let uploadDescribeRef = ref()
   let uploadPictureRef = ref()
 
-  // 用于编辑课程展示预览图片
-  const describeFileList = ref<UploadUserFile[]>([])
-  const pictureFileList = ref<UploadUserFile[]>([])
-
-  const handleDescribeSuccess = (response: any, uploadFile: UploadFile, uploadFiles: UploadFiles) => {
-    Object.assign(newAssignmentInfo, {
-      assignmentName: newAssignmentInfo.assignmentName,
-      description: newAssignmentInfo.description,
-      descriptionFile: response.data,
-      attachments: newAssignmentInfo.attachments == null? ['']:newAssignmentInfo.attachments,
-      startTime: newAssignmentInfo.startTime,
-      endTime: newAssignmentInfo.endTime
-    })
-  }
-  const handlePictureSuccess = (response: any, uploadFile: UploadFile, uploadFiles: UploadFiles) => {
-    newAssignmentInfo.attachments[0] = response.data
-  }
-
-  const handleDescribeError: UploadProps['onError'] = (error: Error, uploadFile: UploadFile, uploadFiles: UploadFiles) => {
-    console.log(error, uploadFile, uploadFiles)
-  }
-  const handlePictureError: UploadProps['onError'] = (error: Error, uploadFile: UploadFile, uploadFiles: UploadFiles) => {
-    console.log(error, uploadFile, uploadFiles)
-  }
-
-  const handleDescribeExceed = (files, fileList) => {
-    // console.log(files)
-    uploadDescribeRef.value.clearFiles()  //清空上传文件(限制一个,所以直接清空即可)
-    const file = files[0]
-    uploadDescribeRef.value.handleStart(file)  //重新上传
-  }
-  const handlePictureExceed = (files, fileList) => {
-    // console.log(files)
-    uploadPictureRef.value.clearFiles()  //清空上传文件(限制一个,所以直接清空即可)
-    const file = files[0]
-    uploadPictureRef.value.handleStart(file)  //重新上传
-  }
-
-  //选择文件
-  const handleDescribeChange = (file,fileList) => {
-    // console.log(file)
-    const fileImgType = file.name.match(/\.([^\.]+)$/)[1];  //匹配文件格式(最后一个'.'后的格式)或者匹配file.raw.type
-    const isImageType = ['doc','docx'];
-    const isLt500KB = file.size / 1024  < 500;  //判断图片格式与大小
-    if (isImageType.indexOf(fileImgType)==-1) {
-      ElMessage.error('文件仅支持png、jpg格式')  //限制文件类型
-      uploadDescribeRef.value.clearFiles()  //清空上传文件
-      return false;
-    }
-    if (!isLt500KB) {
-      ElMessage.error('文件不能超过500kb!')  //限制文件大小
-      uploadDescribeRef.value.clearFiles()  //清空上传文件
-      return false;
-    }
-    apis.uploadFile(file.raw)
-        .then((res) => {
-          newAssignmentInfo.descriptionFile = res.data.data
-        })
-        .catch((err) => {
-          console.log(err)
-        })
-  }
-  const handlePictureChange = (file,fileList) => {
-    // console.log(file)
-    const fileImgType = file.name.match(/\.([^\.]+)$/)[1];  //匹配文件格式(最后一个'.'后的格式)或者匹配file.raw.type
-    // const isImageType = ['png','jpg'];
-    const isImageType = ['doc','docx'];
-    const isLt500KB = file.size / 1024  < 500;  //判断图片格式与大小
-    if (isImageType.indexOf(fileImgType)==-1) {
-      ElMessage.error('文件仅支持png、jpg格式')  //限制文件类型
-      uploadDescribeRef.value.clearFiles()  //清空上传文件
-      return false;
-    }
-    if (!isLt500KB) {
-      ElMessage.error('文件不能超过500kb!')  //限制文件大小
-      uploadDescribeRef.value.clearFiles()  //清空上传文件
-      return false;
-    }
-    apis.uploadFile(file.raw)
-        .then((res) => {
-          newAssignmentInfo.attachments[0] = res.data.data
-        })
-        .catch((err) => {
-          console.log(err)
-        })
-  }
+  const writingVisible = ref(false)
+  const AISpeakingVisible = ref(false)
+  const isTypeDialogVisible = ref(false);
+  const assignmentType = ref()
 
   const dialogConfig = reactive({
     type: "",
-    visible: false,
     title: "",
-    disable: false
+    disable: false,
+    assignmentId:0
   })
 
-  const changeDialog = (type:string)=>{
-    dialogConfig.visible=true;
+
+  const changeDialog = (type:string, assignmentId: number, assignmentType: string)=>{
     switch (type){
       case "add": {
         dialogConfig.type="add";
+        dialogConfig.title=`新增${assignmentType}作业`;
         dialogConfig.disable=false;
-        dialogConfig.title="新增作业";
+        dialogConfig.assignmentId = assignmentId;
         break;
       }
       case "edit": {
         dialogConfig.type="edit";
+        dialogConfig.title=`编辑${assignmentType}作业`;
         dialogConfig.disable=false;
-        dialogConfig.title="编辑作业";
+        dialogConfig.assignmentId = assignmentId;
         break;
       }
     }
   }
 
+
+  const createAssignmentDialog = ()=>{
+    isTypeDialogVisible.value = false;
+    if(assignmentType.value != ''){
+     changeDialog('add', 0, assignmentType.value)
+     if(assignmentType.value === '写作'){
+        writingVisible.value = true;
+      } else if(assignmentType.value === 'AI口语'){
+        AISpeakingVisible.value = true;
+      }
+    }
+  }
+
   let open = ref(false)
 
   const alertProps = reactive({
@@ -335,6 +200,7 @@ const openEditor = (url: string, title: string) => {
   let newAssignmentInfo = reactive<AssignmentDTO>({
     assignmentName: '',
     description: '',
+    type:'',
     descriptionFile: '',
     attachments: [''],
     startTime: '',
@@ -345,6 +211,7 @@ const openEditor = (url: string, title: string) => {
     Object.assign(newAssignmentInfo, {
       assignmentName: '',
       description: '',
+      type:'',
       descriptionFile: '',
       attachments: [''],
       startTime: '',
@@ -356,6 +223,7 @@ const openEditor = (url: string, title: string) => {
     Object.assign(newAssignmentInfo, {
       assignmentName: assignment.assignmentName,
       description: assignment.description,
+      type:assignment.type,
       descriptionFile: assignment.descriptionFile,
       attachments: assignment.attachments == null? ['']:assignment.attachments,
       startTime: assignment.startTime,
@@ -374,17 +242,13 @@ const openEditor = (url: string, title: string) => {
        //如果被禁用则提示
       if(props.disabled || isDisabled(teacherId, endDate)){
         if(props.role == 'student'){
-          ElMessage.error('作业已经超过截止时间')
-          // ElMessage({
-          //   message: '作业已经超过截止时间',
-          //   type: 'message',
-          // })
+          if(props.disabled){
+            ElMessage.error('请先加入课程')
+          } else {
+            ElMessage.error('作业已经超过截止时间')
+          }
         }else if(props.role == 'teachers'){
           ElMessage.error('无权限')
-          // ElMessage({
-          //   message: '无权限',
-          //   type: 'message',
-          // })
         }
       }
     } else if(hover && type == 'leave'){
@@ -394,17 +258,16 @@ const openEditor = (url: string, title: string) => {
 
   // 用于记录当前编辑的作业的id
   let IAssignmentId = ref(0)
-  const handleFunctionButton = (assignmentId:number) => {
+  const handleFunctionButton = (assignmentId:number,  type: string) => {
     if(props.role == 'student'){
       handleWriting(assignmentId)
     } else if(props.role == 'teachers'){
-      apis.getAssignmentById(assignmentId)
-          .then((res:any) => {
-            setNewAssignmentInfo(res.data.data)
-            // console.log("功能按钮方法内部",newAssignmentInfo)
-            IAssignmentId.value = res.data.data.assignmentId
-          })
-      changeDialog('edit')
+      changeDialog('edit', assignmentId, type)
+      if(type === '写作'){
+        writingVisible.value = true;
+      } else if(type === 'AI口语'){
+        AISpeakingVisible.value =true;
+      }
     }
   }
 
@@ -414,115 +277,7 @@ const openEditor = (url: string, title: string) => {
   }
 
 
-  const handleSubmit = async () => {
-
-    if(newAssignmentInfo.assignmentName === '') {
-      ElNotification({title: '作业名称不得为空',type: 'error',})
-      return
-    }
-    if(newAssignmentInfo.description === '') {
-      ElNotification({title: '作业描述不得为空',type: 'error',})
-      return
-    }
 
-    if(newAssignmentInfo.startTime === '') {
-      ElNotification({title: '开始时间不得为空',type: 'error',})
-      return
-    }
-    if(newAssignmentInfo.endTime === '') {
-      ElNotification({title: '结束时间不得为空',type: 'error',})
-      return
-    }
-    if(newAssignmentInfo.startTime >= newAssignmentInfo.endTime) {
-      ElNotification({title: '结束时间不得在起始时间之前',type: 'error',})
-      return
-    }
-    newAssignmentInfo.endTime = formatDate(newAssignmentInfo.endTime);
-    newAssignmentInfo.startTime = formatDate(newAssignmentInfo.startTime);
-    if(dialogConfig.type === 'add'){
-      apis.createAssignment(props.courseId, newAssignmentInfo)
-          .then((res) => {
-            if(res.data.code===200) {
-              ElNotification({
-                title: '新增成功',
-                type: 'success',
-              });
-              //TODO: 新建作业后自动刷新页面 但是怎么跳转回作业列表页面?
-              location.reload();
-            }else {
-              ElNotification({
-                title: '新增失败',
-                type: 'error',
-              })
-            }
-            dialogConfig.visible = false
-            // cleanNewAssignmentInfo()
-          })
-          .catch((err) => {
-            console.log(err)
-          })
-    } else if(dialogConfig.type === 'edit'){
-      apis.updateAssignment(IAssignmentId.value, newAssignmentInfo)
-          .then((res:any) => {
-            if(res.data.code===200) {
-              ElNotification({
-                title: '修改成功',
-                type: 'success',
-              })
-            }else {
-              ElNotification({
-                title: '修改失败',
-                type: 'error',
-              })
-            }
-            dialogConfig.visible = false
-            // cleanNewAssignmentInfo()
-          })
-          .catch((err) => {
-            console.log(err)
-          })
-    }
-    doRefresh();
-  }
-//传teacherId:number, assignmentId:number
-  const confirmDelete = () => {
-    ElMessageBox.confirm('确定要删除这个作业吗?', '警告', {
-      confirmButtonText: '确定',
-      cancelButtonText: '取消',
-      type: 'warning'
-    }).then(() => {
-      deleteAssignment();
-    }).catch(() => {
-      // 用户选择取消,什么都不做
-    });
-  };
-
-  const deleteAssignment = () => {
-    apis.deleteAssignmentById(store.user.id,IAssignmentId.value)
-    
-      .then((res: any) => {
-        if (res.data.code === 200) {
-          ElMessage({
-            type: 'success',
-            message: '删除成功!'
-          });
-          // 这里可以调用刷新数据的方法
-          doRefresh();
-        } else {
-          ElMessage({
-            type: 'error',
-            message: '删除失败!'+res.data.message
-          });
-        }
-      })
-      .catch((err) => {
-        console.log(err);
-        ElMessage({
-          type: 'error',
-          message: '请求出错!'
-        });
-      });
-  };
 
   const isDisabled = (teacherId: string, endDate: string) => {
     if(props.role == 'teachers'){// 是教师且不是作业发布人则禁用
@@ -551,4 +306,6 @@ export default {
     height: 178px;
     text-align: center;
   }
+
+
 </style>

+ 10 - 2
src/views/CourseSquare/component/CourseDetails/index.vue

@@ -179,6 +179,7 @@ let query = reactive<AssignmentQuery>({
   courseId:courseId,
   assignmentName:null,
   status:null,
+  type:null
 })
 
 const editCourseRules = reactive<FormRules>({
@@ -239,13 +240,19 @@ const columns = ref<IAssignmentTableColumn[]>([
     title: '作业名称',
     dataIndex: 'desc',
     key: 'desc',
-    width: 250
+    width: 200
+  },
+  {
+    title: '类型',
+    dataIndex: 'type',
+    key: 'type',
+    width: 80
   },
   {
     title: '截止日期',
     dataIndex: 'endDate',
     key: 'endDate',
-    width: 180
+    width: 170
   },
   {
     title: '发布人',
@@ -455,6 +462,7 @@ const convertHomeworkList = (data:IAssignment[]) => {
   for(let i = 0; i < data.length; i++){
     let item:IAssignmentTableItem = {
       desc: data[i].assignmentName,
+      type: data[i].type,
       publisher: data[i].teacherId.toString(),
       startDate: formatDate(data[i].startTime),
       endDate: formatDate(data[i].endTime),

+ 31 - 4
src/views/HomeworkPage/component/HomeworkDetail.vue

@@ -22,7 +22,12 @@
         </div>
         <div class="item">
           <el-link type="primary" @click="downloadFile(data.descriptionFile, `${data.assignmentName}-描述文件`)">相关文件下载</el-link>
-          <el-button color="#597d3b" size="large" style="margin-left: 660px" @click="handleWriting()" :disabled="new Date() > new Date(data.endTime)">开始写作</el-button>
+          <div class="button-container">
+            <el-button style="margin-left: 600px" color="#597D3B" @click="handleBack">返回</el-button>
+            <el-button color="#597D3B" @click="handleStart()" :disabled="new Date() > new Date(data.endTime)">
+              开始{{ data.type }}
+            </el-button>
+          </div>
         </div>
       </div>
     </div>
@@ -73,12 +78,17 @@ let data = reactive<IAssignment>({
   startTime: "",
   endTime: "",
   createTime: "",
-  status: ""
+  status: "",
+  type:""
 })
 
-function handleWriting(){
+function handleStart(){
   getEngagement()
-  router.push(`/home/assignment/${assignmentId}/edit`)
+  if(data.type == '写作'){
+    router.push(`/home/assignment/${assignmentId}/edit`)
+  } else if(data.type == 'AI口语'){
+    router.push(`/home/assignment/${assignmentId}/aispeaking`)
+  } 
 }
 
 function downloadFile(url:string, fileName:string) {
@@ -119,6 +129,11 @@ const fetchData = () => {
       })
 }
 
+const handleBack = () => {
+   // 跳转到目标页面
+   router.go(-1)
+}
+
 onMounted(() => {
   fetchData()
 })
@@ -129,3 +144,15 @@ export default {
   name: "HomeworkDetail"
 }
 </script>
+
+<style scoped>
+  .button-container {
+    display: flex;
+
+  }
+  .el-button {
+  margin-right:0px;
+}
+</style>
+
+

+ 15 - 3
src/views/HomeworkPage/index.vue

@@ -32,6 +32,7 @@
       </template>
       <el-table-column type="index"/>
       <el-table-column prop="assignmentName" label="课程名称" width="400"/>
+      <el-table-column prop="type" label="类型" width="100"/>
 <!--      <el-table-column prop="description" label="描述" width="400"/>-->
       <el-table-column prop="endTime" label="截止时间" sortable/>
       <el-table-column prop="status" label="状态" sortable>
@@ -51,7 +52,7 @@
       </el-table-column>
       <el-table-column label="查看" width="150">
         <template #default="scope">
-          <el-button @click="viewCorrection(scope.row.assignmentId,store.user.id)" style="margin-right: 0; margin-left: 0;padding-left: 10px; padding-right: 10px" color="#597D3B">
+          <el-button @click="viewCorrection(scope.row.assignmentId,store.user.id,scope.row.type, scope.row.assignmentName)" style="margin-right: 0; margin-left: 0;padding-left: 10px; padding-right: 10px" color="#597D3B">
             <el-icon style="margin-right: 3px"><ZoomIn/></el-icon>
             批改结果
           </el-button>
@@ -116,8 +117,19 @@
     total: null,
   })
   //  查看教师批改结果 跳转至批改页面
-  const viewCorrection = (assignmentId:number,studentId:number) => {
-    router.push(`/home/assignment/${assignmentId}/correct?studentId=${studentId}`)
+  const viewCorrection = (assignmentId:number,studentId:number, type:string, assignmentName:string) => {
+    if(type === '写作'){
+      router.push(`/home/assignment/${assignmentId}/correct?studentId=${studentId}`)
+    } else if(type === 'AI口语'){
+      router.push({
+      path: `/home/assignment/${assignmentId}/airecord`,
+      query:{
+        userId: studentId,
+        assignmentId: assignmentId,
+        assignmentName: assignmentName
+      }});
+    }
+   
 
   }
 

+ 11 - 0
src/views/LoginPage/component/Register.vue

@@ -34,6 +34,10 @@
         <el-form-item label="邮箱" prop="officialEmail">
           <el-input v-model="userRegisterDTO.officialEmail" placeholder="例:xxx@smail.nju.edu.cn"></el-input>
         </el-form-item>
+        <!-- 手机号 -->
+        <el-form-item label="手机号" prop="phone">
+          <el-input v-model="userRegisterDTO.phone" placeholder="请输入手机号"></el-input>
+        </el-form-item>
 
         <!--密码-->
         <el-form-item label="密码" prop="password">
@@ -129,6 +133,13 @@ const userRules = reactive<FormRules<UserRegisterDTO>>({
       trigger: 'blur'
     }
   ],
+  phone: [
+    {
+      required: true,
+      message: "手机号不能为空",
+      trigger: 'blur'
+    }
+  ],
   password: [
     {
       required: true,