Browse Source

Merge branch 'host-nju' into fix/lyz_bug_fix_20260112

# Conflicts:
#	src/router/index.ts
#	src/views/BehaviorPage/BehaviorBoard.vue
廖彦舟 4 tháng trước cách đây
mục cha
commit
c72102bb18
53 tập tin đã thay đổi với 4999 bổ sung727 xóa
  1. 3 1
      .env.example
  2. 3 0
      .gitignore
  3. 30 0
      package-lock.json
  4. 2 0
      package.json
  5. 331 0
      plan_exam_mode.md
  6. 415 0
      plan_exam_mode_student.md
  7. 124 0
      src/apis/ai.ts
  8. 9 0
      src/apis/assignment.ts
  9. 6 1
      src/apis/axios.config.ts
  10. 17 0
      src/apis/behavior.ts
  11. 9 0
      src/apis/class.ts
  12. 16 0
      src/apis/engagement.ts
  13. 12 2
      src/apis/evaluation.ts
  14. 328 15
      src/components/AIChatter/AIChatter.module.scss
  15. 119 57
      src/components/AIChatter/AIChatter.vue
  16. 41 35
      src/components/QuillEditor/Editor.vue
  17. 34 11
      src/components/QuillEditor/QuillEditor.vue
  18. 2 2
      src/components/ViewsWordEditor/ViewsWordEditor.vue
  19. 81 0
      src/hooks/useExamCountdown.ts
  20. 19 3
      src/layout/Header/Header.vue
  21. 10 0
      src/layout/Header/index.scss
  22. 2 2
      src/router/index.ts
  23. 2 0
      src/types/dto.ts
  24. 40 9
      src/types/vo.ts
  25. 11 11
      src/utils/indexedDBUtil.ts
  26. 794 104
      src/views/AIEvaluationPage/AIEvaluationPage.vue
  27. 7 4
      src/views/AiSpeakingPage/SpeakingPage.vue
  28. 2 2
      src/views/AiSpeakingPage/component/VoiceAnalysis.vue
  29. 125 15
      src/views/BehaviorPage/BehaviorBoard.vue
  30. 54 4
      src/views/ClassPage/ClassHomework.vue
  31. 37 1
      src/views/ClassPage/ClassStudent.vue
  32. 18 69
      src/views/ClassPage/StudentDetail.vue
  33. 141 55
      src/views/CorrectPage/CorrectPage.vue
  34. 722 58
      src/views/CorrectPage/CoursePage.vue
  35. 240 69
      src/views/CorrectPage/index.scss
  36. 3 2
      src/views/CoursePage/CoursePage.vue
  37. 1 1
      src/views/CourseSquare/component/AllCourse/MoreCourse.vue
  38. 33 1
      src/views/CourseSquare/component/CourseDetails/component/AssignmentDialogue/WritingAssignmentDialogue.vue
  39. 27 15
      src/views/CourseSquare/component/CourseDetails/component/CourseHomeworkTable.vue
  40. 15 6
      src/views/CourseSquare/component/CourseDetails/index.vue
  41. 242 57
      src/views/EditPage/EditPage.vue
  42. 31 0
      src/views/EditPage/index.scss
  43. 87 20
      src/views/EditPage/userWritingRecord.ts
  44. 6 1
      src/views/Home/component/ContentSelector/ContentSelector.vue
  45. 9 0
      src/views/Home/component/Dictionaries/index.scss
  46. 1 1
      src/views/Home/component/Dictionaries/index.vue
  47. 24 6
      src/views/Home/component/Edit/index.vue
  48. 408 26
      src/views/Home/component/Silder/index.scss
  49. 239 39
      src/views/Home/component/Silder/index.vue
  50. 27 21
      src/views/HomeworkPage/component/HomeworkDetail.vue
  51. 3 1
      src/views/LoginPage/component/Login.vue
  52. 29 0
      vite.config.js
  53. 8 0
      vite.config.ts

+ 3 - 1
.env.example

@@ -7,7 +7,9 @@
 # 建议不要删除,只注释。按需取用
 VITE_TITLE = '本地开发'
 # 本地后端服务器端口
-VITE_BASE_URL = 'http://localhost:8082'
+VITE_BASE_URL = 'http://localhost:8080'
+# 请求超时(毫秒)。默认 30000;需更快失败可设小些
+# VITE_REQUEST_TIMEOUT = 30000
 # 线上正式服务器地址
 # VITE_BASE_URL = 'https://air-nju.seec.seecoder.cn/'
 # 线上http服务器地址

+ 3 - 0
.gitignore

@@ -30,3 +30,6 @@ coverage
 *.tsbuildinfo
 
 .env.local
+RESPONSIVE_DESIGN_CHANGES.md
+EDITPAGE_RESPONSIVE_CHANGES.md
+EDITPAGE_LAYOUT_IMPROVEMENTS.md

+ 30 - 0
package-lock.json

@@ -16,9 +16,11 @@
         "crypto-js": "^4.2.0",
         "dayjs": "^1.11.13",
         "docx-preview": "^0.3.2",
+        "dompurify": "^3.4.0",
         "echarts": "^5.6.0",
         "element-plus": "^2.9.7",
         "js-audio-recorder": "^1.0.7",
+        "marked": "^18.0.2",
         "mitt": "^3.0.1",
         "pinia": "^2.1.7",
         "pinia-plugin-persistedstate": "^3.2.1",
@@ -1208,6 +1210,13 @@
         "undici-types": "~6.19.2"
       }
     },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/@types/web-bluetooth": {
       "version": "0.0.16",
       "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
@@ -2145,6 +2154,15 @@
         "jszip": ">=3.0.0"
       }
     },
+    "node_modules/dompurify": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.0.tgz",
+      "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3183,6 +3201,18 @@
         "@jridgewell/sourcemap-codec": "^1.5.0"
       }
     },
+    "node_modules/marked": {
+      "version": "18.0.2",
+      "resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.2.tgz",
+      "integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==",
+      "license": "MIT",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 20"
+      }
+    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

+ 2 - 0
package.json

@@ -20,9 +20,11 @@
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.13",
     "docx-preview": "^0.3.2",
+    "dompurify": "^3.4.0",
     "echarts": "^5.6.0",
     "element-plus": "^2.9.7",
     "js-audio-recorder": "^1.0.7",
+    "marked": "^18.0.2",
     "mitt": "^3.0.1",
     "pinia": "^2.1.7",
     "pinia-plugin-persistedstate": "^3.2.1",

+ 331 - 0
plan_exam_mode.md

@@ -0,0 +1,331 @@
+# 考试模式功能修改计划
+
+## 一、需求概述
+
+根据后端新增的字段:
+- `Assignment.examMode`: 是否为考试模式
+- `Assignment.examDuration`: 考试时长(分钟)
+- `EngagementVO.submitted`: 是否已交卷
+
+功能要求:
+1. 教师创建作业时可选择是否开启考试模式,并设置考试时长(单位:分钟)
+2. 考试模式下学生可正常使用所有辅助组件(AI助手、词典等)
+3. 考试期间可以暂存内容
+4. 未提交时实时显示考试倒计时
+5. 结束前5分钟给出暂存提示
+6. 倒计时结束时触发**自动暂存并提交**
+
+---
+
+## 二、涉及文件清单
+
+### 类型定义文件
+| 文件 | 修改内容 |
+|------|----------|
+| `src/types/dto.ts` | `AssignmentDTO` 添加 `examMode`, `examDuration` 字段 |
+| `src/types/vo.ts` | `IAssignment`, `AssignmentVO` 添加 `examMode`, `examDuration` 字段<br>`engagementVO` 添加 `submitted` 字段 |
+
+### 教师端作业创建页面
+| 文件 | 修改内容 |
+|------|----------|
+| `src/views/CourseSquare/component/CourseDetails/component/AssignmentDialogue/WritingAssignmentDialogue.vue` | 添加考试模式开关和时长输入 |
+
+### Hooks文件(新建)
+| 文件 | 修改内容 |
+|------|----------|
+| `src/hooks/useExamCountdown.ts` | 新建考试倒计时hook |
+
+### 学生端页面/组件
+| 文件 | 修改内容 |
+|------|----------|
+| `src/views/HomeworkPage/component/HomeworkDetail.vue` | 显示作业模式标签(普通作业/考试模式) |
+| `src/views/EditPage/EditPage.vue` | 集成倒计时显示 |
+| `src/views/Home/component/Edit/index.vue` | 考试模式提交时显示确认提示 |
+
+---
+
+## 三、详细修改计划
+
+### 阶段1:类型定义更新
+
+#### 1.1 `src/types/dto.ts`
+```typescript
+export interface AssignmentDTO {
+    assignmentName: string;
+    description: string;
+    type: string;
+    descriptionFile?: string;
+    attachments?: string[];
+    startTime: string;
+    endTime: string;
+    examMode?: boolean;        // 新增:是否为考试模式
+    examDuration?: number;     // 新增:考试时长(分钟),仅考试模式有效
+}
+```
+
+#### 1.2 `src/types/vo.ts`
+```typescript
+// engagementVO 新增字段
+export interface engagementVO {
+    "id": number,
+    "assignmentId": number,
+    "studentId": number,
+    "score": number,
+    "remark": string,
+    "fileUrl": string,
+    "version": number,
+    "status": string,
+    "fileKey": string,
+    "quillContent": string,
+    "textContent": string,
+    "htmlContent": string,
+    "submitted": boolean;      // 新增:是否已交卷
+}
+
+// IAssignment 新增字段
+export interface IAssignment {
+    // ... 现有字段
+    examMode?: boolean;        // 新增
+    examDuration?: number;     // 新增
+}
+
+// AssignmentVO 新增字段
+export interface AssignmentVO {
+    // ... 现有字段
+    examMode?: boolean;        // 新增
+    examDuration?: number;     // 新增
+}
+```
+
+---
+
+### 阶段2:新建考试倒计时Hook
+
+#### 2.1 `src/hooks/useExamCountdown.ts`(新建)
+功能:
+- 接收参数:考试开始时间、考试时长(分钟)
+- 实时计算剩余时间
+- 倒计时结束时触发**自动暂存并提交**回调
+- 提供"剩余时间小于5分钟"的标识,供UI提示暂存
+
+```typescript
+interface UseExamCountdownOptions {
+    startTime: Date;      // 作业开始时间
+    duration: number;     // 考试时长(分钟)
+    onAutoSubmit: () => void;  // 自动交卷回调
+    onWarning: () => void;     // 5分钟警告回调
+}
+
+返回值:
+- remainingTime: ref<number>  // 剩余秒数
+- isWarning: ref<boolean>     // 是否进入5分钟警告期
+- isExpired: ref<boolean>     // 是否已过期
+- formattedTime: computed      // 格式化时间字符串 "HH:mm:ss"
+```
+
+---
+
+### 阶段3:教师创建作业页面修改
+
+#### 3.1 `WritingAssignmentDialogue.vue`
+
+添加考试模式表单项:
+```vue
+<el-form-item label="作业模式">
+  <el-switch
+    v-model="newAssignmentInfo.examMode"
+    active-text="考试模式"
+    inactive-text="普通作业"
+  />
+</el-form-item>
+<el-form-item v-if="newAssignmentInfo.examMode" label="考试时长">
+  <el-input-number
+    v-model="newAssignmentInfo.examDuration"
+    :min="1"
+    :max="300"
+    controls-position="right"
+  />
+  <span style="margin-left: 10px;">分钟</span>
+</el-form-item>
+```
+
+数据初始化修改:
+```typescript
+const newAssignmentInfo = reactive<AssignmentDTO>({
+  // ... 现有字段
+  examMode: false,
+  examDuration: 60,  // 默认60分钟
+});
+```
+
+编辑时数据回填:
+```typescript
+Object.assign(newAssignmentInfo, {
+  // ... 现有字段
+  examMode: assignment.examMode ?? false,
+  examDuration: assignment.examDuration ?? 60,
+});
+```
+
+提交验证:
+```typescript
+if (newAssignmentInfo.examMode && (!newAssignmentInfo.examDuration || newAssignmentInfo.examDuration < 1)) {
+  ElNotification({ title: '考试模式下考试时长不得为空', type: 'error' });
+  return;
+}
+```
+
+---
+
+### 阶段4:作业详情页修改
+
+#### 4.1 `HomeworkDetail.vue`
+
+修改点:显示模式标识和时长
+```vue
+<div class="item">
+  <span style="font-weight: 600">作业模式: </span>
+  <el-tag :type="data.examMode ? 'warning' : 'success'">
+    {{ data.examMode ? '考试模式' : '普通作业' }}
+  </el-tag>
+  <span v-if="data.examMode && data.examDuration" style="margin-left: 10px;">
+    时长: {{ data.examDuration }}分钟
+  </span>
+</div>
+```
+
+---
+
+### 阶段5:写作页面修改(核心)
+
+#### 5.1 `EditPage.vue`
+
+考试模式下的布局调整:
+```vue
+<div class="my-work" >
+  <!-- 左侧:AI助手 + 词典(考试模式下正常显示) -->
+  <div class="my-work-slider">
+    <el-button class="back" ...>返回</el-button>
+    <AIChatter ... />  <!-- 考试模式下正常显示 -->
+    <Dictionaries />   <!-- 考试模式下正常显示 -->
+  </div>
+
+  <!-- 考试模式:显示倒计时横幅 -->
+  <div v-if="isExamMode" class="exam-countdown-header">
+    <el-tag type="warning">考试模式</el-tag>
+    <span class="countdown-time">{{ formattedTime }}</span>
+    <el-tag v-if="isWarning" type="danger" effect="plain">
+      距离结束还有5分钟,请及时暂存内容!
+    </el-tag>
+  </div>
+
+  <!-- 中间:编辑区 -->
+  <div class="my-work-input">
+    <Edit :engagement="dataForm.engagement" />
+  </div>
+
+  <!-- 右侧:作业要求和批改(考试模式下隐藏) -->
+  <Slider v-if="!isExamMode" ... />
+</div>
+```
+
+考试倒计时集成:
+```typescript
+import { useExamCountdown } from '@/hooks/useExamCountdown';
+
+// 计算属性
+const isExamMode = computed(() => dataForm.assignment?.examMode ?? false);
+const examDuration = computed(() => dataForm.assignment?.examDuration ?? 0);
+
+// 倒计时hook
+const { remainingTime, isWarning, formattedTime, start: startCountdown } = useExamCountdown({
+  startTime: new Date(dataForm.assignment.startTime),
+  duration: examDuration.value,
+  onAutoSubmit: async () => {
+    ElMessage.warning('考试时间已到,正在自动提交...');
+    await handleStage();  // 自动暂存
+    await handleSubmit(); // 自动提交
+  },
+  onWarning: () => {
+    ElMessage.info('距离考试结束还有5分钟,请注意暂存您的内容!');
+  }
+});
+
+onMounted(() => {
+  if (isExamMode.value) {
+    startCountdown();
+  }
+});
+```
+
+---
+
+### 阶段6:编辑组件提交确认
+
+#### 6.1 `Edit/index.vue`
+
+考试模式提交确认:
+```typescript
+const handleSubmit = async () => {
+  if (isExamMode) {
+    // 考试模式:确认提示
+    await ElMessageBox.confirm(
+      '考试时间结束后将自动交卷,确认现在提交吗?提交后不可再修改。',
+      '确认交卷',
+      {
+        confirmButtonText: '确认提交',
+        cancelButtonText: '取消',
+        type: 'warning',
+      }
+    );
+  }
+  // 执行提交...
+};
+```
+
+Props接收考试模式标识:
+```typescript
+interface IEditProps {
+  engagement: engagementVO
+  isExamMode?: boolean;
+}
+defineProps<IEditProps>();
+```
+
+---
+
+## 四、考试模式功能差异对照表
+
+| 功能 | 普通作业模式 | 考试模式 |
+|------|------------|---------|
+| AI助手 | ✅ 正常显示 | ✅ 正常显示 |
+| 词典 | ✅ 正常显示 | ✅ 正常显示 |
+| 暂存按钮 | ✅ 显示 | ✅ 显示(考试期间可暂存) |
+| 批改区域 | ✅ 显示 | ❌ 隐藏 |
+| 倒计时 | ❌ 不显示 | ✅ 显示 |
+| 提交次数限制 | 多次 | 仅一次 |
+| 提交确认提示 | 无 | 有"不可修改"提示 |
+| 5分钟警告 | 无 | 有暂存提示 |
+| 自动交卷 | 无 | 倒计时结束自动暂存并提交 |
+
+---
+
+## 五、实施顺序
+
+1. **类型定义更新**(dto.ts, vo.ts)
+2. **新建 useExamCountdown hook**
+3. **WritingAssignmentDialogue.vue** - 教师端添加考试模式选项
+4. **HomeworkDetail.vue** - 显示模式标识
+5. **EditPage.vue** - 集成倒计时横幅
+6. **Edit/index.vue** - 考试模式提交确认
+7. **样式文件** - 考试模式UI
+
+---
+
+## 六、注意事项
+
+1. 考试时长从 `assignment` 获取(教师设置),不是从 `engagement`
+2. 倒计时基于 `startTime` + `examDuration` 计算结束时间
+3. 自动交卷需要先暂存当前内容,再提交
+4. 已提交状态的严格校验在后端完成
+5. 考试模式下的各个辅助功能正常可用,仅隐藏批改区域

+ 415 - 0
plan_exam_mode_student.md

@@ -0,0 +1,415 @@
+# 学生端考试模式实现计划(修订版)
+
+## 一、需求回顾
+
+根据后端字段:
+- `Assignment.examMode`: 是否为考试模式
+- `Assignment.examDuration`: 考试时长(分钟)
+- `EngagementVO.submitted`: 是否已交卷
+
+功能要求:
+1. 作业详情页显示考试模式标识
+2. 写作页面实时显示考试倒计时
+3. 结束前5分钟提示学生暂存内容
+4. 倒计时结束时**自动暂存并提交**
+5. 提交时提示"提交后不可再修改"
+6. **考试模式已提交学生不可再进入写作页面**
+7. 教师端无需隐藏批改区域
+
+---
+
+## 二、涉及文件清单
+
+| 文件 | 修改内容 |
+|------|----------|
+| `src/hooks/useExamCountdown.ts` | **新建** - 考试倒计时 hook |
+| `src/views/HomeworkPage/component/HomeworkDetail.vue` | 显示考试模式标识、已提交则禁用开始按钮 |
+| `src/views/EditPage/EditPage.vue` | 集成倒计时横幅、已提交则跳转离开 |
+| `src/views/Home/component/Edit/index.vue` | 考试模式提交确认提示 |
+
+---
+
+## 三、详细实现计划
+
+### 3.1 新建考试倒计时 Hook
+
+**文件**: `src/hooks/useExamCountdown.ts`
+
+```typescript
+import { ref, computed, onUnmounted } from 'vue';
+
+interface UseExamCountdownOptions {
+  startTime: Date;
+  duration: number;
+  onAutoSubmit: () => void;
+  onWarning: () => void;
+}
+
+export function useExamCountdown(options: UseExamCountdownOptions) {
+  const { startTime, duration, onAutoSubmit, onWarning } = options;
+
+  const endTime = new Date(startTime.getTime() + duration * 60 * 1000);
+  const remainingSeconds = ref(0);
+  const isWarning = ref(false);
+  const isExpired = ref(false);
+  let intervalId: number | null = null;
+  let warningTriggered = false;
+
+  const formattedTime = computed(() => {
+    const hours = Math.floor(remainingSeconds.value / 3600);
+    const minutes = Math.floor((remainingSeconds.value % 3600) / 60);
+    const seconds = remainingSeconds.value % 60;
+    return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
+  });
+
+  const tick = () => {
+    const now = new Date();
+    const diff = Math.floor((endTime.getTime() - now.getTime()) / 1000);
+
+    if (diff <= 0) {
+      remainingSeconds.value = 0;
+      isExpired.value = true;
+      isWarning.value = false;
+      stop();
+      onAutoSubmit();
+      return;
+    }
+
+    remainingSeconds.value = diff;
+
+    if (diff <= 300 && !warningTriggered) {
+      isWarning.value = true;
+      warningTriggered = true;
+      onWarning();
+    }
+  };
+
+  const start = () => {
+    tick();
+    intervalId = window.setInterval(tick, 1000);
+  };
+
+  const stop = () => {
+    if (intervalId !== null) {
+      clearInterval(intervalId);
+      intervalId = null;
+    }
+  };
+
+  onUnmounted(() => {
+    stop();
+  });
+
+  return {
+    remainingSeconds,
+    isWarning,
+    isExpired,
+    formattedTime,
+    start,
+    stop
+  };
+}
+```
+
+---
+
+### 3.2 作业详情页修改
+
+**文件**: `src/views/HomeworkPage/component/HomeworkDetail.vue`
+
+#### 3.2.1 数据模型修改
+
+在 `IAssignment` 类型已包含 `examMode` 和 `examDuration`,无需修改接口
+
+#### 3.2.2 模板添加考试模式标识
+
+```vue
+<div class="item">
+  <span style="font-weight: 600">截止日期: </span>
+  {{new Date(Date.parse(data.endTime)).toLocaleString()}}
+</div>
+<!-- 新增:考试模式标识 -->
+<div v-if="data.examMode" class="item">
+  <span style="font-weight: 600">作业模式: </span>
+  <el-tag type="warning">考试模式</el-tag>
+  <span v-if="data.examDuration" style="margin-left: 10px; color: #909399;">
+    时长: {{ data.examDuration }}分钟
+  </span>
+</div>
+```
+
+#### 3.2.3 开始按钮逻辑修改
+
+```typescript
+// 计算属性:判断作业是否应该禁用
+const isAssignmentDisabled = computed(() => {
+  const now = new Date()
+  const startTime = new Date(data.startTime)
+  const endTime = new Date(data.endTime)
+
+  if (now < startTime) {
+    return true
+  }
+
+  if (now > endTime) {
+    if (engagementStatus.value) {
+      return engagementStatus.value.status !== 'NOT_SUBMITTED'
+    }
+    return false
+  }
+
+  // 新增:考试模式且已提交,禁止再次进入
+  if (data.examMode && engagementStatus.value?.submitted) {
+    return true
+  }
+
+  return false
+})
+
+// 新增:按钮提示文本
+const getButtonTooltip = computed(() => {
+  // ... 现有逻辑 ...
+
+  // 新增:考试模式已提交提示
+  if (data.examMode && engagementStatus.value?.submitted) {
+    return '您已完成交卷,无法再次编辑'
+  }
+
+  return ''
+})
+```
+
+#### 3.2.4 开始按钮模板修改
+
+```vue
+<el-button
+  style="color: #FFFFFF;"
+  color="#7ADBB0"
+  @click="handleStart()"
+  :disabled="isAssignmentDisabled"
+  :title="getButtonTooltip"
+>
+  开始{{ data.type }}
+</el-button>
+```
+
+---
+
+### 3.3 写作页面修改
+
+**文件**: `src/views/EditPage/EditPage.vue`
+
+#### 3.3.1 数据模型扩展
+
+```typescript
+const dataForm = reactive<{
+  messages: IDialogue[],
+  dialogueId: string;
+  engagement: engagementVO;
+  assignment: IAssignment;  // 新增
+}>({
+  messages: [],
+  dialogueId: '',
+  engagement: {} as engagementVO,
+  assignment: {} as IAssignment
+})
+```
+
+#### 3.3.2 页面加载时校验
+
+```typescript
+import { ElMessage } from 'element-plus'
+
+onMounted(async () => {
+  await fetchData()
+
+  // 新增:考试模式已提交则不允许进入
+  if (isExamMode.value && dataForm.engagement.submitted) {
+    ElMessage.warning('您已完成交卷,无法再次编辑')
+    router.push(`/home/homeworkDetail/${assignmentId}`)
+    return
+  }
+
+  if (isExamMode.value) {
+    startCountdown()
+  }
+
+  registerAllEventListeners(indexedDB, store.user.id, assignmentId)
+})
+
+// 计算属性
+const isExamMode = computed(() => dataForm.assignment?.examMode ?? false)
+```
+
+#### 3.3.3 模板添加倒计时横幅
+
+```vue
+<div class="my-work" >
+  <div class="my-work-slider">
+    <el-button class="back" ...>返回</el-button>
+    <AIChatter ... />
+    <Dictionaries />
+  </div>
+
+  <!-- 考试模式倒计时横幅 -->
+  <div v-if="isExamMode" class="exam-countdown-banner">
+    <el-tag type="warning">考试模式</el-tag>
+    <span class="countdown-time">{{ formattedTime }}</span>
+    <el-tag v-if="isWarning" type="danger" effect="plain">
+      距离结束还有5分钟,请及时暂存内容!
+    </el-tag>
+  </div>
+
+  <div class="my-work-input">
+    <Edit :engagement="dataForm.engagement" :is-exam-mode="isExamMode" />
+  </div>
+
+  <Slider :dialogueId="dataForm.dialogueId" :engagement="dataForm.engagement"/>
+</div>
+```
+
+#### 3.3.4 倒计时集成
+
+```typescript
+import { useExamCountdown } from '@/hooks/useExamCountdown'
+
+const handleStage = async () => {
+  emitter.emit('get-html')
+  emitter.emit('get-text')
+  emitter.emit('get-quill-content')
+  // 实际暂存逻辑...
+}
+
+const handleSubmit = async () => {
+  // 实际提交逻辑...
+}
+
+const { remainingSeconds, isWarning, formattedTime, start: startCountdown, stop: stopCountdown } = useExamCountdown({
+  startTime: new Date(dataForm.assignment.startTime),
+  duration: dataForm.assignment.examDuration || 60,
+  onAutoSubmit: async () => {
+    ElMessage.warning('考试时间已到,正在自动提交...')
+    await handleStage()
+    await handleSubmit()
+  },
+  onWarning: () => {
+    ElMessage.info('距离考试结束还有5分钟,请注意暂存您的内容!')
+  }
+})
+
+onUnmounted(() => {
+  stopCountdown()
+  removeAllEventListeners()
+  emitter.off('save-ai-dialogue-before-submit')
+})
+```
+
+#### 3.3.5 样式添加
+
+**文件**: `src/views/EditPage/index.scss`
+
+```scss
+.exam-countdown-banner {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  padding: 12px 24px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  font-size: 16px;
+  justify-content: center;
+
+  .countdown-time {
+    font-size: 28px;
+    font-weight: bold;
+    font-family: 'Monaco', 'Menlo', monospace;
+    letter-spacing: 2px;
+  }
+}
+```
+
+---
+
+### 3.4 编辑组件提交确认
+
+**文件**: `src/views/Home/component/Edit/index.vue`
+
+```typescript
+interface IEditProps {
+  engagement: engagementVO
+  isExamMode?: boolean
+}
+defineProps<IEditProps>()
+
+const props = defineProps<{
+  engagement: engagementVO
+  isExamMode?: boolean
+}>()
+
+const handleSubmit = async () => {
+  // 考试模式:强制确认
+  if (props.isExamMode) {
+    try {
+      await ElMessageBox.confirm(
+        '考试模式下提交后将无法再次修改,您确定要提交吗?',
+        '确认交卷',
+        {
+          confirmButtonText: '确认提交',
+          cancelButtonText: '取消',
+          type: 'warning',
+        }
+      )
+    } catch {
+      return
+    }
+  }
+
+  // 执行提交逻辑...
+}
+```
+
+---
+
+## 四、关键差异对照表
+
+| 功能 | 普通作业模式 | 考试模式 |
+|------|------------|---------|
+| 暂存按钮 | ✅ 显示 | ✅ 显示 |
+| AI助手 | ✅ 显示 | ✅ 显示 |
+| 词典 | ✅ 显示 | ✅ 显示 |
+| 批改区域 | ✅ 显示 | ✅ 显示(教师端不隐藏) |
+| 倒计时 | ❌ 不显示 | ✅ 显示 |
+| 提交确认 | 无 | 有"不可修改"提示 |
+| 5分钟警告 | 无 | 有暂存提示 |
+| 自动交卷 | 无 | 倒计时结束自动暂存并提交 |
+| 已提交禁止再进入 | 无 | ✅ 有 |
+
+---
+
+## 五、实施顺序
+
+| 顺序 | 文件 | 说明 |
+|------|------|------|
+| 1 | `src/hooks/useExamCountdown.ts` | 新建倒计时 hook |
+| 2 | `src/views/EditPage/index.scss` | 倒计时横幅样式 |
+| 3 | `src/views/HomeworkPage/component/HomeworkDetail.vue` | 模式标识 + 已提交禁用 |
+| 4 | `src/views/EditPage/EditPage.vue` | 集成倒计时 + 提交拦截 |
+| 5 | `src/views/Home/component/Edit/index.vue` | 提交确认提示 |
+
+---
+
+## 六、后端接口需求
+
+**无需新增接口**,现有 API 已足够:
+
+| 接口 | 返回数据 | 用途 |
+|------|----------|------|
+| `GET /api/assignment/byAssignment/{id}` | `examMode`, `examDuration` | 获取作业考试配置 |
+| `GET /api/assignment/engage` | `submitted` | 获取学生交卷状态 |
+| `PUT /api/assignment/engage/stage` | - | 暂存作业内容 |
+| `PUT /api/assignment/engage/submit` | - | 提交作业 |
+
+**后端需确保**:
+1. `Assignment` 返回中包含 `examMode` 和 `examDuration`
+2. `EngagementVO` 包含 `submitted` 字段
+3. 提交接口校验考试模式只能提交一次

+ 124 - 0
src/apis/ai.ts

@@ -3,6 +3,79 @@ import type {IDialogue} from "@/types/dialogue";
 
 const AI_PREFIX = "/api/ai"
 
+interface StreamHandlers {
+    onChunk?: (chunk: string) => void;
+    onComplete?: () => void;
+}
+
+export type StreamRequestOptions = StreamHandlers & {
+    /** 与 userId 一并用于 dialogueId 失效时后端按作业+用户纠偏/补建 Mongo 会话 */
+    assignmentId?: number
+    userId?: number
+}
+
+const extractStreamContent = (payload: unknown): string => {
+    if (typeof payload === 'string') {
+        return payload
+    }
+
+    if (!payload || typeof payload !== 'object') {
+        return ''
+    }
+
+    const data = payload as Record<string, any>
+
+    if (typeof data.content === 'string') {
+        return data.content
+    }
+
+    if (typeof data.data?.content === 'string') {
+        return data.data.content
+    }
+
+    const choice = data.choices?.[0]
+    if (typeof choice?.delta?.content === 'string') {
+        return choice.delta.content
+    }
+
+    if (typeof choice?.message?.content === 'string') {
+        return choice.message.content
+    }
+
+    return ''
+}
+
+const handleSSEBlock = (block: string, onChunk?: (chunk: string) => void) => {
+    const lines = block
+        .split('\n')
+        .map((line) => line.trim())
+        .filter(Boolean)
+
+    const dataLines = lines
+        .filter((line) => line.startsWith('data:'))
+        .map((line) => line.slice(5).trim())
+
+    if (dataLines.length === 0) {
+        return
+    }
+
+    const payload = dataLines.join('\n')
+    if (!payload || payload === '[DONE]') {
+        return
+    }
+
+    try {
+        const parsed = JSON.parse(payload)
+        const content = extractStreamContent(parsed)
+        if (content) {
+            onChunk?.(content)
+        }
+        return
+    } catch (_error) {
+        onChunk?.(payload)
+    }
+}
+
 const aiApis = {
     getAIDialogues(assignmentId: number, userId: number, page: number=0, size: number=20) {
         return axiosInstance.get(`${AI_PREFIX}`, {
@@ -29,8 +102,59 @@ const aiApis = {
         }, {
             params: {
                 dialogueId: dialogueId
+            },
+             timeout: 60000, // 设置60秒超时(默认通常是0,表示不超时)
+            timeoutErrorMessage: '请求超时,请稍后重试'
+        })
+    },
+    async requestAIStream(dialogueId: string, model: string, messages: IDialogue[], options: StreamRequestOptions = {}) {
+        const { onChunk, onComplete, assignmentId, userId } = options
+        let processedLength = 0
+        let streamBuffer = ''
+
+        const response = await axiosInstance.put(`${AI_PREFIX}/stream`, {
+            model,
+            messages,
+            ...(assignmentId != null ? { assignmentId } : {}),
+            ...(userId != null ? { userId } : {})
+        }, {
+            params: {
+                dialogueId
+            },
+            headers: {
+                Accept: 'text/event-stream'
+            },
+            timeout: 0,
+            responseType: 'text',
+            onDownloadProgress: (progressEvent: any) => {
+                const responseText = progressEvent?.event?.target?.responseText
+                if (typeof responseText !== 'string') {
+                    return
+                }
+
+                const newText = responseText.slice(processedLength)
+                if (!newText) {
+                    return
+                }
+
+                processedLength = responseText.length
+                streamBuffer += newText
+
+                const blocks = streamBuffer.split('\n\n')
+                streamBuffer = blocks.pop() ?? ''
+                blocks.forEach((block) => handleSSEBlock(block, onChunk))
             }
         })
+
+        if (typeof response.data === 'string' && response.data.length > processedLength) {
+            streamBuffer += response.data.slice(processedLength)
+        }
+
+        if (streamBuffer.trim()) {
+            handleSSEBlock(streamBuffer, onChunk)
+        }
+
+        onComplete?.()
     },
     rewrite(assignmentId: number, userId: number) {
         return axiosInstance.post(`${AI_PREFIX}/rewrite`, {}, {

+ 9 - 0
src/apis/assignment.ts

@@ -64,6 +64,15 @@ const assignmentApis = {
                 assignmentId
             }
         })
+    },
+    downloadClassAssignmentDocument: (classId: number, assignmentId: number) => {
+        return axiosInstance.get(`/api/export/class_assignment/download`, {
+            params: {
+                classId,
+                assignmentId
+            },
+            responseType: 'blob'
+        })
     }
 
 }

+ 6 - 1
src/apis/axios.config.ts

@@ -2,13 +2,18 @@ import axios from "axios";
 
 // !!!本地开发不要直接修改baseURL,请看 .env.example中的配置说明!!!
 const BASEURL = import.meta.env.VITE_BASE_URL;
+const REQUEST_TIMEOUT =
+    Number(import.meta.env.VITE_REQUEST_TIMEOUT) > 0
+        ? Number(import.meta.env.VITE_REQUEST_TIMEOUT)
+        : 30_000;
 
 const axiosInstance = axios.create({
     baseURL: BASEURL,
     headers: {
         'Content-Type': 'application/json',
     },
-    timeout: 10000, 
+    // 10s 在访问远程/冷库 / Mongo 拉 AI 对话时易 ECONNABORTED;编辑页会并发多接口
+    timeout: REQUEST_TIMEOUT,
 })
 //请求拦截器
 axiosInstance.interceptors.request.use(

+ 17 - 0
src/apis/behavior.ts

@@ -71,6 +71,23 @@ const bevaviorApis = {
             }
         })
     },
+    downLoadAllBehaviorDataByClass:(classId :number,assignmentId :number)=>{
+        return axiosInstance.get(`/api/export/behaviorOverview/excel/class/assignment`, {
+            responseType: 'blob',
+            params: {
+                classId,
+                assignmentId
+            }
+        })
+    },
+    reportWindowSwitch:(studentId: number, assignmentId: number, switchCount: number, switchEvents: { eventType: 'hidden' | 'visible'; timestamp: string }[])=>{
+        return axiosInstance.post(`${PREFIX}/windowSwitch`, {
+            studentId,
+            assignmentId,
+            switchCount,
+            switchEvents
+        })
+    },
 }
 
 export default bevaviorApis;

+ 9 - 0
src/apis/class.ts

@@ -35,6 +35,15 @@ const classApis = {
             }
         })
     },
+    importStudentsOfClass:(classId:number,file:File) => {
+        const formData = new FormData();
+        formData.append("file", file);
+        return axiosInstance.post(ENGAGE_PREFIX+`/${classId}/students/batch`, formData, {
+            headers: {
+                'Content-Type': 'multipart/form-data'
+            }
+        })
+    },
     deleteStudentOfClass:(classId:number,officialNumbers) => {
         return axiosInstance.delete(ENGAGE_PREFIX+`/${classId}/students/batch`,{
             headers:{

+ 16 - 0
src/apis/engagement.ts

@@ -18,6 +18,14 @@ const engagementApis = {
             }
         })
     },
+    getEngagementHistory: (studentId: number, assignmentId: number) => {
+        return axiosInstance.get(`${ENGAGE_PREFIX}/history`, {
+            params: {
+                studentId,
+                assignmentId
+            }
+        })
+    },
     getDetailEngagement: (studentId: number, assignmentId: number) => {
         return axiosInstance.get(ENGAGE_PREFIX+"/detail", {
             params: {
@@ -82,6 +90,14 @@ const engagementApis = {
                 assignmentId
             }
         })
+    },
+    getExamTime: (studentId: number, assignmentId: number) => {
+        return axiosInstance.get(`${ENGAGE_PREFIX}/exam-time`, {
+            params: {
+                studentId,
+                assignmentId
+            }
+        })
     }
 }
 

+ 12 - 2
src/apis/evaluation.ts

@@ -8,7 +8,8 @@ const evaluationApis = {
             params: {
                 assignmentId,
                 studentId
-            }
+            },
+            timeout: 40000 // 设置超时时间为 40 秒
         })
     },
     sentenceEvaluation(assignmentId: number, studentId: number){
@@ -16,7 +17,8 @@ const evaluationApis = {
             params: {
                 assignmentId,
                 studentId
-            }
+            },
+            timeout: 40000 // 设置超时时间为 40 秒
         })
     },
     getOverall(assignmentId: number, studentId: number, version: number){
@@ -45,6 +47,14 @@ const evaluationApis = {
             }
         })
     },
+    getCurrentVersionEvaluation(assignmentId: number, studentId: number){
+        return axiosInstance.get(`${EVALUATION_PREFIX}/current`, {
+            params: {
+                assignmentId,
+                studentId
+            }
+        })
+    },
 }
 
 export default evaluationApis

+ 328 - 15
src/components/AIChatter/AIChatter.module.scss

@@ -27,18 +27,174 @@
     width: 100%;
     height: 80%;
     overflow-y: auto;
+    padding: 10px 0;
 
-    .item {
+    .message-group {
+      margin-bottom: 20px;
+    }
+
+    // 用户消息样式
+    .user-message {
+      display: flex;
+      justify-content: flex-end;
+      align-items: flex-start;
+      gap: 8px;
+
+      .message-info {
+        display: flex;
+        flex-direction: column;
+        align-items: flex-end;
+        max-width: 70%;
+      }
+
+      .user-name {
+        font-size: 12px;
+        color: #666;
+        margin-bottom: 4px;
+        font-weight: 500;
+      }
+
+      .message-bubble {
+        padding: 12px 16px;        
+        word-break: break-word;
+        line-height: 1.5;
+      }
+
+      .user-bubble {
+        background:linear-gradient(135deg, #4ca1f4, #5cb3f5, #6ec0f6);
+        color: white;
+        border-radius: 20px;
+      }
+
+      .message-avatar {
+        width: 36px;
+        height: 36px;
+        border-radius: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        font-weight: 600;
+        font-size: 14px;
+        flex-shrink: 0;
+      }
+
+      .user-avatar {
+        background: linear-gradient(135deg, #4ca1f4, #5cb3f5, #6ec0f6);
+        color: white;
+      }
+    }
+
+    // AI消息样式
+    .ai-message {
       display: flex;
+      justify-content: flex-start;
       align-items: flex-start;
-      margin-bottom: 15px;
+      gap: 12px;
+      margin-bottom: 16px;
+
+      .message-info {
+        display: flex;
+        flex-direction: column;
+        align-items: flex-start;
+        max-width: 70%;
+      }
+
+      .ai-name {
+        font-size: 12px;
+        color: #666;
+        margin-bottom: 4px;
+        font-weight: 500;
+      }
 
-      .content {
-        background-color: #f0f0f0;
-        padding: 8px 12px;
-        border-radius: 16px;
+      .message-bubble {
+        padding: 12px 16px;
+        border-radius: 12px;
         word-break: break-word;
-        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+        line-height: 1.5;
+      }
+
+      .ai-bubble {
+        background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
+        color: #333;
+        border-radius: 20px;
+
+        :global(p) {
+          margin: 0 0 8px;
+        }
+
+        :global(p:last-child) {
+          margin-bottom: 0;
+        }
+
+        :global(ul),
+        :global(ol) {
+          margin: 8px 0;
+          padding-left: 20px;
+        }
+
+        :global(li) {
+          margin: 4px 0;
+        }
+
+        :global(h1),
+        :global(h2),
+        :global(h3),
+        :global(h4),
+        :global(h5),
+        :global(h6) {
+          margin: 8px 0;
+          line-height: 1.4;
+        }
+
+        :global(code) {
+          padding: 2px 6px;
+          border-radius: 6px;
+          background-color: rgba(255, 255, 255, 0.65);
+          font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+          font-size: 0.9em;
+        }
+
+        :global(pre) {
+          overflow-x: auto;
+          margin: 8px 0;
+          padding: 10px 12px;
+          border-radius: 10px;
+          background-color: rgba(255, 255, 255, 0.7);
+        }
+
+        :global(pre code) {
+          padding: 0;
+          background: transparent;
+        }
+
+        :global(blockquote) {
+          margin: 8px 0;
+          padding-left: 12px;
+          border-left: 3px solid rgba(0, 0, 0, 0.18);
+          color: #555;
+        }
+
+        :global(a) {
+          color: #2563eb;
+          text-decoration: underline;
+        }
+      }
+
+      .message-avatar {
+        width: 36px;
+        height: 36px;
+        border-radius: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        font-weight: 600;
+        font-size: 14px;
+        flex-shrink: 0;
+      }
+
+      .ai-avatar {
+        background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
+        color: #333;
       }
     }
   }
@@ -80,23 +236,180 @@
   background-color: #ffffff;
   border-radius: 10px;
 
-
   .message-box {
     width: 100%;
     height: 34vh;
     overflow: auto;
+    padding: 10px 0;
 
-    .item {
+    .message-group {
+      margin-bottom: 20px;
+    }
+
+    // 用户消息样式(教师视图)
+    .user-message {
       display: flex;
+      justify-content: flex-end;
       align-items: flex-start;
-      margin-bottom: 15px;
+      gap: 12px;
+      margin-bottom: 16px;
+
+      .message-info {
+        display: flex;
+        flex-direction: column;
+        align-items: flex-end;
+        max-width: 70%;
+      }
+
+      .user-name {
+        font-size: 12px;
+        color: #666;
+        margin-bottom: 4px;
+        font-weight: 500;
+      }
 
-      .content {
-        background-color: #f0f0f0;
-        padding: 8px 12px;
-        border-radius: 16px;
+      .message-bubble {
+        padding: 12px 16px;
+        border-radius: 12px;
         word-break: break-word;
-        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+        line-height: 1.5;
+      }
+
+      .user-bubble {
+        background: #5cb3f5;
+        color: white;
+        border-bottom-right-radius: 4px;
+      }
+
+      .message-avatar {
+        width: 36px;
+        height: 36px;
+        border-radius: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        font-weight: 600;
+        font-size: 14px;
+        flex-shrink: 0;
+      }
+
+      .user-avatar {
+        background: #5cb3f5;
+        color: white;
+      }
+    }
+
+    // AI消息样式(教师视图)
+    .ai-message {
+      display: flex;
+      justify-content: flex-start;
+      align-items: flex-start;
+      gap: 12px;
+      margin-bottom: 16px;
+
+      .message-info {
+        display: flex;
+        flex-direction: column;
+        align-items: flex-start;
+        max-width: 70%;
+      }
+
+      .ai-name {
+        font-size: 12px;
+        color: #666;
+        margin-bottom: 4px;
+        font-weight: 500;
+      }
+
+      .message-bubble {
+        padding: 12px 16px;
+        border-radius: 12px;
+        word-break: break-word;
+        line-height: 1.5;
+      }
+
+      .ai-bubble {
+        background-color: #f0f2f5;
+        color: #333;
+        border-bottom-left-radius: 4px;
+
+        :global(p) {
+          margin: 0 0 8px;
+        }
+
+        :global(p:last-child) {
+          margin-bottom: 0;
+        }
+
+        :global(ul),
+        :global(ol) {
+          margin: 8px 0;
+          padding-left: 20px;
+        }
+
+        :global(li) {
+          margin: 4px 0;
+        }
+
+        :global(h1),
+        :global(h2),
+        :global(h3),
+        :global(h4),
+        :global(h5),
+        :global(h6) {
+          margin: 8px 0;
+          line-height: 1.4;
+        }
+
+        :global(code) {
+          padding: 2px 6px;
+          border-radius: 6px;
+          background-color: rgba(255, 255, 255, 0.9);
+          font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+          font-size: 0.9em;
+        }
+
+        :global(pre) {
+          overflow-x: auto;
+          margin: 8px 0;
+          padding: 10px 12px;
+          border-radius: 10px;
+          background-color: rgba(255, 255, 255, 0.95);
+        }
+
+        :global(pre code) {
+          padding: 0;
+          background: transparent;
+        }
+
+        :global(blockquote) {
+          margin: 8px 0;
+          padding-left: 12px;
+          border-left: 3px solid rgba(0, 0, 0, 0.18);
+          color: #555;
+        }
+
+        :global(a) {
+          color: #2563eb;
+          text-decoration: underline;
+        }
+      }
+
+      .message-avatar {
+        width: 36px;
+        height: 36px;
+        border-radius: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        font-weight: 600;
+        font-size: 14px;
+        flex-shrink: 0;
+      }
+
+      .ai-avatar {
+        background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
+        color: #333;
       }
     }
   }

+ 119 - 57
src/components/AIChatter/AIChatter.vue

@@ -16,7 +16,7 @@
           </span>
             <template #dropdown>
               <el-dropdown-menu>
-                <el-dropdown-item v-for="model in modelOptions" :command="model">{{ model }}</el-dropdown-item>
+                <el-dropdown-item v-for="model in modelOptions" :key="model" :command="model">{{ model }}</el-dropdown-item>
               </el-dropdown-menu>
             </template>
           </el-dropdown>
@@ -24,28 +24,39 @@
       </div>
      </div>
 
-    <!-- 消息区域,采用ChatGPT的交互模式 -->
+    <!-- 消息区域 -->
     <div :class="$style['message-box']" ref="containerTopRef">
       <div
           v-for="(item, index) in messageList"
-          :key="index"
-          :class="$style.item"
+          :key="`${item?.role}-${index}`"
+          :class="$style['message-group']"
       >
-        <div>
-          <!--展示头像-->
-          <el-avatar
-              :size="32"
-              :shape="'circle'"
-              :src="index % 2 === 0 ? store.user.userAvatar : ''"
-          >
-            {{ index % 2 === 0 ? store.user.name.charAt(0) : data.model.charAt(0) }}
-          </el-avatar>
+        <!-- 用户消息 -->
+        <div v-if="item?.role === 'user'" :class="$style['user-message']">
+          <div :class="$style['message-info']">
+            <div :class="$style['user-name']">{{ props.isTeacher ? 'Student' : 'You' }}</div>
+            <div :class="[$style['message-bubble'], $style['user-bubble']]">
+              {{ item?.content }}
+            </div>
+          </div>
+          <div :class="[$style['message-avatar'], $style['user-avatar']]">
+            <span>{{ props.isTeacher ? 'S' : (store.user.name.charAt(0) || 'Y') }}</span>
+          </div>
         </div>
-        <div style="margin-left: 15px">
-          <!--名字-->
-          <div style="margin-bottom: 2px">{{ index % 2 === 0 ? 'You' : data.model }}</div>
-          <!--内容-->
-          <div :class="$style.content">{{ item?.content }}</div>
+        
+        <!-- AI消息 -->
+        <div v-if="item?.role === 'assistant'" :class="$style['ai-message']">
+          <div :class="[$style['message-avatar'], $style['ai-avatar']]">
+            <span>{{ data.model.charAt(0) }}</span>
+          </div>
+          <div :class="$style['message-info']">
+            <div :class="$style['ai-name']">{{ data.model }}</div>
+            <div
+              :class="[$style['message-bubble'], $style['ai-bubble']]"
+              v-html="renderMarkdown(item?.content || '')"
+            >
+            </div>
+          </div>
         </div>
       </div>
       <div ref="messagesEndRef" />
@@ -97,14 +108,16 @@
 
 <script setup lang="ts">
   import "./AIChatter.module.scss";
-  import type {IDialogue} from "@/types/dialogue.ts";
+  import type {IDialogue} from "@/types/dialogue";
   import {useStore} from "@/store";
   import useApis from "@/apis";
-  import {onMounted, reactive, ref, defineProps, watch, onUnmounted, nextTick} from "vue";
+  import {onMounted, reactive, ref, watch, onUnmounted, nextTick} from "vue";
   import {ArrowDown, Top} from '@element-plus/icons-vue'
   import {ElMessage} from "element-plus";
   import emitter from "@/utils/emitter";
-  import dayjs from "dayjs";
+  import * as dayjsModule from "dayjs";
+  import { marked } from "marked";
+  import * as DOMPurifyModule from "dompurify";
 
   // const svg = `
   //       <path class="path" d="
@@ -137,20 +150,36 @@
 </svg>
 `;
 //update: 加入isTeacher属性,用于判断是否是教师批改界面
-  interface IProps {
-    dialogueId: string;
-    messages: IDialogue[]
-    isTeacher: boolean
-  }
-
-  const props = defineProps<{
+  const props = withDefaults(defineProps<{
     dialogueId: string,
     messages: IDialogue[]
     isTeacher: boolean
-  }>()
+    /** 与 userId 一起传入,供流式在 dialogueId 与 Mongo 不一致时后端补建/纠偏 */
+    assignmentId?: number
+    userId?: number
+  }>(), {
+    isTeacher: false
+  })
 
   const apis = useApis()
   const store = useStore()
+  const dayjs = (
+    (dayjsModule as unknown as { default?: (date?: Date) => { format: (template: string) => string } }).default
+    ?? dayjsModule
+  ) as (date?: Date) => { format: (template: string) => string }
+  const sanitizeHtml = (dirty: string) => {
+    const purifierModule = DOMPurifyModule as unknown as {
+      default?: { sanitize: (input: string) => string },
+      sanitize?: (input: string) => string
+    }
+    const purifier = purifierModule.default ?? purifierModule
+    return purifier.sanitize(dirty)
+  }
+
+  marked.setOptions({
+    gfm: true,
+    breaks: true
+  })
   //UPDATE: 目前只有ChatGLM4
   const modelOptions = ["ChatGLM4"]
   const questionTemplates = [
@@ -166,7 +195,7 @@
   const containerTopRef = ref(null);
 
   // 用于处理ai对话展示异常问题
-  let messageList:IDialogue[] = []
+  const messageList = ref<IDialogue[]>([])
 
   let data = reactive<{
     model: string;
@@ -187,15 +216,14 @@
   // 加载聊天记录,并滚动至最后
   onMounted(() => {
     if (props.messages) data.messages = props.messages;
-    // console.log(props.messages)
+    handleMessageList()
     nextTick(scrollToEnd);
   });
 
   const scrollToEnd = () => {
-    // messagesEndRef.value?.scrollIntoView({ behavior: 'smooth' });
-    let container = containerTopRef.value
-    container.scrollTo({ top: container.scrollHeight-1, behavior: 'smooth' });
-
+    const container = containerTopRef.value as HTMLDivElement | null
+    if (!container) return
+    container.scrollTo({ top: container.scrollHeight - 1, behavior: 'smooth' });
   };
 
   watch(() => props.messages, (newMessages:any) => {
@@ -231,7 +259,9 @@
   });
 
   // 发送问题
-  const handleSendQuestion = () => {
+  const handleSendQuestion = async () => {
+    if (data.loading || !data.questionContent.trim()) return
+
     // 触发行为记录
     emitter.emit("click-ai-send-button", {
       insert: data.questionContent,
@@ -244,37 +274,67 @@
       AICount.value++
     } else {
       ElMessage.warning("单次只能使用10次对话功能")
-      AICount.value++
+      // AICount.value++
       return
     }
     data.loading = true;
 
-    let requestBody:IDialogue[] = data.messages
-    requestBody.push({
+    const questionContent = data.questionContent.trim()
+    const userMessage: IDialogue = {
       role: 'user',
-      content: data.questionContent
-    })
+      content: questionContent
+    }
+    const assistantMessage: IDialogue = {
+      role: 'assistant',
+      content: ''
+    }
+    const requestBody: IDialogue[] = [...data.messages, userMessage]
 
-    apis.requestAI(props.dialogueId, data.model, requestBody)
-        .then((_res:any) => {
-          const resDialogue = _res.data.data;
-          data.messages.push(resDialogue);
+    data.messages.push(userMessage)
+    data.messages.push(assistantMessage)
+    data.questionContent = ''
+    handleMessageList()
+    nextTick(scrollToEnd)
+
+    try {
+      await apis.requestAIStream(props.dialogueId, data.model, requestBody, {
+        onChunk: (chunk: string) => {
+          assistantMessage.content += chunk
           handleMessageList()
-        })
-        .catch((_err:any) => {
-          console.log(_err);
-        })
-        .finally(() => {
-          data.questionContent = '';
-          data.loading = false;
-          nextTick(scrollToEnd);
-        });
+          nextTick(scrollToEnd)
+        },
+        assignmentId: props.assignmentId,
+        userId: props.userId
+      })
+    } catch (_err:any) {
+      console.log(_err);
+      if (!assistantMessage.content) {
+        data.messages.pop()
+      } else {
+        assistantMessage.content += '\n\n[请求中断]'
+      }
+      ElMessage.error('AI 请求失败,请稍后重试')
+      handleMessageList()
+    } finally {
+      data.loading = false;
+      nextTick(scrollToEnd);
+    }
   };
 
   const handleCommand = (command:string) => {
     data.model = command
   }
 
+  const normalizeMarkdown = (content: string) => {
+    return content.replace(/^(#{1,6})(\S)/gm, '$1 $2')
+  }
+
+  const renderMarkdown = (content: string) => {
+    const normalizedContent = normalizeMarkdown(content ?? '')
+    const html = marked.parse(normalizedContent)
+    return sanitizeHtml(typeof html === 'string' ? html : '')
+  }
+
   // 选择模板问题
   const handleMenuClick = (key:any) => {
     data.questionContent = questionTemplates[key];
@@ -283,6 +343,7 @@
 
   // 处理ai对话显示异常问题
   const handleMessageList = () => {
+    const nextMessageList: IDialogue[] = []
     // console.log(data.messages)
     let count1 = 0
     let count2 = 1
@@ -303,7 +364,7 @@
     if(userNum > AssistantNum) count2 = count2 + 2 * (userNum - AssistantNum)
     for(let message of data.messages){
       if(message.role == "user"){
-        messageList[count1] = {
+        nextMessageList[count1] = {
           role: "user",
           content: message.content
         }
@@ -311,13 +372,13 @@
       }
       if(message.role == "assistant"){
         if(userNum == AssistantNum){
-          messageList[count2] = {
+          nextMessageList[count2] = {
             role: "assistant",
             content: message.content
           }
           count2+=2
         } else {
-          messageList[count2] = {
+          nextMessageList[count2] = {
             role: "assistant",
             content: message.content
           }
@@ -325,6 +386,7 @@
         }
       }
     }
+    messageList.value = nextMessageList
     // console.log(messageList)
   }
 

+ 41 - 35
src/components/QuillEditor/Editor.vue

@@ -35,7 +35,6 @@ import {ElMessage} from "element-plus";
         toolbar: [
           ['bold', 'italic', 'underline', 'strike'],
           [{ 'size': ['small', false, 'large', 'huge'] }],
-          [{ 'font': [] }],
           [{ 'align': [] }],
           [{ 'list': 'ordered' }, { 'list': 'bullet' }],
           [{ 'indent': '-1' }, { 'indent': '+1' }],
@@ -87,44 +86,51 @@ import {ElMessage} from "element-plus";
     // 文本变化监控
     quill.on(Quill.events.TEXT_CHANGE, (...args) => {
       const [delta, oldDelta, source] = args;
-      const deleteOps = delta.ops.filter(op => 'delete' in op);
-      // 直接将 ops 数组中的属性合并到一个对象
-      const opsObj = {};
+      if (source === 'user') {
+        emitter.emit('is-modified', true)
+      }
+      const oldContent = processOps(args["1"].ops);
+      const docLength = quill.getLength() - 1;
+      const letterCount = (quill.getText().match(/[a-zA-Z\d\u4e00-\u9fa5]/g) || []).length;
+      let retain = 0;
+
       delta.ops.forEach(op => {
-        Object.assign(opsObj, op);
+        if ('retain' in op) {
+          retain += op.retain;
+          return;
+        }
+
+        if ('delete' in op) {
+          const deleteLength = op.delete;
+          const deletedContent = oldContent.slice(retain, retain + deleteLength);
+
+          emitter.emit('change-record', {
+            type: "delete",
+            retain,
+            delete: deleteLength,
+            oldContent,
+            deletedContent,
+            docLength,
+            letterCount
+          })
+          return;
+        }
+
+        if ('insert' in op) {
+          const insertedContent = typeof op.insert === 'string' ? op.insert : '';
+
+          emitter.emit('change-record', {
+            type: "insert",
+            retain,
+            insert: op.insert,
+            docLength,
+            letterCount,
+            key: insertedContent || null
+          })
+        }
       });
-      delete opsObj.attributes;
-
-      if(deleteOps.length > 0){ //删除操作
-        // 获取删除操作的位置和长度
-        const deleteStart = delta.ops.find(op => 'retain' in op).retain || 0;   // 删除起始位置
-        const deleteLength = delta.ops.find(op => 'delete' in op).delete;       // 删除长度
-        const oldContent = processOps(args["1"].ops)// 原内容
-        // 提取被删除的具体内容
-        const deletedContent = oldContent.slice(deleteStart, deleteStart + deleteLength);
-
-        emitter.emit('change-record', {
-          type: "delete",
-          retain: 0,
-          ...opsObj,
-          oldContent,
-          deletedContent,
-          docLength: quill.getLength() - 1,
-          letterCount: (quill.getText().match(/[a-zA-Z\d\u4e00-\u9fa5]/g) || []).length
-        })
-      } else {
-        emitter.emit('change-record', {
-          type: "insert",
-          retain: 0,
-          ...opsObj,
-          docLength: quill.getLength() - 1,
-          letterCount: (quill.getText().match(/[a-zA-Z\d\u4e00-\u9fa5]/g) || []).length,
-          key: args["0"].ops.find(op => 'insert' in op).insert || null
-        })
-      }
     });
 
-    // 光标变化监控
     quill.on(Quill.events.SELECTION_CHANGE, (...args) => {
       emitter.emit('change-record', {
         type: "cursor_move",

+ 34 - 11
src/components/QuillEditor/QuillEditor.vue

@@ -51,15 +51,35 @@
     changeRecordList.push(event)
   });
 
+const normalizeKey = (event: KeyboardEvent) => {
+  let key = event.key
+
+  // Shift + 数字 → 符号(包含 0 -> ))
+  const shiftNumMap: Record<string, string> = {
+    '1': '!', '2': '@', '3': '#', '4': '$', '5': '%',
+    '6': '^', '7': '&', '8': '*', '9': '(', '0': ')'
+  }
+  if (event.shiftKey && shiftNumMap[key]) {
+    return shiftNumMap[key]
+  }
+
+  // Shift + 字母 → 大写
+  if (event.shiftKey && /^[a-z]$/i.test(key)) {
+    return key.toUpperCase()
+  }
+
+  return key
+}
+
   const handleKeydown = (event: KeyboardEvent) => {
     // 增加对Shift组合键的识别
     // 在handleKeyup中增加对组合键的识别
-    let key = event.key
-    if (event.shiftKey && /^[0-9]$/.test(key)) {
-      key = "!@#$%^&*()"[parseInt(key) - 1]; // 映射Shift+数字到符号
-    } else if(event.shiftKey && /^[a-z]$/i.test(event.key) ){
-      key = event.key.toUpperCase()
-    }
+    let key = normalizeKey(event)
+    // if (event.shiftKey && /^[0-9]$/.test(key)) {
+    //   key = "!@#$%^&*()"[parseInt(key) - 1]; // 映射Shift+数字到符号
+    // } else if(event.shiftKey && /^[a-z]$/i.test(event.key) ){
+    //   key = event.key.toUpperCase()
+    // }
 
     // 存储down事件信息到keyEventMap中
     if (!keyEventMap[key]) {
@@ -107,9 +127,12 @@
     return result
   }
 
+const isCtrlOrMeta = (event: KeyboardEvent) => event.ctrlKey === true || event.metaKey === true
+
+
   const handleKeyup = (event: KeyboardEvent) => {
     const realTime = dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss.SSS")
-    const key = event.key;
+    const key = normalizeKey(event);
     // 处理所有与该按键key有关的keydown行为
     while (keyEventMap[key] && keyEventMap[key].length > 0) {
       // 取得keydown事件信息
@@ -151,8 +174,8 @@
           }
 
         }
-        // 粘贴
-        else if ((key === 'c' || key === 'C') && event.ctrlKey === true) {
+        // 复制
+        else if ((key === 'c' || key === 'C') && isCtrlOrMeta(event)) {
           changeRecordList[i] = {
             ...changeRecordList[i],
             ...combinedEvent,
@@ -161,8 +184,8 @@
           }
           break
         }
-        // 复制
-        else if ((key === 'v' || key === 'V') && event.ctrlKey === true) {
+        // 粘贴
+        else if ((key === 'v' || key === 'V') && isCtrlOrMeta(event)) {
           changeRecordList[i] = {
             ...changeRecordList[i],
             ...combinedEvent,

+ 2 - 2
src/components/ViewsWordEditor/ViewsWordEditor.vue

@@ -28,8 +28,8 @@
 
       <template #footer>
         <div class="button-right">
-          <el-button color="#597d3b" @click="handleClose">取消</el-button>
-          <el-button color="#597d3b" type="primary" @click="handleClose">OK</el-button>
+          <el-button style="background-color: #6A9BD8; color: white;" @click="handleClose">取消</el-button>
+          <el-button style="background-color: #6A9BD8; color: white;" type="primary" @click="handleClose">OK</el-button>
         </div>
       </template>
     </el-dialog>

+ 81 - 0
src/hooks/useExamCountdown.ts

@@ -0,0 +1,81 @@
+import { ref, computed, onUnmounted } from 'vue';
+
+interface UseExamCountdownOptions {
+  startTime: Date;
+  duration: number;
+  initialRemainingSeconds?: number;
+  onAutoSubmit: () => void;
+  onWarning: () => void;
+}
+
+export function useExamCountdown(options: UseExamCountdownOptions) {
+  const { startTime, duration, initialRemainingSeconds, onAutoSubmit, onWarning } = options;
+
+  const hasValidTime = initialRemainingSeconds !== undefined && initialRemainingSeconds > 0;
+
+  const endTime = hasValidTime
+    ? new Date(Date.now() + initialRemainingSeconds * 1000)
+    : new Date(startTime.getTime() + duration * 60 * 1000);
+
+  const remainingSeconds = ref(hasValidTime ? initialRemainingSeconds : duration * 60);
+  const isWarning = ref(false);
+  const isExpired = ref(false);
+  let intervalId: number | null = null;
+  let warningTriggered = false;
+
+  const formattedTime = computed(() => {
+    const hours = Math.floor(remainingSeconds.value / 3600);
+    const minutes = Math.floor((remainingSeconds.value % 3600) / 60);
+    const seconds = remainingSeconds.value % 60;
+    return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
+  });
+
+  const tick = () => {
+    const now = new Date();
+    const diff = Math.floor((endTime.getTime() - now.getTime()) / 1000);
+
+    if (diff <= 0) {
+      remainingSeconds.value = 0;
+      isExpired.value = true;
+      isWarning.value = false;
+      stop();
+      onAutoSubmit();
+      return;
+    }
+
+    remainingSeconds.value = diff;
+
+    if (diff <= 300 && !warningTriggered) {
+      isWarning.value = true;
+      warningTriggered = true;
+      onWarning();
+    }
+  };
+
+  const start = () => {
+    tick();
+    intervalId = window.setInterval(tick, 1000);
+  };
+
+  const stop = () => {
+    if (intervalId !== null) {
+      clearInterval(intervalId);
+      intervalId = null;
+    }
+  };
+
+  start();
+
+  onUnmounted(() => {
+    stop();
+  });
+
+  return {
+    remainingSeconds,
+    isWarning,
+    isExpired,
+    formattedTime,
+    start,
+    stop
+  };
+}

+ 19 - 3
src/layout/Header/Header.vue

@@ -10,14 +10,14 @@
     <div class="header-menu">
       <el-menu mode="horizontal"
                router
-               :default-active="useRoute().path" @select="handleSelect" :ellipsis="false" class="menu">
+               :default-active="useRoute().path" @select="handleSelect" :ellipsis="false" class="menu" :class="{ 'exam-mode-disabled': examModeActive }">
         <template v-if="role == 'student'">
-          <el-menu-item v-for="item in studentMenuItems" :key="item.key" :index="item.path" class="header-menu-item">
+          <el-menu-item v-for="item in studentMenuItems" :key="item.key" :index="item.path" class="header-menu-item" :disabled="examModeActive" @click="handleMenuClick">
             <div>{{ item.value }}</div>
           </el-menu-item>
         </template>
         <template v-else>
-          <el-menu-item v-for="item in teacherMenuItems" :key="item.key" :index="item.path" class="header-menu-item">
+          <el-menu-item v-for="item in teacherMenuItems" :key="item.key" :index="item.path" class="header-menu-item" :disabled="examModeActive" @click="handleMenuClick">
             <div>{{ item.value }}</div>
           </el-menu-item>
         </template>
@@ -66,6 +66,12 @@ import { useRoute } from "vue-router";
 const store = useStore();
 const role: 'teachers' | 'student' = store.user.role === 'STUDENT' ? 'student' : 'teachers';
 
+const examModeActive = ref(false);
+
+emitter.on('exam-mode-change', (isExamMode: boolean) => {
+  examModeActive.value = isExamMode;
+});
+
 const studentMenuItems = [
   { value: "课程广场", key: "CourseSquare",path:"/home/courseSquare" },
   { value: "我的课程", key: "MyCourse",path:"/home/myCourse" },
@@ -80,6 +86,9 @@ const teacherMenuItems = [
 ];
 
 const handleSelect = (index: string) => {
+  if (examModeActive.value) {
+    return;
+  }
   // emitter.emit('change-page', index);
   router.push(index).then(() => {
     window.location.reload();
@@ -88,6 +97,13 @@ const handleSelect = (index: string) => {
   });
 };
 
+const handleMenuClick = (e: Event) => {
+  if (examModeActive.value) {
+    e.preventDefault();
+    e.stopPropagation();
+  }
+};
+
 const handleCommand = (command: string) => {
   if (command === "logout") {
     store.setUser(null);

+ 10 - 0
src/layout/Header/index.scss

@@ -28,6 +28,11 @@
 
     .menu {
       height: 40px;
+
+      &.exam-mode-disabled {
+        pointer-events: none;
+        opacity: 0.6;
+      }
     }
 
     .header-menu-item {
@@ -48,6 +53,11 @@
         color: #4A90C4 !important;
         border-bottom: 2px solid #4A90C4;
       }
+
+      &.is-disabled {
+        opacity: 0.6;
+        cursor: not-allowed;
+      }
     }
   }
 

+ 2 - 2
src/router/index.ts

@@ -20,7 +20,7 @@ const router = createRouter({
       path: "/",
       redirect: () => {
         window.location.href = "https://p-nju.seec.seecoder.cn/login?from=https://air-nju.seec.seecoder.cn";
-        // window.location.href = "http://localhost:8000/login?from=http://localhost:4000";
+       //  window.location.href = "http://localhost:8000/login?from=http://localhost:4000";
         // {本地门户首页地址}?from={本地eai首页地址}
         return "/"; // 占位返回值,实际不会执行
     },
@@ -260,7 +260,7 @@ const router = createRouter({
  */
   router.beforeEach(async (to) => {
     const store = useStore();
-    const publicPaths = [ '/register', '/passwordChange', '/login', '/admin/login'];
+    const publicPaths = [  '/register', '/passwordChange', '/admin/login' ];
     // 1. 检查URL中的token
     const urlToken = new URLSearchParams(window.location.search).get('token');
     if (urlToken) {

+ 2 - 0
src/types/dto.ts

@@ -8,6 +8,8 @@ export interface AssignmentDTO {
     attachments?: string[]; // 作业附件 (可选)
     startTime: string; // 起始时间不能缺失 (ISO 8601 日期时间字符串)
     endTime: string; // 结束时间不能缺失 (ISO 8601 日期时间字符串)
+    examMode?: boolean;
+    examDuration?: number;
 }
 
 export interface AISpeakingAssignmentDTO {

+ 40 - 9
src/types/vo.ts

@@ -14,6 +14,21 @@ export interface engagementVO {
     "quillContent": string,
     "textContent": string,
     "htmlContent": string,
+    "submitted": boolean,
+}
+
+export interface EngagementHistoryStatusVO {
+    code: number;
+    status: string;
+}
+
+export interface engagementHistoryVO {
+    version: number;
+    status: EngagementHistoryStatusVO;
+    quillContent: string;
+    textContent: string;
+    htmlContent: string;
+    submitTime: string;
 }
 
 export interface engagementDetailVO {
@@ -40,15 +55,15 @@ export interface IAssignment {
 
     type:string;
 
-    descriptionFile: string|null; // 作业要求,Word/Pdf文件
+    descriptionFile: string|null;
 
-    attachments: string[]|null; // 作业附件
+    attachments: string[]|null;
 
-    teacherId: number; // 发布作业的老师的Id
+    teacherId: number;
 
-    teacherName: string; // 发布作业的老师的姓名
+    teacherName: string;
 
-    courseId: number; // 哪个课程下的assignment
+    courseId: number;
 
     startTime: string;
 
@@ -57,6 +72,10 @@ export interface IAssignment {
     createTime: string;
 
     status: string;
+
+    examMode?: boolean;
+
+    examDuration?: number;
 }
 
 export interface AssignmentVO {
@@ -68,13 +87,13 @@ export interface AssignmentVO {
 
     type:string;
 
-    descriptionFile: string|null; // 作业要求,Word/Pdf文件
+    descriptionFile: string|null;
 
-    attachments: string[]|null; // 作业附件
+    attachments: string[]|null;
 
-    teacherId: number; // 发布作业的老师的Id
+    teacherId: number;
 
-    courseId: number; // 哪个课程下的assignment
+    courseId: number;
 
     startTime: string;
 
@@ -83,6 +102,10 @@ export interface AssignmentVO {
     createTime: string;
 
     status: string;
+
+    examMode?: boolean;
+
+    examDuration?: number;
 }
 
 export interface IAssignmentTableColumn {
@@ -172,4 +195,12 @@ export interface ClassStudentVO {
     stateCode: number;
     stateDesc: string;
     studentId: number;
+}
+
+export interface ExamTimeVO {
+    remainingSeconds: number;
+    examStartTime: string;
+    examEndTime: string;
+    examDuration: number;
+    submitted: boolean;
 }

+ 11 - 11
src/utils/indexedDBUtil.ts

@@ -32,7 +32,7 @@ class IndexedDB {
             };
             // 数据库初次创建或更新时会触发
             this._request!.onupgradeneeded = (event) => {
-                let db = this._request!.result;
+                const db = this._request!.result;
                 if (!db.objectStoreNames.contains(this._cacheTableName)) {
                     db.createObjectStore(this._cacheTableName, {
                         keyPath: val,
@@ -52,9 +52,9 @@ class IndexedDB {
 
     async addData(params: any): Promise<Event> {
         return new Promise<Event>((resolve, reject) => {
-            let transaction = this._db!.transaction(this._cacheTableName, "readwrite");
-            let store = transaction.objectStore(this._cacheTableName);
-            let response = store.add(params);
+            const transaction = this._db!.transaction(this._cacheTableName, "readwrite");
+            const store = transaction.objectStore(this._cacheTableName);
+            const response = store.add(params);
             // 操作成功
             response.onsuccess = (event) => {
                 console.log("操作成功");
@@ -70,10 +70,10 @@ class IndexedDB {
 
     async getDataByKey(key: any): Promise<any> {
         return new Promise<any>((resolve, reject) => {
-            let transaction = this._db!.transaction(this._cacheTableName);
-            let objectStore = transaction.objectStore(this._cacheTableName);
+            const transaction = this._db!.transaction(this._cacheTableName);
+            const objectStore = transaction.objectStore(this._cacheTableName);
             // 通过主键读取数据
-            let request = objectStore.get(key);
+            const request = objectStore.get(key);
             // 操作成功
             request.onsuccess = () => {
                 resolve(request.result);
@@ -87,9 +87,9 @@ class IndexedDB {
 
     async clearDB(): Promise<Event> {
         return new Promise<Event>((resolve, reject) => {
-            let transaction = this._db && this._db.transaction(this._cacheTableName, "readwrite");
-            let store = transaction && transaction.objectStore(this._cacheTableName);
-            let response = store && store.clear();
+            const transaction = this._db && this._db.transaction(this._cacheTableName, "readwrite");
+            const store = transaction && transaction.objectStore(this._cacheTableName);
+            const response = store && store.clear();
             // 操作成功
             response.onsuccess = (event) => {
                 console.log("清空数据库数据");
@@ -103,7 +103,7 @@ class IndexedDB {
     }
 
     async deleteDBAll(): Promise<void> {
-        let deleteRequest = indexedDB.deleteDatabase(this._dbName);
+        const deleteRequest = indexedDB.deleteDatabase(this._dbName);
         return new Promise<void>((resolve, reject) => {
             deleteRequest.onerror = function (event) {
                 console.log("删除失败");

+ 794 - 104
src/views/AIEvaluationPage/AIEvaluationPage.vue

@@ -1,21 +1,21 @@
 <template>
-  <div style="display: flex">
+  <div class="ai-evaluation-container">
     <!-- 1.作文展示区域 -->
-    <div style="width: 40%; height: 90vh; margin: 10px; position: relative;">
-      <el-breadcrumb :separator-icon="ArrowRight" style="height: 30px; align-content: center; margin-left: 6px">
+    <div class="essay-section">
+      <el-breadcrumb :separator-icon="ArrowRight" class="breadcrumb">
         <el-breadcrumb-item :to="{ path: `/home/Assignment/${assignmentId}/edit` }">写作</el-breadcrumb-item>
         <el-breadcrumb-item><span style="color: rgba(22,119,255,0.62); font-weight: bold; cursor:pointer;">智能批改</span></el-breadcrumb-item>
       </el-breadcrumb>
 
-      <el-card  shadow="never">
+      <el-card shadow="never" class="essay-card">
         <!-- 原文标识 -->
-        <div style="display: flex;justify-content: center;align-items: center;width: 100px; height: 50px; background-color:var(--system-color); position: absolute; top: 40px; left: 0; clip-path: polygon(0 0, 100% 0, 80% 50%, 100% 100%, 0 100%);">
-          <span style="margin-left: -20px;color: white">原文</span>
+        <div class="original-text-label">
+          <span>原文</span>
         </div>
         <!-- 作文内容展示区域 -->
-        <div style="white-space: pre-wrap; margin-top: 80px; line-height: 30px">
-          <el-scrollbar height="760px">
-          <span style="position: relative;">
+        <div class="essay-content-wrapper">
+          <el-scrollbar class="essay-scrollbar">
+          <span class="essay-content">
             <template v-if="data.sentenceEvaluationList?.length != 0">
               <span v-for="sentenceEvaluation in data.sentenceEvaluationList"
                     :key="sentenceEvaluation.id"
@@ -43,9 +43,9 @@
 
 
     <!-- 2.AI评价区域 -->
-    <div style="width: 40%; margin: 10px; height: 89vh;">
+    <div class="evaluation-section">
       <div class="combined-box">
-        <div style="display: flex">
+        <div class="tab-container">
           <div
               class="trapezoid"
               :class="{ active: selected === 'trapezoid' }"
@@ -63,44 +63,58 @@
         </div>
         <div class="rectangle">
           <template v-if="selected === 'trapezoid'">
-            <el-scrollbar height="820px">
-              <div style="display: flex;align-items: flex-start;margin-bottom: 15px;">
-                <div>
-                  <!--展示头像-->
-                  <!--<el-avatar src="@/image/seecoderLogo.png" :size="32" :shape="'circle'">-->
-                  <!--</el-avatar>-->
-                  <div style="border-radius: 50%; background-color:#fff; width: 40px; height: 40px;display: flex;justify-content: center;align-items: center;">
+            <el-scrollbar height="100%" class="evaluation-scrollbar">
+              <!-- 整体评价加载状态 -->
+              <div v-if="isLoadingOverall" class="loading-container">
+                <el-icon class="is-loading" :size="40" color="#409EFF">
+                  <Loading />
+                </el-icon>
+                <div class="loading-text">AI 正在生成整体评价,请稍候...</div>
+              </div>
+              <div v-else class="overall-evaluation-content">
+                <div class="avatar-container">
+                  <div class="avatar-wrapper">
                     <img src="@/image/seecoderLogo.png" width="32" >
                   </div>
                 </div>
-                <div style="margin-left: 15px">
-                  <!--名字-->
-                  <div style="margin-bottom: 2px">SEEAI英语助手</div>
-                  <!--内容-->
-                  <div style="background-color: #f0f0f0;padding: 8px 12px;border-radius: 16px;white-space: pre-line;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);">
-                    {{ data.overallEvaluation?.evaluation }}
+                <div class="evaluation-message">
+                  <div class="assistant-name">SEEAI英语助手</div>
+                  <div
+                    class="message-content markdown-content"
+                    v-html="renderMarkdown(data.overallEvaluation?.evaluation || '')"
+                  >
                   </div>
                 </div>
               </div>
             </el-scrollbar>
           </template>
           <template v-else>
-              <el-scrollbar height="820px">
-                <div style="display: flex; flex-direction: column;">
+              <el-scrollbar height="100%" class="evaluation-scrollbar">
+                <!-- 逐句评价加载状态 -->
+                <div v-if="isLoadingSentence" class="loading-container">
+                  <el-icon class="is-loading" :size="40" color="#409EFF">
+                    <Loading />
+                  </el-icon>
+                  <div class="loading-text">AI 正在生成逐句评价,请稍候...</div>
+                  <div class="loading-tip">由于需要对每个句子进行详细分析,此过程可能需要较长时间</div>
+                </div>
+                <div v-else class="sentence-evaluation-content">
                   <template v-if="data.sentenceEvaluationList?.length == 0">
-                    暂无逐句评价
+                    <div class="no-evaluation">暂无逐句评价</div>
                   </template>
                   <el-card v-for="sentenceEvaluation in data.sentenceEvaluationList"
+                           v-else
                            :key="sentenceEvaluation.id"
-                           style="cursor: pointer; margin-bottom: 10px; position: relative;" shadow="hover"
+                           class="sentence-card" 
+                           shadow="hover"
                            @mouseenter="highlightSentenceId = sentenceEvaluation.id"
                            @mouseleave="highlightSentenceId = -1"
                   >
-                    <div :style="{display: 'flex',justifyContent: 'center',alignItems: 'center',width: '20px', height: '100%', backgroundColor: getTextDecorationColor(sentenceEvaluation), position: 'absolute', top: '0', left: 'calc(100% - 20px)'}">
+                    <div class="color-indicator" :style="{backgroundColor: getTextDecorationColor(sentenceEvaluation)}">
                     </div>
-                    <div style="padding-right: 30px">
-                      <div style="font-weight: bold; line-height: 30px">{{ sentenceEvaluation.category }}</div>
-                      {{ sentenceEvaluation.evaluation }}
+                    <div class="sentence-content">
+                      <div class="sentence-category">{{ sentenceEvaluation.category }}</div>
+                      <div class="sentence-evaluation">{{ sentenceEvaluation.evaluation }}</div>
                     </div>
                   </el-card>
                 </div>
@@ -111,44 +125,15 @@
     </div>
 
     <!-- 3.历史版本选择区域 -->
-    <el-card
-        style="
-          width: 15%;  /* 固定宽度代替百分比 */
-          height: 90vh;
-          margin: 10px 0 10px auto;  /* 关键:左侧自动边距 */
-          border-left: 1px solid #e4e7ed;
-          border-radius: 0 8px 8px 0;
-          box-shadow: -2px 0 4px rgba(0,0,0,0.05);
-          /*position: sticky;  !* 新增固定定位 *!*/
-          right: 0;
-      "
-        shadow="never"
-    >
-      <div style="
-        position: relative;
-        height: 100%;
-        padding: 20px 10px;
-      ">
+    <el-card class="version-section" shadow="never">
+      <div class="version-container">
         <!-- 版本选择标题 -->
-        <div style="
-          position: absolute;
-          top: 0;
-          left: -20px;
-          width: 80px;
-          height: 40px;
-          background: var(--system-color);
-          clip-path: polygon(0 0, 100% 0, 90% 50%, 100% 100%, 0 100%);
-          color: white;
-          display: flex;
-          align-items: center;
-          padding-left: 12px;
-          font-size: 13px;
-        ">
+        <div class="version-header">
           版本历史
         </div>
 
         <!-- 版本列表 -->
-        <el-scrollbar height="800px" style="margin-top: 50px;">
+        <el-scrollbar height="800px" class="version-scrollbar">
           <div
               v-for="version in data.versionList"
               :key="version.id"
@@ -165,14 +150,7 @@
         </el-scrollbar>
 
         <!-- 当前版本提示 -->
-        <div style="
-          position: absolute;
-          bottom: 10px;
-          left: 10px;
-          right: 10px;
-          font-size: 12px;
-          color: #666;
-        ">
+        <div class="current-version">
           当前选择:v{{ selectedVersion }}
         </div>
       </div>
@@ -181,13 +159,15 @@
 </template>
 
 <script setup lang="ts">
-  import {onMounted, reactive, ref, watch} from "vue";
+  import {onMounted, onUnmounted, reactive, ref, watch} from "vue";
   import useApis from "@/apis";
   import MyComponent from "@/views/AIEvaluationPage/MyComponent.vue";
   import {ElMessage} from "element-plus";
   import {useRoute} from "vue-router";
   import {useStore} from "@/store";
-  import { ArrowRight } from "@element-plus/icons-vue";
+  import { ArrowRight, Loading } from "@element-plus/icons-vue";
+  import { marked } from "marked";
+  import * as DOMPurifyModule from "dompurify";
 
   interface recordVersion {
     id: number,
@@ -210,7 +190,46 @@
     sentenceEvaluationList: [],
     versionList: []
   })
-  const selectedVersion = ref(1.2)
+  const selectedVersion = ref<number>(1)
+  const currentVersion = ref<number | null>(null)
+  const isLoadingSentence = ref(false) // 逐句评价加载状态
+  const isLoadingOverall = ref(false) // 整体评价加载状态
+  let sentenceLoadingTimer: NodeJS.Timeout | null = null // 逐句评价延迟加载计时器
+  let sentencePollingInterval: NodeJS.Timeout | null = null // 逐句评价轮询计时器
+
+  const sanitizeHtml = (dirty: string) => {
+    const purifierModule = DOMPurifyModule as unknown as {
+      default?: { sanitize: (input: string) => string },
+      sanitize?: (input: string) => string
+    }
+    const purifier = purifierModule.default ?? purifierModule
+    return purifier.sanitize(dirty)
+  }
+
+  marked.setOptions({
+    gfm: true,
+    breaks: true
+  })
+
+  const normalizeMarkdown = (content: string) => {
+    return content
+      .split('\n')
+      .map((line) => {
+        const headingMatch = line.match(/^\s*(#{1,6})\s*(.*)$/)
+        if (!headingMatch) {
+          return line
+        }
+        const [, hashes, text] = headingMatch
+        return `${hashes} ${text.trim()}`
+      })
+      .join('\n')
+  }
+
+  const renderMarkdown = (content: string) => {
+    const normalizedContent = normalizeMarkdown(content ?? '')
+    const html = marked.parse(normalizedContent)
+    return sanitizeHtml(typeof html === 'string' ? html : '')
+  }
 
   const toEvaluation = () => {
     apis.overAllEvaluation(assignmentId, store.user.id)
@@ -237,29 +256,133 @@
         })
   }
 
+  // 轮询获取逐句评价(在120秒内每5秒轮询一次)
+  const pollSentenceEvaluation = (version: number) => {
+    let pollCount = 0
+    const maxPolls = 24 // 120秒 / 5秒 = 24次
+    
+    sentencePollingInterval = setInterval(() => {
+      pollCount++
+      
+      // 如果已经轮询了24次(120秒),停止轮询
+      if (pollCount >= maxPolls) {
+        if (sentencePollingInterval) {
+          clearInterval(sentencePollingInterval)
+          sentencePollingInterval = null
+        }
+        isLoadingSentence.value = false
+        return
+      }
+      
+      // 请求逐句评价
+      apis.getSentence(assignmentId, store.user.id, version)
+        .then(res => {
+          if(res.data.code === 200 && res.data.data && res.data.data.length > 0) {
+            // 获取到数据了,停止轮询
+            data.sentenceEvaluationList = res.data.data
+            isLoadingSentence.value = false
+            
+            if (sentencePollingInterval) {
+              clearInterval(sentencePollingInterval)
+              sentencePollingInterval = null
+            }
+            
+            // 检查数据完整性
+            const hasEvaluation = res.data.data.some(item => item.evaluation)
+            if(!hasEvaluation) {
+              console.warn('⚠️ 后端返回的逐句评价数据缺少 evaluation 字段')
+            }
+          }
+        })
+        .catch(err => {
+          console.error('轮询逐句评价失败:', err)
+        })
+    }, 5000) // 每5秒轮询一次
+  }
+
   const getEvaluation = (version: number) => {
     selectedVersion.value = version
+    console.info('[EVAL][FE] getEvaluation start', {
+      assignmentId,
+      userId: store.user?.id,
+      version
+    })
+    
+    // 清除之前的计时器和轮询
+    if (sentenceLoadingTimer) {
+      clearTimeout(sentenceLoadingTimer)
+      sentenceLoadingTimer = null
+    }
+    if (sentencePollingInterval) {
+      clearInterval(sentencePollingInterval)
+      sentencePollingInterval = null
+    }
+    
+    // 开始加载整体评价
+    isLoadingOverall.value = true
     apis.getOverall(assignmentId,store.user.id,version)
         .then(res => {
-          // console.log(res)
-          data.overallEvaluation = res.data.data
+          if (res.data.code === 200) {
+            data.overallEvaluation = res.data.data
+            console.info('[EVAL][FE] overall ok', {
+              version,
+              hasData: !!res.data.data,
+              evalLen: res.data.data?.evaluation?.length ?? -1
+            })
+          } else {
+            data.overallEvaluation = null
+            console.warn('[EVAL][FE] overall non-200', {
+              version,
+              code: res.data.code,
+              msg: res.data.msg
+            })
+            ElMessage.error(res.data.msg || '获取整体评价失败')
+          }
         })
         .catch(err => {
-          console.log(err)
-          data.overallEvaluation = {} as OverallEvaluation
+          console.error('[EVAL][FE] overall request failed', { version, err })
+          data.overallEvaluation = null
+          ElMessage.error('整体评价请求失败')
+        })
+        .finally(() => {
+          isLoadingOverall.value = false
         })
+    
+    // 开始加载逐句评价
+    isLoadingSentence.value = true
     apis.getSentence(assignmentId,store.user.id,version)
         .then(res => {
-          // console.log(res)
           if(res.data.code === 200){
-            data.sentenceEvaluationList = res.data.data
+            const responseData = res.data.data
+            
+            // 如果返回了数据,直接使用
+            if(responseData && responseData.length > 0) {
+              data.sentenceEvaluationList = responseData
+              isLoadingSentence.value = false
+              console.info('[EVAL][FE] sentence ok', { version, count: responseData.length })
+              
+              // 检查数据完整性
+              const hasEvaluation = responseData.some(item => item.evaluation)
+              if(!hasEvaluation) {
+                console.warn('⚠️ 后端返回的逐句评价数据缺少 evaluation 字段,请检查后端 AI 评价生成逻辑')
+              }
+            } else {
+              // 如果返回空数据,开始轮询(每5秒一次,持续120秒)
+              data.sentenceEvaluationList = []
+              pollSentenceEvaluation(version)
+            }
           } else {
+            console.warn('后端返回非200状态码:', res.data.code, res.data.msg)
             data.sentenceEvaluationList = []
+            // 非200状态码也开始轮询
+            pollSentenceEvaluation(version)
           }
         })
         .catch(err => {
-          console.log(err)
+          console.error('逐句评价API请求失败:', err)
           data.sentenceEvaluationList = []
+          // 请求失败也尝试轮询
+          pollSentenceEvaluation(version)
         })
   }
 
@@ -269,9 +392,14 @@
           // 从未批改过
           if(res.data.data.length === 0){
             data.versionList = []
+            if (currentVersion.value != null) {
+              getEvaluation(currentVersion.value)
+            }
           } else {
             data.versionList = res.data.data
-            getEvaluation(res.data.data[0].version)
+            // 优先展示“当前最新提交版本”,否则回落到列表第一项(最新批改版本)
+            const prefer = currentVersion.value ?? res.data.data[0].version
+            getEvaluation(prefer)
           }
         })
         .catch(err => {
@@ -279,6 +407,19 @@
         })
   }
 
+  const fetchCurrentVersion = async () => {
+    try {
+      const res = await apis.getCurrentVersionEvaluation(assignmentId, store.user.id)
+      if (res.data.code === 200 && res.data.data?.version) {
+        currentVersion.value = res.data.data.version
+        selectedVersion.value = res.data.data.version
+      }
+    } catch (e) {
+      // 后端未部署 current 接口/用户未参与等情况,继续走历史列表逻辑
+      console.warn('获取当前版本失败:', e)
+    }
+  }
+
   const getBackgroundColor = (sentenceEvaluation: SentenceEvaluation) => {
     if (sentenceEvaluation.id === highlightSentenceId.value) {
       switch (sentenceEvaluation.type) {
@@ -314,7 +455,21 @@
   onMounted(() => {
     // toEvaluation()
     // getEvaluation()
-    getEvaluationVersionList()
+    fetchCurrentVersion().finally(() => {
+      getEvaluationVersionList()
+    })
+  })
+
+  // 组件卸载时清理定时器
+  onUnmounted(() => {
+    if (sentenceLoadingTimer) {
+      clearTimeout(sentenceLoadingTimer)
+      sentenceLoadingTimer = null
+    }
+    if (sentencePollingInterval) {
+      clearInterval(sentencePollingInterval)
+      sentencePollingInterval = null
+    }
   })
 </script>
 
@@ -325,34 +480,241 @@ export default {
 </script>
 
 <style scoped>
+/* 主容器 - 响应式布局 */
+.ai-evaluation-container {
+  display: flex;
+  align-items: stretch; /* 让所有子元素高度一致 */
+  gap: 10px;
+  padding: 10px;
+  height: calc(100vh - 80px); /* 使用视口高度减去顶部导航栏等空间 */
+  box-sizing: border-box;
+}
+
+/* 作文展示区域 */
+.essay-section {
+  flex: 0 0 32%;
+  min-width: 300px;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+}
+
+.breadcrumb {
+  height: 30px;
+  align-content: center;
+  margin-left: 6px;
+  margin-bottom: 10px;
+}
+
+.essay-card {
+  flex: 1;
+  position: relative;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.essay-card :deep(.el-card__body) {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.original-text-label {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 100px;
+  height: 50px;
+  background-color: #87CEEB;
+  position: absolute;
+  top: 0;
+  left: 0;
+  clip-path: polygon(0 0, 100% 0, 80% 50%, 100% 100%, 0 100%);
+  z-index: 10;
+}
+
+.original-text-label span {
+  margin-left: -20px;
+  color: white;
+  font-weight: 500;
+}
+
+.essay-content-wrapper {
+  white-space: pre-wrap;
+  margin-top: 40px;
+  line-height: 30px;
+  flex: 1; /* 自动填充剩余空间 */
+  overflow: hidden;
+  min-height: 0; /* 关键:允许flex子元素收缩并显示滚动条 */
+}
+
+.essay-scrollbar {
+  height: 100%;
+  padding-right: 10px; /* 滚动条右移 */
+}
+
+/* 确保 el-scrollbar 内部元素正确显示 */
+.essay-scrollbar :deep(.el-scrollbar__wrap) {
+  max-height: 100%;
+}
+
+.essay-scrollbar :deep(.el-scrollbar__view) {
+  display: block;
+}
+
+.essay-content {
+  position: relative;
+  display: block;
+  padding-right: 15px; /* 确保内容不被滚动条遮挡 */
+  padding-bottom: 20px; /* 确保最后一行不被截断 */
+  white-space: pre-wrap; /* 保留换行符并自动换行 */
+  word-wrap: break-word; /* 长单词换行 */
+  overflow-wrap: break-word; /* 确保内容不会溢出 */
+}
+
+.markdown-content :deep(p) {
+  margin: 0 0 8px;
+}
+
+.markdown-content {
+  font-size: 13px;
+  line-height: 1.5;
+}
+
+.markdown-content :deep(p:last-child) {
+  margin-bottom: 0;
+}
+
+.markdown-content :deep(ul),
+.markdown-content :deep(ol) {
+  margin: 8px 0;
+  padding-left: 20px;
+}
+
+.markdown-content :deep(li) {
+  margin: 4px 0;
+}
+
+.markdown-content :deep(h1),
+.markdown-content :deep(h2),
+.markdown-content :deep(h3),
+.markdown-content :deep(h4),
+.markdown-content :deep(h5),
+.markdown-content :deep(h6) {
+  margin: 8px 0;
+  line-height: 1.4;
+  font-weight: 600;
+}
+
+.markdown-content :deep(h1) { font-size: 1.2em; }
+.markdown-content :deep(h2) { font-size: 1.12em; }
+.markdown-content :deep(h3) { font-size: 1.06em; }
+.markdown-content :deep(h4),
+.markdown-content :deep(h5),
+.markdown-content :deep(h6) { font-size: 1em; }
+
+.markdown-content :deep(code) {
+  padding: 2px 6px;
+  border-radius: 6px;
+  background-color: rgba(0, 0, 0, 0.06);
+  font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+  font-size: 0.9em;
+}
+
+.markdown-content :deep(pre) {
+  overflow-x: auto;
+  margin: 8px 0;
+  padding: 10px 12px;
+  border-radius: 10px;
+  background-color: rgba(0, 0, 0, 0.03);
+}
+
+.markdown-content :deep(pre code) {
+  padding: 0;
+  background: transparent;
+}
+
+.markdown-content :deep(blockquote) {
+  margin: 8px 0;
+  padding-left: 12px;
+  border-left: 3px solid rgba(0, 0, 0, 0.18);
+  color: #555;
+}
+
+.markdown-content :deep(a) {
+  color: #2563eb;
+  text-decoration: underline;
+}
+
+.markdown-content :deep(table) {
+  width: 100%;
+  border-collapse: collapse;
+  margin: 10px 0;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.markdown-content :deep(th),
+.markdown-content :deep(td) {
+  border: 1px solid #e5e7eb;
+  padding: 6px 8px;
+  text-align: left;
+  vertical-align: top;
+}
+
+.markdown-content :deep(th) {
+  background-color: #f8fafc;
+  font-weight: 600;
+}
+
+/* AI评价区域 */
+.evaluation-section {
+  flex: 0 0 48%;
+  min-width: 300px;
+  overflow: hidden;
+}
 
 .combined-box {
-  width: 100%; /* 整体宽度 */
+  width: 100%;
   height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.tab-container {
+  display: flex;
+  flex-shrink: 0;
 }
 
 .trapezoid {
-  width: 100px; /* 梯形底边长度 */
+  width: 100px;
   height: 0;
-  border-bottom: 30px solid #cfcfcf; /* 梯形高度和颜色 */
+  border-bottom: 30px solid #cfcfcf;
   border-right: 20px solid transparent;
-  line-height: 30px; /* 与 border-bottom 高度相等 */
-  text-align: center; /* 水平居中 */
+  line-height: 30px;
+  text-align: center;
   z-index: 1;
   cursor: pointer;
+  transition: all 0.3s;
+  font-size: 14px;
 }
 
 .trapezoid2 {
-  width: 100px; /* 梯形底边长度 */
+  width: 100px;
   height: 0;
-  border-bottom: 30px solid #cfcfcf; /* 梯形高度和颜色 */
+  border-bottom: 30px solid #cfcfcf;
   border-right: 20px solid transparent;
   border-left: 20px solid transparent;
   margin-left: -20px;
   z-index: 2;
-  line-height: 30px; /* 与 border-bottom 高度相等 */
-  text-align: center; /* 水平居中 */
+  line-height: 30px;
+  text-align: center;
   cursor: pointer;
+  transition: all 0.3s;
+  font-size: 14px;
 }
 
 .trapezoid.active {
@@ -366,14 +728,178 @@ export default {
 }
 
 .rectangle {
-  width: 100%; /* 长方形宽度 */
-  height: calc(100% - 30px - 2vh); /* 长方形高度 */
-  border-top: none; /* 去掉顶部边框,使其与梯形无缝连接 */
-  /* 长方形底部的圆角 */
+  width: 100%;
+  flex: 1;
+  border-top: none;
+  background-color: #fff;
+  border-radius: 0 5px 5px 5px;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  min-height: 0; /* 重要:允许flexbox子元素正确收缩 */
+}
+
+/* 评价内容滚动区域 */
+.evaluation-scrollbar {
+  flex: 1;
+  min-height: 0;
+}
+
+.overall-evaluation-content {
+  display: flex;
+  align-items: flex-start;
+  padding: 20px;
+  min-height: 100%;
+}
+
+.avatar-container {
+  flex-shrink: 0;
+}
+
+.avatar-wrapper {
+  border-radius: 50%;
+  background-color: #fff;
+  width: 40px;
+  height: 40px;
+  display: flex;
+  justify-content: center;
   align-items: center;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.evaluation-message {
+  margin-left: 15px;
+  flex: 1;
+  min-width: 0; /* 允许文本换行 */
+}
+
+.assistant-name {
+  margin-bottom: 8px;
+  font-weight: 500;
+  color: #333;
+}
+
+.message-content {
+  background-color: #f0f0f0;
+  padding: 12px 16px;
+  border-radius: 16px;
+  white-space: pre-line;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+  word-wrap: break-word;
+  overflow-wrap: break-word;
+  line-height: 1.5;
+  font-size: 13px;
+}
+
+.sentence-evaluation-content {
+  display: flex;
+  flex-direction: column;
   padding: 20px;
-  background-color: #fff; /* 长方形的背景颜色 */
-  border-radius: 0 5px 5px 5px;
+  gap: 10px;
+}
+
+.no-evaluation {
+  text-align: center;
+  color: #999;
+  padding: 40px 20px;
+  font-size: 14px;
+}
+
+/* 加载状态样式 */
+.loading-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 300px;
+  padding: 40px 20px;
+}
+
+.loading-text {
+  margin-top: 20px;
+  font-size: 16px;
+  color: #409EFF;
+  font-weight: 500;
+}
+
+.loading-tip {
+  margin-top: 10px;
+  font-size: 12px;
+  color: #999;
+  text-align: center;
+  max-width: 80%;
+  line-height: 1.5;
+}
+
+.sentence-card {
+  cursor: pointer;
+  position: relative;
+  transition: all 0.2s;
+}
+
+.color-indicator {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 20px;
+  height: 100%;
+  position: absolute;
+  top: 0;
+  left: calc(100% - 20px);
+  border-radius: 0 4px 4px 0;
+}
+
+.sentence-content {
+  padding-right: 30px;
+}
+
+.sentence-category {
+  font-weight: bold;
+  line-height: 30px;
+  color: #333;
+  margin-bottom: 4px;
+}
+
+.sentence-evaluation {
+  line-height: 1.6;
+  color: #666;
+  word-wrap: break-word;
+  overflow-wrap: break-word;
+}
+
+/* 版本历史区域 */
+.version-section {
+  flex: 0 0 20%;
+  min-width: 180px;
+  border-left: 1px solid #e4e7ed;
+  border-radius: 0 8px 8px 0;
+  box-shadow: -2px 0 4px rgba(0,0,0,0.05);
+}
+
+.version-container {
+  position: relative;
+  height: 100%;
+  padding: 20px 10px;
+}
+
+.version-header {
+  position: absolute;
+  top: 0;
+  left: -20px;
+  width: 80px;
+  height: 40px;
+  background: #87CEEB;
+  clip-path: polygon(0 0, 100% 0, 90% 50%, 100% 100%, 0 100%);
+  color: white;
+  display: flex;
+  align-items: center;
+  padding-left: 12px;
+  font-size: 13px;
+  font-weight: 500;
+}
+
+.version-scrollbar {
+  margin-top: 50px;
 }
 
 .version-item {
@@ -392,8 +918,8 @@ export default {
 }
 
 .version-item.active {
-  background: rgba(89, 125, 59, 0.1);
-  border: 1px solid rgba(89, 125, 59, 0.3);
+  background: #E0F7FF;
+  border: 1px solid rgba(135, 206, 235, 0.8);
   box-sizing: border-box;
 }
 
@@ -403,19 +929,183 @@ export default {
   border-radius: 50%;
   background: #cfcfcf;
   margin-right: 10px;
+  flex-shrink: 0;
 }
 
 .version-item.active .version-marker {
-  background: var(--system-color);
+  background: #87CEEB;
+}
+
+.version-info {
+  flex: 1;
+  min-width: 0;
 }
 
 .version-time {
   font-size: 12px;
   color: #333;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
 .version-author {
   font-size: 10px;
   color: #999;
+  margin-top: 2px;
+}
+
+.current-version {
+  position: absolute;
+  bottom: 10px;
+  left: 10px;
+  right: 10px;
+  font-size: 12px;
+  color: #666;
+  text-align: center;
+  padding: 8px;
+  background: #f5f7fa;
+  border-radius: 4px;
+}
+
+/* 响应式设计 */
+/* 平板设备 */
+@media screen and (max-width: 1200px) {
+  .ai-evaluation-container {
+    flex-wrap: wrap;
+    height: auto; /* 允许换行时自动调整高度 */
+    min-height: calc(100vh - 80px);
+  }
+  
+  .essay-section,
+  .evaluation-section {
+    flex: 0 0 calc(50% - 10px);
+    min-width: 280px;
+    height: calc(50vh - 50px); /* 平板模式下各占一半视口 */
+  }
+  
+  .essay-content-wrapper {
+    height: calc(100% - 80px);
+  }
+  
+  .version-section {
+    flex: 0 0 100%;
+    height: 40vh;
+    min-height: 300px;
+    border-left: none;
+    border-top: 1px solid #e4e7ed;
+    border-radius: 8px;
+  }
+  
+  .version-header {
+    left: 0;
+    top: -10px;
+  }
+}
+
+/* 移动设备 */
+@media screen and (max-width: 768px) {
+  .ai-evaluation-container {
+    flex-direction: column;
+    padding: 5px;
+    gap: 8px;
+    height: auto; /* 移动端垂直排列,高度自适应 */
+  }
+  
+  .essay-section,
+  .evaluation-section,
+  .version-section {
+    flex: 0 0 auto;
+    width: 100%;
+    min-width: auto;
+  }
+  
+  .essay-section {
+    height: 45vh;
+  }
+  
+  .essay-content-wrapper {
+    height: calc(100% - 80px);
+  }
+  
+  .evaluation-section {
+    height: 45vh;
+  }
+  
+  .version-section {
+    height: 35vh;
+  }
+  
+  .trapezoid,
+  .trapezoid2 {
+    width: 80px;
+    font-size: 12px;
+  }
+  
+  .overall-evaluation-content {
+    padding: 15px;
+  }
+  
+  .avatar-wrapper {
+    width: 32px;
+    height: 32px;
+  }
+  
+  .avatar-wrapper img {
+    width: 24px !important;
+  }
+  
+  .evaluation-message {
+    margin-left: 10px;
+  }
+  
+  .message-content {
+    padding: 10px 12px;
+    font-size: 13px;
+  }
+  
+  .sentence-evaluation-content {
+    padding: 15px;
+  }
+}
+
+/* 小屏幕移动设备 */
+@media screen and (max-width: 480px) {
+  .breadcrumb {
+    font-size: 12px;
+  }
+  
+  .original-text-label {
+    width: 80px;
+    height: 40px;
+    font-size: 12px;
+  }
+  
+  .essay-content {
+    font-size: 14px;
+    line-height: 24px;
+  }
+  
+  .trapezoid,
+  .trapezoid2 {
+    width: 70px;
+    font-size: 11px;
+    border-bottom-width: 25px;
+    line-height: 25px;
+  }
+  
+  .sentence-category {
+    font-size: 13px;
+  }
+  
+  .sentence-evaluation {
+    font-size: 12px;
+  }
+  
+  .version-header {
+    width: 70px;
+    height: 35px;
+    font-size: 11px;
+  }
 }
-</style>
+</style>

+ 7 - 4
src/views/AiSpeakingPage/SpeakingPage.vue

@@ -152,21 +152,21 @@
           <el-button
             @click="startRecording"
             class="start-button"
-            :disabled="!isHomeworkStarted || isHomeworkSubmitted || hasStartPlay"
+            :disabled="!isHomeworkStarted || isHomeworkSubmitted || hasStartPlay || isLoading"
           >
             {{ hasStopRecording ? "继续" : "开始" }}
           </el-button>
           <el-button
             @click="stopRecording"
             class="stop-button"
-            :disabled="!isHomeworkStarted || isHomeworkSubmitted || hasStartPlay"
+            :disabled="!isHomeworkStarted || isHomeworkSubmitted || hasStartPlay || isLoading"
           >
             暂停
           </el-button>
           <el-button
             @click="resetRecording"
             class="reset-button"
-            :disabled="!isHomeworkStarted || isHomeworkSubmitted"
+            :disabled="!isHomeworkStarted || isHomeworkSubmitted || isLoading"
           >
             重试
           </el-button>
@@ -425,7 +425,7 @@ const stopRecording = async() => {
   
   console.log("识别结果:", resultText.value);
   // 只有当有识别结果时才追加
-  if (resultText.value && resultText.value.trim() !== '') {
+  if (resultText.value) {
     userInput.value += resultText.value;
     ElMessage.success("语音识别完成");
   } else {
@@ -606,6 +606,9 @@ const autoPlay = (message) => {
 };
 
 const playAudio = (message) => {
+  if(isLoading.value) {
+    ElMessage.warning('正在发送中,无法处理语音');
+  }
   if (message.audioUrl) {
     if (message.isPlaying) {
       // 如果正在播放,则暂停

+ 2 - 2
src/views/AiSpeakingPage/component/VoiceAnalysis.vue

@@ -50,8 +50,8 @@
   const voiceAnalysisContainerRef = ref(null);
   const scrollToBottom = () => {
       nextTick(() => {
-        if (voiceAnalysisContainerRef) {
-          voiceAnalysisContainerRef.scrollTop = voiceAnalysisContainerRef.scrollHeight;
+        if (voiceAnalysisContainerRef.value) {
+          voiceAnalysisContainerRef.value.scrollTop = voiceAnalysisContainerRef.value.scrollHeight;
         }
       });
   };

+ 125 - 15
src/views/BehaviorPage/BehaviorBoard.vue

@@ -4,9 +4,12 @@ import {Back} from "@element-plus/icons-vue";
 import {computed, onMounted, provide, type Ref, ref, watch} from "vue";
 import {ElLoading, ElMessage} from "element-plus";
 import useApis from "@/apis";
+import {useRoute} from "vue-router";
 import {BehaviorVersionItem} from "@/types/history";
 
 const apis = useApis()
+const route = useRoute()
+
 //pageHeader
 interface CourseVO{
   courseName: string;
@@ -29,6 +32,7 @@ const loadingStatus = ref({
 provide('loadingStatus',loadingStatus)
 const activeNames = ref(['1'])
 const selectedCourse  = ref({courseName: "", courseId: -1});
+const selectedClass  = ref({courseName: "", classId: -1});
 const courseList:Ref<CourseVO[]> = ref([]);
 let courseData:any[]= []
 const selectedAssignment  = ref({assignmentName: "", assignmentId: -1});
@@ -55,6 +59,11 @@ function getCourseList(){
     target: pageHTML,
     background: '#F5F5F59E'
   })
+
+  // 从路由参数中获取课程ID和作业ID
+  const courseIdFromRoute = route.query.courseId ? Number(route.query.courseId) : null
+  const assignmentIdFromRoute = route.query.assignmentId ? Number(route.query.assignmentId) : null
+
   apis.getCourseByTeacherId(0,100)
       .then((res: any)=>{
         if (res&&res.data.code===200){
@@ -64,14 +73,28 @@ function getCourseList(){
             return {courseName: item.courseName, courseId: item.courseId}
           })
           if (courseList.value&&courseList.value.length>0){
-            selectedCourse.value = courseList.value[0];
+            // 如果路由中有课程ID参数,尝试选中该课程
+            if (courseIdFromRoute) {
+              const targetCourse = courseList.value.find(c => c.courseId === courseIdFromRoute)
+              if (targetCourse) {
+                selectedCourse.value = targetCourse
+                console.log("从路由参数选中课程:", targetCourse.courseName)
+              } else {
+                selectedCourse.value = courseList.value[0]
+              }
+            } else {
+              selectedCourse.value = courseList.value[0]
+            }
           }
           else {
             selectedCourse.value = {courseName: "", courseId: -1}
             loading.close();
             return;
           }
-          apis.getAssignmentByCourseId(courseData[0].courseId,0,1000)
+
+          // 获取选中课程的作业列表
+          const targetCourseId = selectedCourse.value.courseId
+          apis.getAssignmentByCourseId(targetCourseId,0,1000)
             .then((res: any)=>{
               if (res&&res.data.code===200){
                 loading.close();
@@ -86,7 +109,18 @@ function getCourseList(){
                 console.log(assignmentList.value);
                 // assignmentData = temp as any[];
                 if (assignmentList.value&&assignmentList.value.length>0){
-                  selectedAssignment.value = assignmentList.value[0];
+                  // 如果路由中有作业ID参数,尝试选中该作业
+                  if (assignmentIdFromRoute) {
+                    const targetAssignment = assignmentList.value.find(a => a.assignmentId === assignmentIdFromRoute)
+                    if (targetAssignment) {
+                      selectedAssignment.value = targetAssignment
+                      console.log("从路由参数选中作业:", targetAssignment.assignmentName)
+                    } else {
+                      selectedAssignment.value = assignmentList.value[0]
+                    }
+                  } else {
+                    selectedAssignment.value = assignmentList.value[0]
+                  }
                 }
                 else{
                   selectedAssignment.value = {assignmentName: "", assignmentId: -1}
@@ -119,6 +153,10 @@ function getAssignmentList(courseId:number){
     target: pageHTML,
     background: '#F5F5F59E'
   })
+
+  // 从路由参数中获取作业ID(仅在用户手动切换课程时不使用)
+  const assignmentIdFromRoute = route.query.assignmentId ? Number(route.query.assignmentId) : null
+
   apis.getAssignmentByCourseId(courseId,0,1000)
       .then((res: any)=>{
         if (res&&res.data.code===200){
@@ -130,7 +168,18 @@ function getAssignmentList(courseId:number){
           })
           // assignmentData = temp as any[];
           if (assignmentList.value&&assignmentList.value.length>0){
-            selectedAssignment.value = assignmentList.value[0];
+            // 如果路由中有作业ID参数且该作业在列表中,选中该作业
+            if (assignmentIdFromRoute) {
+              const targetAssignment = assignmentList.value.find(a => a.assignmentId === assignmentIdFromRoute)
+              if (targetAssignment) {
+                selectedAssignment.value = targetAssignment
+                console.log("选中路由参数中的作业:", targetAssignment.assignmentName)
+              } else {
+                selectedAssignment.value = assignmentList.value[0]
+              }
+            } else {
+              selectedAssignment.value = assignmentList.value[0]
+            }
           }
           else{
             selectedAssignment.value = {assignmentName: "", assignmentId: -1}
@@ -192,7 +241,6 @@ watch(currentEngagementIndex,()=>{
   }
 })
 provide('currentEngagement',currentEngagement);
-
 watch(currentEngagement,()=>{
   activeNames.value = []
   currentVersionIndex.value = 0
@@ -209,16 +257,34 @@ function AssignmentChanged(value:AssignmentVO){
     currentEngagementIndex.value = -1;
     return;
   }
+
+  // 从路由参数中获取学生ID
+  const studentIdFromRoute = route.query.studentId ? Number(route.query.studentId) : null
+
   apis.getEngagementByAssignmentId(value.assignmentId,0,10000)
       .then((res:any) => {
         console.log(res);
         if (res.data.code === 200){
           engagements.value = res.data.data.records;
           if (engagements.value&&engagements.value.length>0){
-            if (currentEngagementIndex.value == 0){
-              currentEngagement.value =  engagements.value[0];
+            // 如果路由中有学生ID参数,尝试定位到该学生
+            if (studentIdFromRoute) {
+              const targetIndex = engagements.value.findIndex((engagement:any) =>
+                engagement.studentId === studentIdFromRoute
+              )
+              if (targetIndex !== -1) {
+                currentEngagementIndex.value = targetIndex
+                console.log("从路由参数定位到学生,索引:", targetIndex)
+              } else {
+                currentEngagementIndex.value = 0
+                console.log("未找到指定学生,默认选择第一个")
+              }
+            } else {
+              if (currentEngagementIndex.value == 0){
+                currentEngagement.value =  engagements.value[0];
+              }
+              currentEngagementIndex.value = 0;
             }
-            currentEngagementIndex.value = 0;
           }
           else{
             currentEngagementIndex.value = -1;
@@ -250,6 +316,25 @@ function assignmentSwitch(direction:number){
   }
 }
 const selectedType = ref('single');
+/**
+ * 从 Content-Disposition 响应头中解析文件名
+ */
+function extractFileName(contentDisposition: string | undefined, fallback: string): string {
+  if (contentDisposition) {
+    // 优先匹配 filename*=UTF-8''xxx 格式(RFC 5987)
+    const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;\n]+)/i)
+    if (utf8Match) {
+      return decodeURIComponent(utf8Match[1])
+    }
+    // 再匹配 filename="xxx" 或 filename=xxx 格式
+    const normalMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
+    if (normalMatch) {
+      return decodeURIComponent(normalMatch[1].replace(/['"]/g, '').trim())
+    }
+  }
+  return fallback
+}
+
 /**
  * 下载行为数据
  */
@@ -262,7 +347,7 @@ function downLoadBehaviorData(){
           console.log(res)
           if (res && res.status === 200){
             let blob = res.data as Blob;
-            const fileName = '行为数据.xlsx';
+            const fileName = extractFileName(res.headers['content-disposition'], '行为数据.xlsx');
             if ('download' in document.createElement('a')) {
               const link = document.createElement('a');
               link.download = fileName;
@@ -271,22 +356,22 @@ function downLoadBehaviorData(){
               document.body.appendChild(link);
               link.click();
               ElMessage.success('下载成功')
-              URL.revokeObjectURL(link.href); // 释放URL对象
+              URL.revokeObjectURL(link.href);
               document.body.removeChild(link);
             }
           }
         })
         .catch((err:any) => {
           console.log(err)
-          ElMessage.success('下载失败')
+          ElMessage.error('下载失败')
         })
   }
   else if (selectedType.value === 'all'){
     apis.downLoadAllBehaviorData(selectedAssignment.value.assignmentId)
         .then((res:any) => {
           if (res && res.status === 200){
-            let blob = res.data as Blob;
-            const fileName = '行为数据.xlsx';
+            const blob = new Blob([res.data], { type: 'application/zip' });
+            const fileName = extractFileName(res.headers['content-disposition'], '行为数据.zip');
             if ('download' in document.createElement('a')) {
               const link = document.createElement('a');
               link.download = fileName;
@@ -295,16 +380,40 @@ function downLoadBehaviorData(){
               document.body.appendChild(link);
               link.click();
               ElMessage.success('下载成功')
-              URL.revokeObjectURL(link.href); // 释放URL对象
+              URL.revokeObjectURL(link.href);
               document.body.removeChild(link);
             }
           }
         })
         .catch((err:any) => {
           console.log(err)
-          ElMessage.success('下载失败')
+          ElMessage.error('下载失败')
         })
   }
+  // else if (selectedType.value === 'class'){
+  //     apis.downLoadAllBehaviorDataByClass(selectedClass.value.classId,selectedAssignment.value.assignmentId)
+  //       .then((res:any) => {
+  //         if (res && res.status === 200){
+  //           let blob = res.data as Blob;
+  //           const fileName = '行为数据.xlsx';
+  //           if ('download' in document.createElement('a')) {
+  //             const link = document.createElement('a');
+  //             link.download = fileName;
+  //             link.style.display = 'none';
+  //             link.href = URL.createObjectURL(blob);
+  //             document.body.appendChild(link);
+  //             link.click();
+  //             ElMessage.success('下载成功')
+  //             URL.revokeObjectURL(link.href); // 释放URL对象
+  //             document.body.removeChild(link);
+  //           }
+  //         }
+  //       })
+  //       .catch((err:any) => {
+  //         console.log(err)
+  //         ElMessage.success('下载失败')
+  //       })
+  // }
   else{
     ElMessage.error("请选择下载类型")
   }
@@ -475,6 +584,7 @@ onMounted(()=>{
       <el-radio-group v-model="selectedType">
         <el-radio value="single">当前学生行为数据</el-radio>
         <el-radio value="all">所有学生行为数据</el-radio>
+        <!-- <el-radio value="class">班级行为数据</el-radio> -->
       </el-radio-group>
       <template #footer>
         <div class="dialog-footer">

+ 54 - 4
src/views/ClassPage/ClassHomework.vue

@@ -35,6 +35,9 @@
                     chart
                 </template>
             </classToggle>
+            <div class="exportButton">
+                    <button @click="exportData">导出数据</button>
+            </div>
         </div>
         <div class="content">
             <div class="Loading" v-show="!DataLoading">Loading...</div>
@@ -137,8 +140,10 @@ import classToggle from './components/classToggle.vue'
 import {onMounted, ref, computed, watch, nextTick } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useStore } from "@/store"
+import {ElLoading, ElMessage} from "element-plus";
 import * as echarts from 'echarts'
 import useApis from "@/apis";
+import { log } from 'console';
 
 const apis = useApis()
 const store = useStore()
@@ -166,7 +171,6 @@ const Homeworksdata = ref([])
 
 const sortMethods = ["默认","成绩从低到高","成绩从高到低","未批改优先","未提交优先"]
 
-
 const chartRef1 = ref(null)
 const chartRef2 = ref(null)
 const setChart = () => {
@@ -277,6 +281,33 @@ const setChart = () => {
     chartInstance2.setOption(option2)
 }
 
+const exportData = () => {
+    console.log('exportData',classId,ShowTheHomeworkId.value)
+    // apis.downLoadAllBehaviorDataByClass(classId,ShowTheHomeworkId.value)
+    // apis.downLoadAllBehaviorData(ShowTheHomeworkId.value)
+    apis.downLoadAllBehaviorDataByClass(classId,ShowTheHomeworkId.value)
+    .then((res:any) => {
+        if (res && res.status === 200){
+        let blob = res.data as Blob;
+        const fileName = `${ShowTheHomework.value}作业行为数据.xlsx`;
+        if ('download' in document.createElement('a')) {
+            const link = document.createElement('a');
+            link.download = fileName;
+            link.style.display = 'none';
+            link.href = URL.createObjectURL(blob);
+            document.body.appendChild(link);
+            link.click();
+            ElMessage.success('下载成功')
+            URL.revokeObjectURL(link.href); // 释放URL对象
+            document.body.removeChild(link);
+        }
+        }
+    })
+    .catch((err:any) => {
+        console.log(err)
+        ElMessage.success('下载失败')
+    })
+}
 
 const identify = () => {
     if(store.user.role === "STUDENT"){
@@ -291,12 +322,14 @@ const getHomeworks = async () => {
             // console.log('----------------------');
             // console.log(res.data.data.records);
             res.data.data.records.forEach(item => Homeworksdata.value.push(item))
+            if(res.data.data.records.length!=0){
             ShowTheHomework.value = res.data.data.records[0].assignmentName
             ShowTheHomeworkId.value = res.data.data.records[0].assignmentId
+            }
         })
     // t.forEach(item => Homeworksdata.value.push(item))
     HomeworkDataLoading.value = true
-    console.log(ShowTheHomeworkId.value);
+    console.log('ShowTheHomeworkId',ShowTheHomeworkId.value);
 }
 const getStudents = () => {
     StudentsDataLoading.value=false
@@ -306,12 +339,12 @@ const getStudents = () => {
         .then((res:any)=>{
             console.log(res.data.data);
             Studentsdata.value = res.data.data
-            StudentsDataLoading.value = true
         })
         .catch((err:any)=>{
             console.log('-------------------------');
             console.log(err);
         })
+        StudentsDataLoading.value = true
 }
 const changeHomework = () => {
     console.log(ShowTheHomework.value)
@@ -348,7 +381,7 @@ const showData = computed(() => {
 })
 
 const DataLoading = computed(() => {
-  return StudentsDataLoading.value && HomeworkDataLoading.value
+   return StudentsDataLoading.value && HomeworkDataLoading.value
 })
 
 watch(TheHomework, () => {
@@ -434,6 +467,23 @@ onMounted(async () => {
     justify-content: flex-start;
     align-items: center;
     margin-top: 20px;
+    .exportButton{
+        height: 100%;
+        display: flex;
+        flex-direction: row;
+        justify-content: flex-start;
+        align-items: center;
+        margin-left: 20px;
+        button{
+            height: 27px;
+            padding: 0 10px;
+            border: 2px solid #e4e7ed;
+            border-radius: 7px;
+            background-color: #4a90c4;
+            color: #fff;
+            font-weight: 600;
+        }
+    }
     .searchInput{
         height: 100%;
         display: flex;

+ 37 - 1
src/views/ClassPage/ClassStudent.vue

@@ -10,7 +10,9 @@
                 </div>
             </div>
 
-            <!-- <button @click="visible_add=true"> 添加学生 </button> -->
+            <button @click="visible_add=true"> 添加学生 </button>
+
+            <button class="import_class" @click="importData">批量导入学生</button>
 
             <classToggle v-model:isLeft="isSingleDelete">
                 <template #left>
@@ -161,6 +163,31 @@ const handleDelete = (sid) => {
         return item.officialNumber === sid
     }).stuName
 }
+const importData = async () => {
+    console.log("importData");
+    const input = document.createElement('input')
+    input.type = 'file'
+    input.accept = '.xlsx,.xls,.csv'
+    input.addEventListener('change', async (e) => {
+        const target = e.target as HTMLInputElement
+        const selectedFile = target.files?.[0]
+        if (!selectedFile) {
+            isloading_add.value = false
+            return
+        }
+        isloading_add.value = true
+        try {
+            const res = await apis.importStudentsOfClass(classId, selectedFile)
+            console.log('Import response:', res)
+            getStudents()
+        } catch (error) {
+            console.error('Import failed:', error)
+        } finally {
+            isloading_add.value = false
+        }
+    })
+    input.click()
+}
 const DeleteStudent = async () =>{
     isloading.value = true
     await apis.deleteStudentOfClass(classId,[deleteId.value])
@@ -274,6 +301,15 @@ onMounted(() => {
     width: 100%;
     height: 100%;
 }
+.import_class{
+    font-size: 14px;
+    padding: 1px 12px;
+    background-color: #c45c4a;
+    color: #fff;
+    border: none;
+    border-radius: 20px;
+    cursor: pointer;
+}
 .search{
     height: 50px;
     width: 100%;

+ 18 - 69
src/views/ClassPage/StudentDetail.vue

@@ -109,6 +109,7 @@ import { useStore } from "@/store"
 import classHeader from './components/classHeader.vue';
 import type {CourseDetailVO, ClassVO, ClassStudentVO} from "@/types/vo";
 import useApis from "@/apis";
+import { log } from 'console';
 
 const route = useRoute()
 const store = useStore()
@@ -150,81 +151,28 @@ const Classdata = ref<ClassVO>({
   classCode: null,
 })
 const sortMethods = ["默认","成绩从低到高","成绩从高到低","未批改优先","未提交优先"]
-// const Studentdata = ref({
-//     "officialNumber": 522025312092,
-//     "stuName": "张伟",
-//     "studentId": studentId,
-//     "assignments": [
-//         {
-//             "assignmentId": 73,
-//             "assignmentName":"第一次作业",
-//             "status": "CORRECTED",
-//             "score": 88
-//         },
-//         {
-//             "assignmentId": 73,
-//             "assignmentName":"第二次作业",
-//             "status": "CORRECTED",
-//             "score": 91
-//         },
-//         {
-//             "assignmentId": 73,
-//             "assignmentName":"第三次作业",
-//             "status": "CORRECTED",
-//             "score": 94
-//         },
-//         {
-//             "assignmentId": 73,
-//             "assignmentName":"期中作业",
-//             "status": "CORRECTED",
-//             "score": 59
-//         },
-//         {
-//             "assignmentId": 73,
-//             "assignmentName":"第五次作业",
-//             "status": "CORRECTED",
-//             "score": 79
-//         },
-//         {
-//             "assignmentId": 73,
-//             "assignmentName":"第六次作业",
-//             "status": "NOT_SUBMITTED",
-//             "score": 0
-//         },
-//         {
-//             "assignmentId": 73,
-//             "assignmentName":"期末考试",
-//             "status": "SUBMITTED",
-//             "score": 0
-//         }
-//     ]
-// })
 
-// const identify = () => {
-//     if(store.user.role === "STUDENT"){
-//         router.push('/404')
-//     }
-// }
 const Studentdata = ref({
     "officialNumber": 0,
     "stuName": "",
     "studentId": 0,
     "assignments":[]
 })
-const getStudent = () => {
+const getStudent = async () => {
     StudentdataLoading.value = false
     console.log(courseId)
     console.log(studentId)
-    apis.getHomeworkOfStudent(courseId,studentId)
+    await apis.getHomeworkOfStudent(courseId,studentId)
         .then((res:any)=>{
             Studentdata.value = res.data.data
-            console.log(res.data.data);
-            StudentdataLoading.value = true
+            console.log('zcx',Studentdata.value);
         })
+    StudentdataLoading.value = true
+    console.log('zzzz','修改',StudentdataLoading.value);
 }
-const getClass = () => {
+const getClass = async () => {
     console.log("classId:",classId)
-    apis.getClassByClassId(classId)
+    await apis.getClassByClassId(classId)
         .then((res:any)=>{
             console.log(res.data.data);
             Classdata.value = res.data.data
@@ -235,22 +183,23 @@ const getClass = () => {
     ClassDataLoading.value = true
 }
 
-const getCourse = () => {
+const getCourse = async () => {
   // 获取课程信息
-  apis.getCourseByCourseId(courseId)
+    await apis.getCourseByCourseId(courseId)
       .then((res: any) => {
         let data = res.data.data.records[0]
         Object.assign(Coursedata, {...data})
-        CourseDataLoading.value = true
       })
       .catch((err: any) => {
         console.error(err);
       })
+    CourseDataLoading.value = true
 }
 
 const DataLoading = computed(() => {
 //   return CourseDataLoading.value && ClassDataLoading.value
     if(store.user.role === "STUDENT"){
+        console.log('finally',StudentdataLoading.value,CourseDataLoading.value,ClassDataLoading.value)
         return StudentdataLoading.value && CourseDataLoading.value && ClassDataLoading.value
     }
     else{
@@ -259,14 +208,14 @@ const DataLoading = computed(() => {
 })
 const searchData = computed(()=>{
     if(searchInput.value===""){
-        return Studentdata.value.assignments
+        return Studentdata.value.assignments??[]
     }
     else{
         return Studentdata.value.assignments.filter(student => {
           return (
             student.assignmentName.includes(searchInput.value)
           )
-        })
+        })??[]
     }
 })
 const showData = computed(() => {
@@ -318,13 +267,13 @@ watch(ShowTheSort,(newData) =>{
     }
 })
 
-onMounted(() => {
+onMounted(async () => {
     // identify()
     if(store.user.role === "STUDENT"){
-        getCourse()
-        getClass()
+        await getCourse()
+        await getClass()
     }
-    getStudent()
+    await getStudent()
     
 })
 </script>

+ 141 - 55
src/views/CorrectPage/CorrectPage.vue

@@ -49,8 +49,8 @@
   
     <!-- 右侧内容区域 -->
     <div class="right-content">
-      <!-- 作业描述区域 -->
-      <div class="content-panel">
+      <!-- 作业描述区域 - 仅学生可见 -->
+      <div v-if="isStudent" class="content-panel">
         <!-- 上半部分:作业描述 -->
         <div class="assignment-section">
           <div class="section-header">
@@ -76,6 +76,29 @@
             补交
           </span>
         </div>
+        <div
+          v-if="!isStudent && engagementHistoryList.length > 0"
+          class="version-switcher"
+        >
+          <div class="version-switcher-header">
+            <span class="version-switcher-label">提交版本:</span>
+            <span class="version-switcher-time">
+              当前显示 V{{ selectedHistoryVersion }} ·
+              {{ formatSubmitTime(selectedHistoryRecord?.submitTime) }}
+            </span>
+          </div>
+          <div class="version-button-group">
+            <el-button
+              v-for="history in engagementHistoryList"
+              :key="history.version"
+              size="small"
+              :type="selectedHistoryVersion === history.version ? 'primary' : 'default'"
+              @click="handleVersionChange(history.version)"
+            >
+              V{{ history.version }}
+            </el-button>
+          </div>
+        </div>
         <div class="student-answer-content">
           {{ studentAnswer }}
         </div>
@@ -226,30 +249,33 @@
     </div>
     
     <!-- 行为记录对话框 -->
-        <el-dialog v-model="showDialog" title="文件信息">
-
-      <el-table :data="fileList" stripe>
-        <el-table-column prop="id" label="ID"></el-table-column>
-        <el-table-column prop="assignmentId" label="作业 ID"></el-table-column>
-        <el-table-column prop="recordFileUrl" label="文件链接 (复制粘贴到浏览器中打开)" width="300px">
+    <el-dialog 
+      v-model="showDialog" 
+      title="文件信息"
+      :width="dialogWidth"
+      class="behavior-dialog"
+    >
+      <el-table :data="fileList" stripe class="behavior-table">
+        <el-table-column prop="id" label="ID" min-width="60"></el-table-column>
+        <el-table-column prop="assignmentId" label="作业 ID" min-width="80"></el-table-column>
+        <el-table-column prop="recordFileUrl" label="文件链接" min-width="200">
           <template #default="scope">
-            <!-- <a :href="scope.row.recordFileUrl" target="_blank">{{ scope.row.recordFileUrl }}</a> -->
-            <span>{{ scope.row.recordFileUrl }}</span>
+            <span class="file-url">{{ scope.row.recordFileUrl }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="createdAt" label="时间">
+        <el-table-column prop="createdAt" label="时间" min-width="150">
           <template #default="scope">
-            {{ formatDateTime(scope.row.createdAt) }} <!-- 调用格式化函数 -->
+            {{ formatDateTime(scope.row.createdAt) }}
           </template>
         </el-table-column>
-        <el-table-column prop="studentId" label="学生 ID"></el-table-column>
+        <el-table-column prop="studentId" label="学生 ID" min-width="80"></el-table-column>
       </el-table>
 
       <template #footer>
-      <el-button @click="showDialog = false">取消</el-button>
-      <el-button type="primary" @click="downloadAllFiles">导出</el-button>
+        <el-button @click="showDialog = false">取消</el-button>
+        <el-button type="primary" @click="downloadAllFiles">导出</el-button>
       </template>
-      </el-dialog>
+    </el-dialog>
       
     <!-- 评语模板对话框 -->
     <el-dialog
@@ -281,8 +307,8 @@ import useApis from "@/apis";
 import { useStore } from "@/store";
 import router from "@/router"
 import { onMounted, reactive, ref, computed } from "vue";
-import type { engagementVO } from "@/types/vo";
-import type { IDialogue } from "@/types/dialogue.ts";
+import type { engagementVO, engagementHistoryVO } from "@/types/vo";
+import type { IDialogue } from "@/types/dialogue";
 import { ElMessage } from 'element-plus';
 
 import "./index.scss"
@@ -291,10 +317,13 @@ import { useRoute } from 'vue-router'
 // 导入所需组件
 import StudentWritingRequirements from "@/views/Home/component/StudentEssay/StudentWritingRequirements.vue";
 
-import * as dayjs from 'dayjs';
+import dayjs from 'dayjs';
 const formatDateTime = (isoString: string): string => {
   return dayjs(isoString).format('YYYY-MM-DD HH:mm:ss');
 };
+const formatSubmitTime = (submitTime?: string): string => {
+  return submitTime ? formatDateTime(submitTime) : '暂无提交时间';
+};
 
 // 获得路由中的assignmentId
 const route = useRoute()
@@ -375,10 +404,22 @@ const studentInfo = reactive({
 
 // 学生回答内容
 const studentAnswer = ref('')
+const engagementHistoryList = ref<engagementHistoryVO[]>([])
+const selectedHistoryVersion = ref<number | null>(null)
+const selectedHistoryRecord = computed(() => {
+  return engagementHistoryList.value.find(item => item.version === selectedHistoryVersion.value)
+    || engagementHistoryList.value[0]
+    || null
+})
 
 // 课程ID(用于返回时保持菜单选中状态)
 const courseId = ref(0)
 
+// 响应式对话框宽度
+const dialogWidth = computed(() => {
+  return '50%'
+})
+
 // 格式化消息数据用于显示
 const formattedMessages = ref<IDialogue[]>([])
 
@@ -394,28 +435,23 @@ const updateFormattedMessages = () => {
 // 获取学生信息
 async function fetchStudentInfo() {
   try {
-    const response = await apis.findUserById(studentId)
-    if (response.data.code === 200) {
-      const userData = response.data.data.records[0]
+    // 获取学生基本信息(姓名)
+    const userResponse = await apis.findUserById(studentId)
+    if (userResponse.data.code === 200) {
+      const userData = userResponse.data.data.records[0]
       studentInfo.name = userData.name
-      studentInfo.className = userData.className || ''
     }
-  } catch (error) {
-    console.error('获取学生信息失败:', error)
-  }
-}
-
-// 获取评分和评语数据
-async function fetchCorrectionData() {
-  try {
-    const response = await apis.getEngagement(studentId, assignmentId)
-    if (response.data.code === 200) {
-      const engagementData = response.data.data
-      correctionData.score = engagementData.score || 0
-      correctionData.remark = engagementData.remark || ''
+    
+    // 获取学生班级信息(需要先获取课程ID)
+    if (courseId.value) {
+      const classResponse = await apis.getClassNameOfStudent(courseId.value, studentId)
+      if (classResponse.data.code === 200) {
+        const classData = classResponse.data.data
+        studentInfo.className = classData.className || ''
+      }
     }
   } catch (error) {
-    console.error('获取评分数据失败:', error)
+    console.error('获取学生信息失败:', error)
   }
 }
 
@@ -432,6 +468,44 @@ async function fetchAssignmentDetails() {
   }
 }
 
+const applyHistoryVersion = (history: engagementHistoryVO | null) => {
+  if (!history) {
+    studentAnswer.value = ''
+    return
+  }
+  selectedHistoryVersion.value = history.version
+  studentAnswer.value = history.textContent || ''
+}
+
+const handleVersionChange = (version: number) => {
+  const targetHistory = engagementHistoryList.value.find(item => item.version === version)
+  if (!targetHistory) {
+    return
+  }
+  applyHistoryVersion(targetHistory)
+}
+
+async function fetchTeacherEngagementHistory(fallbackText = '') {
+  try {
+    const response = await apis.getEngagementHistory(studentId, assignmentId)
+    const historyList = Array.isArray(response.data.data)
+      ? [...response.data.data].sort((a: engagementHistoryVO, b: engagementHistoryVO) => b.version - a.version)
+      : []
+
+    engagementHistoryList.value = historyList
+
+    if (historyList.length > 0) {
+      applyHistoryVersion(historyList[0])
+      return
+    }
+  } catch (error) {
+    console.error('获取学生历史提交版本失败:', error)
+  }
+
+  selectedHistoryVersion.value = null
+  studentAnswer.value = fallbackText
+}
+
 // 获取数据
 async function fetchData() {
   await apis.getAIDialogues(assignmentId, studentId)
@@ -453,36 +527,45 @@ async function fetchData() {
         messages: dataForm.messages,
         engagement: engagementData
       })
-      // 获取学生回答内容
-      if (engagementData.textContent) {
-        studentAnswer.value = engagementData.textContent
-      }
+
+      correctionData.score = engagementData.score || 0
+      correctionData.remark = engagementData.remark || ''
     }).catch((_err: any) => {
       console.log(_err)
       // router.push("/login");
       router.go(-1);
     })
+
+  if (isStudent.value) {
+    studentAnswer.value = dataForm.engagement?.textContent || ''
+  } else {
+    await fetchTeacherEngagementHistory(dataForm.engagement?.textContent || '')
+  }
   
-  // 获取学生信息
-  await fetchStudentInfo()
-  
-  // 获取现有的评分和评语
-  await fetchCorrectionData()
-  
-  // 获取作业详情(获取课程ID)
+  // 获取作业详情(获取课程ID)- 必须先获取,因为获取班级信息需要用到
   await fetchAssignmentDetails()
   
+  // 获取学生信息(包括班级信息)
+  await fetchStudentInfo()
+  
   // 获取批改进度统计
   await fetchCorrectionStats()
 }
 
-// 弹窗相关函数
+// 跳转到行为数据页面
 const handleBehaviorRecord = () => {
-  showDialog.value = true
-  // 如果数据为空,重新获取
-  if (fileList.length === 0) {
-    getBehaviorRecords()
-  }
+  // 跳转到行为数据分析页面,并携带课程ID、作业ID和学生ID参数
+  router.push({
+    path: '/home/behavior/main',
+    query: {
+      courseId: courseId.value.toString(),
+      assignmentId: assignmentId.toString(),
+      studentId: studentId.toString()
+    }
+  }).then(() => {
+      // 强制刷新页面以重新加载数据
+      window.location.reload();
+    });
 }
 
 // 获取行为记录数据
@@ -693,7 +776,10 @@ const goBackStudent = () => {
       courseId: courseId.value.toString(),
       fromAssignment: 'true' // 标记是从作业页面返回
     }
-  });
+  }).then(() => {
+      // 强制刷新页面以重新加载数据
+      window.location.reload();
+    });
 };
 
 onMounted(() => {

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 722 - 58
src/views/CorrectPage/CoursePage.vue


+ 240 - 69
src/views/CorrectPage/index.scss

@@ -9,13 +9,16 @@
   background-color: #FFF;
   border-radius: 15px;
   height: 125px;
-  width: 330px;
-  margin-top: 0px;
-  margin-left: 0px;
+  width: 100%;
   display: flex;
   align-items: center;
   justify-content: center;
-  position: relative; // 为绝对定位的错误提示提供相对定位容器
+  position: relative;
+  flex-shrink: 0;
+  
+  @media (max-width: 1439px) {
+    height: 110px;
+  }
   
   .rating-component {
     display: flex;
@@ -112,46 +115,46 @@
 
     .error-message {
       position: absolute;
-      top: 90%; // 位于评分输入框下方
+      top: 90%;
       left: 60%;
-      transform: translateX(-50%); // 水平居中
+      transform: translateX(-50%);
       color: #ff4d4f;
       font-size: 14px;
-      font-weight: 700;
+      font-weight: 500;
       text-align: center;
-      white-space: nowrap; // 防止文字换行
+      white-space: nowrap;
       margin-top: 10px;
-      font-size: 16px;
-      font-weight: 500;
-      z-index: 10; // 确保在其他元素之上
+      z-index: 10;
     }
   }
 }
 
 .remark-section {
-  margin-top: 0px;
-  width: 330px;
+  width: 100%;
   background-color: #FFF;
-  height: 470px;
   border-radius: 15px;
-  padding: 15px 15px;
+  padding: 15px;
   box-sizing: border-box;
   display: flex;
   flex-direction: column;
+  flex: 1;
+  min-height: 0;
   
   .textarea-container {
     flex: 1;
+    display: flex;
+    min-height: 0;
     
     .remark-textarea {
-      width: 300px;
-      height: 445px;
+      width: 100%;
+      height: 100%;
       box-sizing: border-box;
       padding: 15px;
       border: 1px solid #e0e0e0;
       border-radius: 8px;
       font-size: 16px;
       line-height: 1.6;
-      resize: none; // 禁止用户调整大小
+      resize: none;
       font-family: inherit;
       background-color: #FFF;
       
@@ -168,15 +171,13 @@
       }
     }
   }
-  
-
 }
 
 // 按钮区域样式
 .button-section {
-  margin-top: 0px;
-  width: 330px;
-  height: 140px;
+  width: 100%;
+  height: auto;
+  min-height: 140px;
   background-color: #FFF;
   border-radius: 15px;
   padding: 15px 10px;
@@ -184,6 +185,11 @@
   display: flex;
   flex-direction: column;
   gap: 10px;
+  flex-shrink: 0;
+  
+  @media (max-width: 1439px) {
+    min-height: 130px;
+  }
   
   .button-row {
     display: flex;
@@ -263,6 +269,10 @@
   .progress-section {
     margin: 0px 0 0px 40px;
     
+    @media (max-width: 1439px) {
+      margin: 0px 0 0px 30px;
+    }
+    
     .progress-bar {
       :deep(.el-progress-bar__outer) {
         border-radius: 10px;
@@ -286,14 +296,23 @@
 
 // 学生功能区域样式
 .student-function-area {
-  margin-left: 20px;
-  margin-top: 25px;
+  margin: 10px 25px 10px 0;
   display: flex;
   flex-direction: column;
   gap: 20px;
+  flex-shrink: 0;
+  width: 330px;
+  height: calc(100vh - 20px);
+  
+  @media (max-width: 1439px) {
+    width: 280px;
+    margin: 10px 15px 10px 0;
+    gap: 15px;
+    height: calc(100vh - 20px);
+  }
   
   .student-score-display {
-    width: 330px;
+    width: 100%;
     background-color: #FFF;
     border-radius: 15px;
     height: 125px;
@@ -301,6 +320,11 @@
     align-items: center;
     justify-content: center;
     gap: 5px;
+    flex-shrink: 0;
+    
+    @media (max-width: 1439px) {
+      height: 110px;
+    }
     
     .score-label {
       font-size: 20px;
@@ -330,17 +354,17 @@
   }
   
   .student-remark-display {
-    width: 330px;
+    width: 100%;
     background-color: #FFF;
     border-radius: 15px;
-    height: 580px;
     padding: 20px;
     box-sizing: border-box;
+    flex: 1;
+    min-height: 0;
     
     .remark-content {
       width: 100%;
       height: 100%;
-      min-height: 260px;
       font-size: 16px;
       line-height: 1.6;
       color: #333;
@@ -364,13 +388,13 @@
   }
   
   .student-button-area {
-    width: 330px;
+    width: 100%;
     display: flex;
     justify-content: center;
-    margin-top: 0px;
+    flex-shrink: 0;
     
     .student-back-btn {
-      width: 330px;
+      width: 100%;
       height: 50px;
       background: linear-gradient(135deg, #5397F8, #4472C4);
       color: white;
@@ -381,6 +405,11 @@
       cursor: pointer;
       transition: all 0.3s ease;
       
+      @media (max-width: 1439px) {
+        height: 45px;
+        font-size: 17px;
+      }
+      
       &:hover {
         background: linear-gradient(135deg, #4585C6, #365F91);
         transform: translateY(-2px);
@@ -406,15 +435,17 @@
   flex-direction: column;
   gap: 15px;
   padding: 0 24px;
+  max-height: 60vh;
+  overflow-y: auto;
 
   .template-item {
     box-sizing: border-box;
-    padding: 0 16px;
+    padding: 12px 16px;
     border: 1px solid #e0e0e0;
-    border-radius: 4px;
+    border-radius: 8px;
     cursor: pointer;
     transition: all 0.3s ease;
-    height: 50px;
+    min-height: 50px;
     overflow: hidden;
     display: flex;
     align-items: center;
@@ -424,12 +455,12 @@
     &:hover {
       background-color: #f5f7fa;
       border-color: #597D3B;
+      transform: translateX(4px);
     }
 
     .template-content {
       width: 100%;
       text-align: left;
-      white-space: nowrap;
       overflow: hidden;
       text-overflow: ellipsis;
       font-size: 14px;
@@ -442,10 +473,32 @@
 
 .correct-page {
   width: 100%;
-  height: 95vh;
+  height: 100vh;
   background-color: #f5f5f5;
   font-family: "Microsoft YaHei", sans-serif;
   display: flex;
+  flex-wrap: nowrap;
+  padding: 0;
+  box-sizing: border-box;
+  position: relative;
+  overflow: hidden;
+  
+  // 响应式断点定义(仅针对电脑屏幕)
+  // Large Desktop: > 1920px
+  // Desktop: 1440px - 1920px
+  // Laptop: 1024px - 1439px
+  
+  // 针对大屏幕的优化
+  @media (min-width: 1920px) {
+    .ai-chat-panel {
+      width: 420px;
+    }
+    
+    .teacher-function-area,
+    .student-function-area {
+      width: 360px;
+    }
+  }
 
   // AI对话面板样式
   .ai-chat-panel {
@@ -454,18 +507,27 @@
     border-right: 1px solid #e9ecef;
     display: flex;
     flex-direction: column;
-    margin-top: 25px;
-    margin-left: 25px;
+    margin: 10px 0 10px 25px;
     border-radius: 15px;
-    height: 90vh;
+    height: calc(100vh - 20px);
+    flex-shrink: 0;
+    overflow: hidden;
+    
+    // 笔记本屏幕
+    @media (max-width: 1439px) {
+      width: 320px;
+      margin: 10px 0 10px 15px;
+      height: calc(100vh - 20px);
+    }
 
-        .chat-header {
-        padding: 20px 16px 16px;
-        border-bottom: 1px solid #e9ecef;
-        display: flex;
-        justify-content: space-between;
-        align-items: center;
-        border-radius: 15px 15px 0 0; // 顶部圆角匹配面板
+    .chat-header {
+      padding: 20px 16px 16px;
+      border-bottom: 1px solid #e9ecef;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      border-radius: 15px 15px 0 0;
+      flex-shrink: 0;
 
       .chat-title {
         font-size: 18px;
@@ -500,7 +562,7 @@
     .chat-messages {
       flex: 1;
       overflow-y: auto;
-      padding: 16px 16px 20px 16px; // 增加底部padding为20px
+      padding: 16px 16px 20px 16px;
       display: flex;
       flex-direction: column;
       gap: 16px;
@@ -512,7 +574,7 @@
       
       &::-webkit-scrollbar-track {
         background: transparent;
-        margin: 8px 0; // 上下留白
+        margin: 8px 0;
       }
       
       &::-webkit-scrollbar-thumb {
@@ -524,7 +586,6 @@
         }
       }
       
-      // 确保滚动条不会影响圆角
       border-radius: 0 0 15px 15px;
 
       .message-group {
@@ -569,9 +630,9 @@
           }
 
           .user-bubble {
-            background: #5cb3f5;
+            background: linear-gradient(135deg, #4ca1f4, #5cb3f5, #6ec0f6);;
             color: white;
-            border-bottom-right-radius: 6px;
+            border-radius: 20px;
           }
         }
 
@@ -579,7 +640,7 @@
           width: 32px;
           height: 32px;
           border-radius: 16px;
-          background: #5cb3f5;
+          background: linear-gradient(135deg, #4ca1f4, #5cb3f5, #6ec0f6);
           display: flex;
           align-items: center;
           justify-content: center;
@@ -600,7 +661,7 @@
           width: 32px;
           height: 32px;
           border-radius: 16px;
-          background: #6c757d;
+          background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
           display: flex;
           align-items: center;
           justify-content: center;
@@ -632,9 +693,9 @@
           }
 
           .ai-bubble {
-            background: #e9ecef;
+            background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
             color: #2c3e50;
-            border-bottom-left-radius: 6px;
+            border-radius: 20px;
                 }
     }
   }
@@ -854,18 +915,29 @@
   .right-content {
     display: flex;
     flex-direction: column;
-    margin-left: 30px;
-    margin-top: 25px;
+    margin: 10px 20px 10px 30px;
     gap: 20px;
+    flex: 1;
+    min-width: 0;
+    height: calc(100vh - 20px);
+    
+    // 笔记本屏幕
+    @media (max-width: 1439px) {
+      margin: 10px 15px 10px 20px;
+      gap: 15px;
+      height: calc(100vh - 20px);
+    }
   }
 
   // 教师功能区域
   .teacher-function-area {
-    margin-left: 20px;
-    margin-top: 25px;
+    margin: 10px 25px 10px 0;
     display: flex;
     flex-direction: column;
     gap: 15px;
+    flex-shrink: 0;
+    width: 330px;
+    height: calc(100vh - 20px);
 
     // 查看行为记录按钮
     .behavior-record-btn {
@@ -877,40 +949,52 @@
       font-weight: 600;
       border-radius: 12px;
       white-space: nowrap;
-      margin-left: 20px;
-      width: fit-content;
+      width: 100%;
       height: 40px;
-      line-height: 40px;
-      width: 300px;
       
       &:hover {
         background: #6BA3D0;
         border-color: #6BA3D0;
       }
     }
+    
+    // 笔记本屏幕
+    @media (max-width: 1439px) {
+      width: 280px;
+      margin: 10px 15px 10px 0;
+      gap: 12px;
+      height: calc(100vh - 20px);
+    }
   }
 
-  // 作业描述面板样式
+  // 作业描述面板样式(仅学生可见)
   .content-panel {
-    width: 900px;
+    width: 100%;
     height: 240px;
     background: #FFF;
     border-radius: 15px;
     border: 1px solid #e9ecef;
     display: flex;
     flex-direction: column;
-    overflow: hidden;
+    flex-shrink: 0;
+    
+    // 笔记本屏幕
+    @media (max-width: 1439px) {
+      height: 220px;
+    }
 
     .assignment-section {
       flex: 1;
       border-bottom: 1px solid #e9ecef;
       display: flex;
       flex-direction: column;
+      min-height: 0;
 
       .section-header {
         padding: 16px 20px 12px;
         border-bottom: 1px solid #f0f0f0;
         background: #FFF;
+        flex-shrink: 0;
 
         h2 {
           margin: 0;
@@ -925,14 +1009,35 @@
         padding: 16px 20px;
         overflow-y: auto;
         background: #fff;
+        min-height: 0;
+        
+        // 自定义滚动条
+        &::-webkit-scrollbar {
+          width: 6px;
+        }
+        
+        &::-webkit-scrollbar-track {
+          background: transparent;
+          margin: 8px 0;
+        }
+        
+        &::-webkit-scrollbar-thumb {
+          background: #d1d5db;
+          border-radius: 3px;
+          
+          &:hover {
+            background: #9ca3af;
+          }
+        }
       }
     }
   }
 
   // 学生信息和回答区域样式
   .student-info-section {
-    width: 900px;
-    height: 550px;
+    width: 100%;
+    flex: 1;
+    min-height: 0;
     background: #FFF;
     border-radius: 15px;
     border: 1px solid #e9ecef;
@@ -947,11 +1052,14 @@
       display: flex;
       align-items: center;
       gap: 20px;
+      flex-wrap: wrap;
+      flex-shrink: 0;
 
       .student-label, .class-label {
         font-weight: 600;
         color: #333;
         font-size: 14px;
+        white-space: nowrap;
       }
 
       .student-name, .class-name {
@@ -976,6 +1084,42 @@
         text-align: center;
         font-family: "Noto Sans SC", sans-serif;
         margin-left: 10px;
+        white-space: nowrap;
+      }
+    }
+
+    .version-switcher {
+      padding: 12px 20px;
+      border-bottom: 1px solid #f0f0f0;
+      background: #fafcff;
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+      flex-shrink: 0;
+
+      .version-switcher-header {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: 12px;
+        flex-wrap: wrap;
+      }
+
+      .version-switcher-label {
+        font-size: 14px;
+        font-weight: 600;
+        color: #333;
+      }
+
+      .version-switcher-time {
+        font-size: 13px;
+        color: #666;
+      }
+
+      .version-button-group {
+        display: flex;
+        flex-wrap: wrap;
+        gap: 8px;
       }
     }
 
@@ -989,6 +1133,20 @@
       color: #333;
       white-space: pre-wrap;
       word-wrap: break-word;
+      
+      // 自定义滚动条
+      &::-webkit-scrollbar {
+        width: 6px;
+      }
+      
+      &::-webkit-scrollbar-thumb {
+        background: #d1d5db;
+        border-radius: 3px;
+        
+        &:hover {
+          background: #9ca3af;
+        }
+      }
     }
   }
 }
@@ -1051,4 +1209,17 @@
         right: 28vh;
       }
     }
+}
+
+// 行为记录对话框样式
+.behavior-dialog {
+  :deep(.el-dialog__body) {
+    padding: 20px;
+    overflow-x: auto;
+  }
+  
+  .file-url {
+    word-break: break-all;
+    font-size: 14px;
+  }
 }

+ 3 - 2
src/views/CoursePage/CoursePage.vue

@@ -7,10 +7,11 @@
   <el-card style="margin: 10px auto 0;height: 100%; width: 80%" shadow="never">
     <el-button :icon="Refresh" circle @click="doRefresh"></el-button>
     <template v-if="role == 'teachers'">
-      <el-button @click="visible = !visible" color="#597d3b">新增课程</el-button>
+      <el-button @click="visible = !visible" style="background-color: #409EFF; border-color: #409EFF; color: white;">新增课程</el-button>
     </template>
     <el-input v-model="courseName" placeholder="请输入课程名称" clearable style="width: 180px; margin: 0 10px 0 10px ;"/>
-    <el-button color="#597D3B" @click="handleSearch">
+    <el-button   style="background-color: #409EFF; border-color: #409EFF; color: white;" 
+ @click="handleSearch">
       <el-icon style="margin-right: 3px"><Search/></el-icon>
       查询
     </el-button>

+ 1 - 1
src/views/CourseSquare/component/AllCourse/MoreCourse.vue

@@ -7,7 +7,7 @@
       <div class="course-content-title">
         <el-button :icon="Refresh" circle @click="doRefresh" style="margin: 0 10px 0 30px"></el-button>
         <el-input v-model="courseName" placeholder="请输入课程名称" clearable class="search-input" style="width: 180px; margin-right: 10px"/>
-        <el-button @click="handleSearch" color="#597D3B">
+        <el-button style="background-color: #409EFF; border-color: #409EFF; color: white;"  @click="handleSearch">
           <el-icon style="margin-right: 3px" ><Search/></el-icon>
           查询
         </el-button>

+ 33 - 1
src/views/CourseSquare/component/CourseDetails/component/AssignmentDialogue/WritingAssignmentDialogue.vue

@@ -15,6 +15,23 @@
         <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-switch
+            v-model="newAssignmentInfo.examMode"
+            active-text="考试模式"
+            inactive-text="普通作业"
+            @change="handleExamModeChange"
+          />
+        </el-form-item>
+        <el-form-item v-if="newAssignmentInfo.examMode" label="考试时长">
+          <el-input-number
+            v-model="newAssignmentInfo.examDuration"
+            :min="1"
+            :max="300"
+            controls-position="right"
+          />
+          <span style="margin-left: 10px; color: #909399;">分钟</span>
+        </el-form-item>
         <el-form-item label="描述文件">
           <el-upload
             ref="uploadDescribeRef"
@@ -98,14 +115,21 @@
     attachments: [''],
     startTime: '',
     endTime: '',
+    examMode: false,
+    examDuration: 60,
   });
   
+  const handleExamModeChange = (value: boolean) => {
+    if (value && !newAssignmentInfo.examDuration) {
+      newAssignmentInfo.examDuration = 60;
+    }
+  };
+
   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, {
@@ -116,6 +140,8 @@
           attachments: assignment.attachments,
           startTime: assignment.startTime,
           endTime: assignment.endTime,
+          examMode: assignment.examMode ?? false,
+          examDuration: assignment.examDuration ?? 60,
     });
       });
     } else {
@@ -220,6 +246,10 @@
       ElNotification({title: '结束时间不得在起始时间之前',type: 'error',})
       return
     }
+    if(newAssignmentInfo.examMode && (!newAssignmentInfo.examDuration || newAssignmentInfo.examDuration < 1)) {
+      ElNotification({title: '考试模式下考试时长不得为空',type: 'error',})
+      return
+    }
     newAssignmentInfo.endTime = formatDate(newAssignmentInfo.endTime);
     newAssignmentInfo.startTime = formatDate(newAssignmentInfo.startTime);
     if (props.dialogConfig.type === 'add') {
@@ -316,6 +346,8 @@
       attachments: [''],
       startTime: '',
       endTime: '',
+      examMode: false,
+      examDuration: 60,
     });
   };
   const emit = defineEmits(['update:writingVisible']);

+ 27 - 15
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="isTypeDialogVisible=true" v-if="props.isShowNewHomework">新建作业</el-button>
+      <el-button style="background-color: #6A9BD8; color: white;" @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>
@@ -22,7 +22,7 @@
         <!-- TODO:找到了 详情界面 -->
         <el-table-column label="详情" width="60">
           <template #default="scope">
-            <el-button color="#597d3b" circle @click="openEditor(scope.row.descriptionFile, scope.row.assignmentName)">
+            <el-button   style="background-color: #6A9BD8; color: white;" circle @click="openEditor(scope.row.descriptionFile, scope.row.assignmentName)">
               <el-icon :size="18">
                 <Search />
               </el-icon>
@@ -32,7 +32,7 @@
         <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,scope.row.type)" :disabled="props.disabled || isDisabled(scope.row.teacherId, scope.row.endDate)">
+                style="background-color: #6A9BD8; color: white;" 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>
@@ -70,7 +70,7 @@
     </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>
+      <el-button style="background-color: #6A9BD8; color: white;" type="primary" @click="createAssignmentDialog()">确定</el-button>
     </div>
   </el-dialog>
   <ViewsWordEditor :alert="alertProps" :docxInfo="docxInfo"></ViewsWordEditor>
@@ -235,23 +235,34 @@ const openEditor = (url: string, title: string) => {
   /*
   鼠标聚焦于功能按钮(写作or编辑)时,显示提示信息
    */
+  let lastTriggerTime = 0;
+
+  // 防抖函数
+  const throttleMessage = () => {
+    const currentTime = Date.now();
+    
+    // 如果距离上次触发时间小于5秒,则不执行
+    if (currentTime - lastTriggerTime < 5000) {
+      return false;
+    }
+    
+    // 更新触发时间并执行
+    lastTriggerTime = currentTime;
+    return true;
+  };
   let hover = false
   const handleMouse = (type: string, teacherId:string, endDate:string) => {
     if(!hover && type == 'over'){
        hover = true;
        //如果被禁用则提示
-      if(props.disabled || isDisabled(teacherId, endDate)){
+      if((props.disabled || isDisabled(teacherId, endDate))&&throttleMessage()){
         if(props.role == 'student'){
-          if(props.disabled){
-            ElMessage.error('请先加入课程')
-          } else {
-            ElMessage.error('作业已经超过截止时间')
-          }
+          ElMessage.warning('请先加入课程')
         }else if(props.role == 'teachers'){
-          ElMessage.error('无权限')
+          ElMessage.warning('无权限')
         }
       }
-    } else if(hover && type == 'leave'){
+    } else if(hover && type == 'leave'){                    
       hover = false
     }
   }
@@ -272,7 +283,9 @@ const openEditor = (url: string, title: string) => {
   }
 
   const handleWriting = (assignmentId:number) => {
-    router.push(`/home/homeworkDetail/${assignmentId}?fromCourse=1`);
+    router.push(`/home/homeworkDetail/${assignmentId}?fromCourse=1`).then(() => {
+      window.location.reload()
+    });
     // Location.reload()
   }
 
@@ -284,9 +297,8 @@ const openEditor = (url: string, title: string) => {
       if(+teacherId != store.user.id){
         return true
       }
-    } else { // 是学生且超时则禁用
-      if(new Date() > new Date(endDate)) return true
     }
+    // 学生不再因为超过截止日期而禁用按钮
     return false
   }
 

+ 15 - 6
src/views/CourseSquare/component/CourseDetails/index.vue

@@ -28,11 +28,11 @@
         <div>
           <template v-if="showCode && role == 'student'">
             <el-input v-model="enrollCode" placeholder="输入邀请码" type="text" style="width: 150px;margin-right: 20px"></el-input>
-            <el-button color="#597d3b" @click="enroll">加入课程</el-button>
+            <el-button @click="enroll" style="background-color: #409EFF; border-color: #409EFF; color: white;" >加入课程</el-button>
           </template>
           <template v-if="showEdit && role == 'teachers'">
-            <el-button color="#597d3b" @click="visible = true; fileList[0] = {name: '壁纸', url: newCourseInfo.courseImages[0]}">编辑课程</el-button>
-            <el-button color="#4a90c4" style="color: #fff" @click="$router.push(`/home/courseClass/${courseId}`)">班级管理</el-button>
+            <el-button style="background-color: #4a90c4 ;color: white" @click="visible = true; fileList.value = newCourseInfo.courseImages[0] ? [{ name: '壁纸', url: newCourseInfo.courseImages[0], uid: Date.now(), status: 'success' }] : []">编辑课程</el-button>
+            <el-button  style="background-color: #4a90c4 ;color: white" @click="$router.push(`/home/courseClass/${courseId}`)">班级管理</el-button>
           </template>
         </div>
       </div>
@@ -95,6 +95,7 @@
             :on-error="handleError"
             :on-exceed="handleExceed"
             :on-change="handleChange"
+            :on-remove="handleRemove"
             list-type="picture-card"
             accept=".png, .jpg"
             :auto-upload="false"
@@ -124,7 +125,7 @@
 </template>
 
 <script setup lang="ts">
-import {onMounted, reactive, ref} from 'vue';
+import {onMounted, reactive, ref, nextTick} from 'vue';
 import {
   ElTabs,
   ElTabPane,
@@ -291,6 +292,14 @@ const handleError: UploadProps['onError'] = (error: Error, uploadFile: UploadFil
   console.log(error, uploadFile, uploadFiles)
 }
 
+const handleRemove: UploadProps['onRemove'] = async (uploadFile: UploadFile, uploadFiles: UploadFiles) => {
+  // 删除图片后,将图片设置为空字符串,后端会处理为空
+  newCourseInfo.courseImages[0] = ''
+  await nextTick()
+  // 清空文件列表
+  fileList.value = []
+}
+
 const handleExceed = (files, fileList) => {
   // console.log(files)
   uploadRef.value.clearFiles()  //清空上传文件(限制一个,所以直接清空即可)
@@ -596,7 +605,7 @@ onMounted(() => {
 
 <style scoped>
 ::v-deep .el-tabs__item {
-  color:#597D3B;
+  color:#6A9BD8;
   opacity: 1;
 }
 
@@ -612,7 +621,7 @@ onMounted(() => {
 
 /* 下划线颜色 */
 ::v-deep .el-tabs__active-bar {
-  background-color: #597D3B;
+  background-color: #6A9BD8;
 }
 
 :deep(.el-upload-dragger) {

+ 242 - 57
src/views/EditPage/EditPage.vue

@@ -6,15 +6,30 @@
 <template>
 
   <div class="my-work" >
-
     <div class="my-work-slider">
-      <el-button class="back" color="#4A90C4" style="color: #FFFFFF;" @click="handleBack">返回</el-button>
-      <AIChatter :dialogueId="dataForm.dialogueId" :messages="dataForm.messages" :is-teacher="false" />
+      <el-button class="back" color="#4A90C4" style="color: #FFFFFF;" @click="handleBack" :disabled="isExamMode">返回</el-button>
+      <AIChatter
+        :dialogue-id="dataForm.dialogueId"
+        :messages="dataForm.messages"
+        :is-teacher="false"
+        :assignment-id="assignmentId"
+        :user-id="store.user.id"
+      />
       <Dictionaries />
     </div>
+    <div
+      v-if="isExamMode"
+      class="exam-countdown-banner"
+      :style="{ top: bannerTop + 'px', right: bannerRight + 'px' }"
+      @mousedown="startDrag"
+    >
+      <el-tag type="warning">考试模式</el-tag>
+      <span class="countdown-time">{{ formattedTime }}</span>
+    </div>
     <div class="my-work-input">
       <Edit
           :engagement="dataForm.engagement"
+          :is-exam-mode="isExamMode"
       />
     </div>
     <Slider :dialogueId="dataForm.dialogueId" :engagement="dataForm.engagement"/>
@@ -27,19 +42,20 @@
   import useApis from "@/apis";
   import {useStore} from "@/store";
   import router from "@/router"
-  import {onMounted, onUnmounted, reactive} from "vue";
-  import type {engagementVO} from "@/types/vo";
-  import type {IDialogue} from "@/types/dialogue.ts";
+  import {onMounted, onUnmounted, reactive, computed, ref, watch} from "vue";
+  import type {engagementVO, IAssignment, ExamTimeVO} from "@/types/vo";
+  import type {IDialogue} from "@/types/dialogue";
   import Edit from "@/views/Home/component/Edit/index.vue";
   import Slider from '@/views/Home/component/Silder/index.vue'
   import "./index.scss"
   import {useRoute} from 'vue-router'
-  import {ElMessageBox} from "element-plus";
+  import {ElMessageBox, ElMessage} from "element-plus";
   import IndexedDB from "@/utils/indexedDBUtil";
   import {isModified, registerAllEventListeners, removeAllEventListeners} from "@/views/EditPage/userWritingRecord";
   import emitter from "@/utils/emitter";
+  import {useExamCountdown} from "@/hooks/useExamCountdown";
+  import dayjs from "dayjs";
 
-  // 获得路由中的assignmentId
   const route = useRoute()
   const assignmentId = Number(route.params.assignmentId)
 
@@ -50,13 +66,152 @@
   const dataForm = reactive<{
     messages: IDialogue[],
     dialogueId: string;
-    engagement: engagementVO
+    engagement: engagementVO;
+    assignment: IAssignment;
   }>({
     messages: [],
     dialogueId: '',
-    engagement: {} as engagementVO
+    engagement: {} as engagementVO,
+    assignment: {} as IAssignment
+  })
+
+  const examTimeInfo = ref<ExamTimeVO | null>(null)
+  const isExamMode = computed(() => dataForm.assignment?.examMode ?? false)
+
+  let getHTML = ref("")
+  let getText = ref("")
+  let getQuillContent = reactive({})
+
+  const bannerTop = ref(50)
+  const bannerRight = ref(20)
+  let isDragging = false
+  let startX = 0
+  let startY = 0
+  let startRight = 0
+  let startTop = 0
+
+  const startDrag = (e: MouseEvent) => {
+    isDragging = true
+    startX = e.clientX
+    startY = e.clientY
+    startRight = bannerRight.value
+    startTop = bannerTop.value
+    document.addEventListener('mousemove', onDrag)
+    document.addEventListener('mouseup', stopDrag)
+  }
+
+  const onDrag = (e: MouseEvent) => {
+    if (!isDragging) return
+    const deltaX = startX - e.clientX
+    const deltaY = e.clientY - startY
+    bannerRight.value = startRight + deltaX
+    bannerTop.value = startTop + deltaY
+  }
+
+  const stopDrag = () => {
+    isDragging = false
+    document.removeEventListener('mousemove', onDrag)
+    document.removeEventListener('mouseup', stopDrag)
+  }
+
+  emitter.on('get-html', (value: string) => {
+    getHTML.value = value
+  })
+
+  emitter.on('get-text', (value: string) => {
+    getText.value = value
   })
 
+  emitter.on('get-quill-content', (value: Object) => {
+    Object.assign(getQuillContent, value)
+  })
+
+  const handleStage = async () => {
+    try {
+      const _res = await apis.engagementStage(store.user.id, assignmentId, JSON.stringify(getQuillContent), getText.value, getHTML.value);
+      emitter.emit('click-stage');
+      emitter.emit('is-modified', false);
+      emitter.emit('stage-success');
+      ElMessage.success(_res.data.data);
+      return _res;
+    } catch (error) {
+      console.error('暂存失败', error);
+      ElMessage.error('暂存失败');
+      throw error;
+    }
+  };
+
+  const handleSubmit = async () => {
+    const endTime = dayjs(dataForm.assignment.endTime);
+    const currentTime = dayjs();
+    let supplementarySubmission = false
+    if (currentTime.isAfter(endTime)) {
+      supplementarySubmission = true
+    }
+    try {
+      await apis.engagementSubmit(store.user.id, assignmentId, supplementarySubmission)
+      ElMessage.success('提交成功');
+      router.push("/home/myHomework").then(() => {
+        window.location.reload()
+      })
+    } catch (error) {
+      console.error('提交失败', error);
+      ElMessage.error('提交失败');
+    }
+  };
+
+  let countdownController: ReturnType<typeof useExamCountdown> | null = null
+
+  const initCountdown = () => {
+    if (!examTimeInfo.value || examTimeInfo.value.remainingSeconds <= 0) return
+
+    countdownController = useExamCountdown({
+      startTime: new Date(),
+      duration: 0,
+      initialRemainingSeconds: examTimeInfo.value.remainingSeconds,
+      onAutoSubmit: async () => {
+        ElMessage.warning('考试时间已到,正在自动提交...');
+        emitter.emit('exam-mode-change', false);
+        try {
+          await handleStage();
+          await handleSubmit();
+        } catch {
+          ElMessage.error('自动交卷失败,请手动提交');
+        }
+      },
+      onWarning: () => {
+        ElMessage.success('距离考试结束还有5分钟,请注意暂存您的内容!');
+      }
+    });
+
+    triggerUpdate.value++;
+  }
+
+  const triggerUpdate = ref(0);
+
+  watch(examTimeInfo, (newVal) => {
+    if (newVal && isExamMode.value && !countdownController) {
+      initCountdown();
+    }
+  }, { immediate: true });
+
+  const isWarning = computed(() => {
+    triggerUpdate.value;
+    return countdownController?.isWarning.value ?? false;
+  });
+  const formattedTime = computed(() => {
+    triggerUpdate.value;
+    return countdownController?.formattedTime.value ?? '00:00:00';
+  });
+
+  const startCountdown = () => {
+    countdownController?.start();
+  }
+
+  const stopCountdown = () => {
+    countdownController?.stop();
+  }
+
   const handleBack = () => {
     if(isModified.value){
       ElMessageBox.confirm(
@@ -70,74 +225,104 @@
       )
           .then(() => {
             router.push(`/home/homeworkDetail/${assignmentId}`)
+          }).then(() => {
+            window.location.reload()
           })
           .catch(() => {
             return
           })
     }else {
       router.push(`/home/homeworkDetail/${assignmentId}`)
+      .then(() => {
+        window.location.reload()
+      })
     }
   }
 
-  // 获取数据
-  async function fetchData(){
-    await apis.getAIDialogues(assignmentId, store.user.id)
-        .then((_res:any) => {
-          const data = _res.data.data
-          Object.assign(dataForm, {
-            dialogueId: data == null ? '': data?.dialogueId,
-            messages: data == null ? []: data?.messages.content,
-            engagement: dataForm.engagement
-          })
-
-        })
+  async function fetchAssignmentData() {
+    return apis.getAssignmentById(assignmentId)
+      .then((res: any) => {
+        dataForm.assignment = res.data.data;
+      });
+  }
 
-    await apis.getEngagement(store.user.id, assignmentId)
-        .then((_res:any) => {
-          console.log(_res)
-          Object.assign(dataForm, {
-            dialogueId: dataForm.dialogueId,
-            messages: dataForm.messages,
-            engagement: _res.data.data
-          })
-          // if(!dataForm.engagement){
-          //   fetchData()
-          // }
-        }).catch((_err:any) => {
-          console.log("错误", _err)
-        })
+  async function fetchExamTime() {
+    return apis.getExamTime(store.user.id, assignmentId)
+      .then((res: any) => {
+        examTimeInfo.value = res.data.data;
+        dataForm.engagement.submitted = examTimeInfo.value?.submitted ?? false;
+      })
+      .catch((err: any) => {
+        console.log('获取考试时间失败', err);
+      });
   }
 
-  // 保存AI对话内容
-  const saveAIDialogue = async () => {
-    if (dataForm.dialogueId && dataForm.messages.length > 0) {
-      try {
-        await apis.requestAI(dataForm.dialogueId, 'ChatGLM4', dataForm.messages)
-        console.log('AI对话内容已保存')
-      } catch (error) {
-        console.error('保存AI对话失败:', error)
-      }
+  async function fetchData() {
+    // 与串行相比,总耗时任取 max 而非 sum,减轻超时概率
+    const [aiResult, enResult] = await Promise.allSettled([
+      apis.getAIDialogues(assignmentId, store.user.id),
+      apis.getEngagement(store.user.id, assignmentId)
+    ])
+
+    if (aiResult.status === 'fulfilled') {
+      const data = (aiResult.value as { data: { data: any } })?.data?.data
+      Object.assign(dataForm, {
+        dialogueId: data == null ? '' : data?.dialogueId,
+        messages: data == null ? [] : data?.messages?.content,
+        engagement: dataForm.engagement
+      })
+    } else {
+      console.log('getAIDialogues 失败', aiResult.reason)
+    }
+
+    if (enResult.status === 'fulfilled') {
+      const _res = (enResult as PromiseFulfilledResult<{ data: { data: engagementVO } }>).value
+      console.log(_res)
+      Object.assign(dataForm, {
+        dialogueId: dataForm.dialogueId,
+        messages: dataForm.messages,
+        engagement: _res.data.data
+      })
+    } else {
+      console.log('getEngagement 错误', (enResult as PromiseRejectedResult).reason)
     }
   }
 
-  // 监听提交前的保存对话事件
-  emitter.on('save-ai-dialogue-before-submit', async () => {
-    await saveAIDialogue()
+  emitter.on('exam-submitted', () => {
+    dataForm.engagement.submitted = true;
+    examTimeInfo.value = examTimeInfo.value ? { ...examTimeInfo.value, submitted: true } : null;
   })
 
-  onMounted( () => {
-    fetchData()
-    // indexedDB.initDB("eventId")
-    registerAllEventListeners(indexedDB);
+  onMounted(async () => {
+    // 首屏三路与 fetchData 内两路均并行,避免墙钟时间累加
+    await Promise.all([
+      fetchAssignmentData(),
+      fetchData(),
+      fetchExamTime()
+    ])
+
+    if (isExamMode.value && dataForm.engagement.submitted) {
+      ElMessage.warning('您已完成交卷,无法再次编辑');
+      router.push(`/home/homeworkDetail/${assignmentId}`);
+      return;
+    }
+
+    if (isExamMode.value) {
+      emitter.emit('exam-mode-change', true);
+    }
+
+    if (isExamMode.value && examTimeInfo.value && examTimeInfo.value.remainingSeconds > 0) {
+      initCountdown();
+      startCountdown();
+    }
+
+    registerAllEventListeners(indexedDB, store.user.id, assignmentId);
   })
 
   onUnmounted(() => {
-    // indexedDB.clearDB()
-    // indexedDB.closeDB()
-    // indexedDB.deleteDBAll()
+    stopCountdown();
     removeAllEventListeners();
-    // 清理事件监听器
-    emitter.off('save-ai-dialogue-before-submit');
+    emitter.emit('exam-mode-change', false);
   })
 
 </script>

+ 31 - 0
src/views/EditPage/index.scss

@@ -23,6 +23,37 @@
     width: 100%;
     flex: 1;
     max-width: 100%;
+    margin-right: -10px;
+  }
+}
+
+.exam-countdown-banner {
+  position: fixed;
+  top: 50px;
+  right: 20px;
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 10px 24px;
+  background: linear-gradient(135deg, #4A90C4 0%, #7EB5E1 100%);
+  color: white;
+  font-size: 15px;
+  z-index: 1001;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(74, 144, 196, 0.3);
+  cursor: move;
+  user-select: none;
+
+  .countdown-time {
+    font-size: 26px;
+    font-weight: bold;
+    font-family: 'Monaco', 'Menlo', monospace;
+    letter-spacing: 3px;
+    color: #ffffff;
+    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+  }
 
+  .el-tag {
+    font-size: 13px;
   }
 }

+ 87 - 20
src/views/EditPage/userWritingRecord.ts

@@ -1,22 +1,28 @@
 import emitter from '@/utils/emitter';
 import IndexedDB from '@/utils/indexedDBUtil';
-import { ElMessage } from 'element-plus';
 import {ref} from "vue";
+import useApis from '@/apis';
 
-export let isModified = ref(false)
+export const isModified = ref(false)
 
-// 处理word区鼠标移动的记录
-// export const setupWordMouseMoveListener = (indexedDB: IndexedDB) => {
-//     emitter.on("word-mouse-move", (event: any) => {
-//         // indexedDB.addData(event)
-//         // console.log(event);
-//     });
-// };
+// ──────────────────────────────────────────────
+// 窗口切换追踪状态
+// ──────────────────────────────────────────────
+interface SwitchEvent {
+    eventType: 'hidden' | 'visible';
+    timestamp: string;
+}
 
+let _switchEvents: SwitchEvent[] = [];
+let _switchCount = 0;
+
+// 保持对 visibilitychange 监听器的引用,以便卸载时精确移除
+let _visibilityChangeHandler: (() => void) | null = null;
 
-// 处理AI对话点击发送按钮事件的监听函数
-export const setupClickAISendButtonListener = (indexedDB: IndexedDB) => {
 
+// 处理AI对话点击发送按钮事件的监听函数
+export const setupClickAISendButtonListener = (_indexedDB: IndexedDB) => {
+    // 预留扩展
 };
 
 // 处理is-modified事件的监听函数
@@ -26,20 +32,81 @@ export const setupIsModifiedListener = () => {
     });
 };
 
-// 可以根据后续需要继续添加更多的事件监听设置函数
+/**
+ * 设置窗口切换(document.visibilitychange)监听器。
+ * 每当学生切离/切回页面时记录事件;
+ * 当学生点击「暂存」(或提交前自动暂存)时,通过 click-stage 事件触发上报并重置计数器。
+ */
+export const setupWindowSwitchListener = (studentId: number, assignmentId: number) => {
+    const apis = useApis();
+
+    _visibilityChangeHandler = () => {
+        const eventType: 'hidden' | 'visible' =
+            document.visibilityState === 'hidden' ? 'hidden' : 'visible';
+        if (eventType === 'hidden') {
+            _switchCount++;
+        }
+        _switchEvents.push({
+            eventType,
+            timestamp: new Date().toISOString(),
+        });
+    };
+
+    document.addEventListener('visibilitychange', _visibilityChangeHandler);
+
+    // 监听暂存成功事件(提交时也会先触发暂存,因此一并覆盖)
+    emitter.on('click-stage', () => {
+        if (_switchCount === 0) return;
+
+        const eventsToReport = [..._switchEvents];
+        const countToReport = _switchCount;
+
+        // 立即重置,避免重复上报
+        _switchEvents = [];
+        _switchCount = 0;
 
-// 统一注册所有事件监听的函数
-export const registerAllEventListeners = (indexedDB: IndexedDB) => {
-    // 后续若有其他事件监听,在此处添加调用对应的设置函数即可
+        apis.reportWindowSwitch(studentId, assignmentId, countToReport, eventsToReport)
+            .catch((err: any) => {
+                console.error('[窗口切换] 上报失败:', err);
+            });
+    });
+};
+
+// ──────────────────────────────────────────────
+// 统一注册 / 移除
+// ──────────────────────────────────────────────
+
+/**
+ * 统一注册所有事件监听。
+ * @param indexedDB  IndexedDB 实例
+ * @param studentId  当前学生 ID(用于窗口切换上报)
+ * @param assignmentId 当前作业 ID(用于窗口切换上报)
+ */
+export const registerAllEventListeners = (
+    indexedDB: IndexedDB,
+    studentId?: number,
+    assignmentId?: number
+) => {
     setupClickAISendButtonListener(indexedDB);
     setupIsModifiedListener();
-    // setupWordMouseMoveListener(indexedDB)
+    if (studentId !== undefined && assignmentId !== undefined) {
+        setupWindowSwitchListener(studentId, assignmentId);
+    }
 };
 
-// 统一移除所有事件监听的函数
+/** 统一移除所有事件监听,并重置窗口切换状态 */
 export const removeAllEventListeners = () => {
-    // 后续如有更多事件监听,在此处添加移除对应事件的代码
     emitter.off("click-ai-send-button");
     emitter.off("is-modified");
-    // emitter.off("word-mouse-move");
-};
+    emitter.off("click-stage");
+    isModified.value = false;
+
+    if (_visibilityChangeHandler) {
+        document.removeEventListener('visibilitychange', _visibilityChangeHandler);
+        _visibilityChangeHandler = null;
+    }
+
+    // 重置切换追踪状态
+    _switchEvents = [];
+    _switchCount = 0;
+};

+ 6 - 1
src/views/Home/component/ContentSelector/ContentSelector.vue

@@ -16,7 +16,12 @@
       <!-- 根据selectedContent的值动态显示内容 -->  
       <div v-if="selectedContent === 0" class="content-item">  
         <!-- TODO:调取AI对话内容,并显示在这里 -->  
-        <AIChatter :dialogueId="dataForm.dialogueId" :messages="dataForm.messages" />  
+        <AIChatter
+          :dialogue-id="dataForm.dialogueId"
+          :messages="dataForm.messages"
+          :assignment-id="assignmentId"
+          :user-id="store.user.id"
+        />
       </div>  
     </div>  
   </div>  

+ 9 - 0
src/views/Home/component/Dictionaries/index.scss

@@ -14,6 +14,15 @@
     flex: 1;
     padding: 15px;
     box-sizing: border-box;
+  }
 
+  .dic-result {
+    white-space: pre-wrap;
+    overflow-y: auto;
+    flex: 1;
+    margin-top: 10px;
+    padding: 5px;
+    word-wrap: break-word;
+    word-break: break-word;
   }
 }

+ 1 - 1
src/views/Home/component/Dictionaries/index.vue

@@ -23,7 +23,7 @@
           </el-button>
         </template>
       </el-input>
-      <div style="white-space: pre-wrap;">{{ res }}</div>
+      <div :class="$style['dic-result']">{{ res }}</div>
     </div>
   </div>
 </template>

+ 24 - 6
src/views/Home/component/Edit/index.vue

@@ -26,7 +26,7 @@
   import router from '@/router'
   import useApis from "@/apis";
   import {useStore} from "@/store";
-  import {ElMessage} from "element-plus";
+  import {ElMessage, ElMessageBox} from "element-plus";
   import emitter from "@/utils/emitter";
   import QuillEditor from "@/components/QuillEditor/QuillEditor.vue";
   import {useRoute} from "vue-router";
@@ -34,10 +34,9 @@
 
   interface IEditProps {
     engagement: engagementVO
+    isExamMode?: boolean
   }
-  const props = defineProps<{
-    engagement: engagementVO
-  }>()
+  const props = defineProps<IEditProps>()
   const apis = useApis()
   const store = useStore()
 
@@ -81,15 +80,29 @@
 
   // 提交操作
   const handleSubmit = async () => {
+    if (props.isExamMode) {
+      try {
+        await ElMessageBox.confirm(
+          '考试模式下提交后将无法再次修改,您确定要提交吗?',
+          '确认交卷',
+          {
+            confirmButtonText: '确认提交',
+            cancelButtonText: '取消',
+            type: 'warning',
+          }
+        );
+      } catch {
+        return;
+      }
+    }
     console.log(props)
-    // 先自动暂存
     try {
       await handleStage();
    } catch (e) {
       ElMessage.error('暂存失败,无法提交!');
       return;
     }
-    const endTime = dayjs(data.assignment.endTime); // 假设截止时间在 assignment 的 endTime 属性中
+    const endTime = dayjs(data.assignment.endTime);
     const currentTime = dayjs();
     let supplementarySubmission = false
     if (currentTime.isAfter(endTime)) {
@@ -98,6 +111,8 @@
     apis.engagementSubmit(store.user.id, props.engagement.assignmentId, supplementarySubmission)
     .then((_res: any) => {
       success(_res.data.data);
+      emitter.emit('exam-mode-change', false);
+      emitter.emit('exam-submitted');
       router.push("/home/myHomework").then(() => {
         window.location.reload()
       })
@@ -114,6 +129,9 @@
       console.log(props)
       const _res = await apis.engagementStage(store.user.id, props.engagement.assignmentId, JSON.stringify(getQuillContent), getText.value, getHTML.value);
       emitter.emit('click-stage');
+      emitter.emit('is-modified', false);
+      // 通知 Silder 组件暂存成功,可以启用批改按钮
+      emitter.emit('stage-success');
       success(_res.data.data);
       return _res;
     } catch (error) {

+ 408 - 26
src/views/Home/component/Silder/index.scss

@@ -1,17 +1,21 @@
 .slider-content {
   width: 20%;
-  margin-left: 20px;
-  box-shadow: rgba(0, 0, 0, 0.1) 0 4px 10px;
+  min-width: 240px;
+  max-width: 350px;
+  margin-left: 1.25rem; /* 20px */
+  box-shadow: rgba(0, 0, 0, 0.1) 0 0.25rem 0.625rem;
   display: flex;
   flex-direction: column;
-  transition: all .3s;
-  padding: 20px 0;
+  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+  padding: 1.25rem 0; /* 20px 0 */
   box-sizing: border-box;
   flex-shrink: 0;
   background-color: #ffffff;
+  overflow: hidden;
 
   &.shrink {
     width: 80px;
+    min-width: 80px;
     align-items: center;
 
     & .slider-icons {
@@ -21,79 +25,457 @@
 
     & .slider-item-box {
       padding: 0;
-      margin-top: 30px;
+      margin-top: 1.875rem; /* 30px */
     }
   }
 
   & .slider-icons {
-    margin-left: 10px;
+    margin-left: 0.625rem; /* 10px */
     cursor: pointer;
+    transition: all 0.2s ease;
+    
+    &:hover {
+      transform: scale(1.05);
+    }
   }
 
   .slider-item-box {
     display: flex;
     flex-direction: column;
-    gap: 20px;
-    margin-top: 20px;
-    cursor: pointer;
-    padding: 0 15px 15px 15px;
+    gap: 1.25rem; /* 20px */
+    margin-top: 1.25rem; /* 20px */
+    padding: 0 0.9375rem 0.9375rem 0.9375rem; /* 0 15px 15px 15px */
     box-sizing: border-box;
     height: 100%;
+    overflow-y: auto;
+    scrollbar-width: thin;
+    scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
+  }
+  
+  /* Webkit浏览器滚动条美化 */
+  .slider-item-box::-webkit-scrollbar {
+    width: 6px;
+  }
+
+  .slider-item-box::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  .slider-item-box::-webkit-scrollbar-thumb {
+    background: rgba(0, 0, 0, 0.2);
+    border-radius: 3px;
+  }
+
+  .slider-item-box::-webkit-scrollbar-thumb:hover {
+    background: rgba(0, 0, 0, 0.3);
   }
 
 
   .slider-tag {
     display: flex;
     flex-direction: column;
-    gap: 10px;
+    gap: 0.625rem; /* 10px */
 
     .slider-tag-item {
       background-color: #4A90C4;
       color: #FFFFFF;
-      padding: 8px 0;
+      padding: 0.5rem 0; /* 8px 0 */
       display: flex;
       justify-content: center;
-      font-size: 14px;
+      font-size: 0.875rem; /* 14px */
       border-radius: 1000px;
       cursor: pointer;
+      transition: all 0.2s ease;
+      white-space: nowrap;
+      
+      &:hover {
+        background-color: #3a7aa4;
+        transform: translateY(-2px);
+        box-shadow: 0 4px 8px rgba(74, 144, 196, 0.3);
+      }
+      
+      &:active {
+        transform: translateY(0);
+      }
+      
+      &.disabled {
+        background-color: #cccccc;
+        color: #999999;
+        cursor: not-allowed;
+        opacity: 0.6;
+        
+        &:hover {
+          background-color: #cccccc;
+          transform: none;
+          box-shadow: none;
+        }
+        
+        &:active {
+          transform: none;
+        }
+      }
     }
   }
 
   .capacity-item {
     width: 100%;
-    height: 40vh;
-    background-color: #d9d9d9;
-    border-radius: 10px;
+    height: 48vh;
+    min-height: 300px;
+    max-height: 600px;
+    background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+    border-radius: 0.75rem; /* 12px */
+    box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08);
+    transition: all 0.3s ease;
+
+    &:hover {
+      box-shadow: 0 0.375rem 1rem rgba(0, 0, 0, 0.12);
+    }
 
     .remark {
       box-sizing: border-box;
       width: 100%;
       height: 100%;
-      //max-height: 70vh;
-      border-radius: 10px;
-      overflow-y: auto; /* 添加垂直滚动 */
-      white-space: pre-wrap; /* 保留换行和空格,允许自动换行 */
-      //background-color: #f0f0f0;
-      padding: 10px;
-      //background-color: rgba(255, 75, 75, 0.3);
+      border-radius: 0.75rem; /* 12px */
+      overflow-y: auto;
+      overflow-x: hidden;
+      white-space: pre-wrap;
+      word-wrap: break-word;
+      word-break: break-word;
+      padding: 1rem; /* 16px */
+      line-height: 1.8;
+      color: #2c3e50;
+      font-size: 0.875rem; /* 14px */
+      background-color: rgba(255, 255, 255, 0.6);
+      backdrop-filter: blur(10px);
+      
+      /* 自定义滚动条样式 */
+      &::-webkit-scrollbar {
+        width: 6px;
+      }
+      
+      &::-webkit-scrollbar-track {
+        background: rgba(0, 0, 0, 0.05);
+        border-radius: 10px;
+      }
+      
+      &::-webkit-scrollbar-thumb {
+        background: rgba(74, 144, 196, 0.5);
+        border-radius: 10px;
+        transition: background 0.3s ease;
+        
+        &:hover {
+          background: rgba(74, 144, 196, 0.8);
+        }
+      }
+
+      .remark-empty {
+        height: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        color: #6b7280;
+        text-align: center;
+        padding: 0 0.75rem;
+      }
     }
   }
 
+  .correct-rule-tip {
+    margin-top: -0.5rem;
+    margin-bottom: 0.25rem;
+    padding: 0.5rem 0.625rem;
+    border-left: 4px solid #409eff;
+    border-radius: 0.5rem;
+    background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);
+    color: #1d4ed8;
+    font-size: 0.75rem;
+    line-height: 1.45;
+    font-weight: 500;
+  }
+
 }
 
 
 .slider-item {
-  font-size: 14px;
+  font-size: 0.875rem; /* 14px */
   width: 40px;
   height: 40px;
+  min-width: 40px;
+  min-height: 40px;
   display: flex;
   align-items: center;
   justify-content: center;
   background-color: #d9d9d9;
   border-radius: 100px;
-  box-shadow: rgba(0, 0, 0, 0.1) 0 4px 10px;
+  box-shadow: rgba(0, 0, 0, 0.1) 0 0.25rem 0.625rem;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  
+  &:hover {
+    background-color: #c0c0c0;
+    transform: scale(1.05);
+    box-shadow: rgba(0, 0, 0, 0.15) 0 0.375rem 0.875rem;
+  }
+  
+  &:active {
+    transform: scale(0.98);
+  }
 }
 
 .edit-text {
-  margin-left: 10px;
+  margin-left: 0.625rem; /* 10px */
+  font-size: 0.875rem; /* 14px */
+}
+
+/* 平板设备 (≤1440px) */
+@media (max-width: 1440px) {
+  .slider-content {
+    width: 22%;
+    min-width: 220px;
+    max-width: 320px;
+    margin-left: 1rem;
+    padding: 1rem 0;
+
+    .slider-item-box {
+      gap: 1rem;
+      margin-top: 1rem;
+      padding: 0 0.75rem 0.75rem 0.75rem;
+    }
+
+    .slider-tag .slider-tag-item {
+      font-size: 0.8125rem; /* 13px */
+      padding: 0.4rem 0;
+    }
+
+    .capacity-item {
+      height: 45vh;
+      min-height: 250px;
+
+      .remark {
+        font-size: 0.8125rem; /* 13px */
+        padding: 0.875rem;
+      }
+    }
+  }
+
+  .slider-item {
+    width: 36px;
+    height: 36px;
+    min-width: 36px;
+    min-height: 36px;
+    font-size: 0.8125rem;
+  }
+}
+
+/* 小屏幕 (≤1024px) */
+@media (max-width: 1024px) {
+  .slider-content {
+    width: 24%;
+    min-width: 200px;
+    max-width: 260px;
+    margin-left: 0.75rem;
+    padding: 0.875rem 0;
+
+    .slider-item-box {
+      gap: 0.75rem;
+      margin-top: 0.75rem;
+      padding: 0 0.625rem 0.625rem 0.625rem;
+    }
+
+    .slider-tag .slider-tag-item {
+      font-size: 0.75rem; /* 12px */
+      padding: 0.375rem 0;
+    }
+
+    .capacity-item {
+      height: 42vh;
+      min-height: 220px;
+
+      .remark {
+        font-size: 0.75rem; /* 12px */
+        padding: 0.75rem;
+        line-height: 1.6;
+      }
+    }
+  }
+
+  .slider-item {
+    width: 32px;
+    height: 32px;
+    min-width: 32px;
+    min-height: 32px;
+    font-size: 0.75rem;
+  }
+
+  .edit-text {
+    margin-left: 0.5rem;
+    font-size: 0.75rem;
+  }
+}
+
+/* 平板竖屏或小笔记本 (≤900px) */
+@media (max-width: 900px) {
+  .slider-content {
+    width: 100%;
+    max-width: 100%;
+    margin-left: 0;
+    margin-top: 0.5rem;
+    padding: 0.75rem;
+    order: 1; /* 移到底部 */
+
+    &.shrink {
+      width: 100%;
+      min-width: 100%;
+      
+      & .slider-item-box {
+        padding: 0.5rem;
+        margin-top: 0.75rem;
+      }
+    }
+
+    .slider-item-box {
+      flex-direction: row;
+      flex-wrap: wrap;
+      gap: 0.5rem;
+      margin-top: 0.5rem;
+      padding: 0.5rem;
+      max-height: 50vh;
+    }
+
+    .slider-tag {
+      flex-direction: row;
+      flex-wrap: wrap;
+      width: 100%;
+
+      .slider-tag-item {
+        flex: 0 1 auto;
+        padding: 0.375rem 0.75rem;
+        min-width: 80px;
+      }
+    }
+
+    .capacity-item {
+      width: 100%;
+      height: auto;
+      min-height: 200px;
+      max-height: 300px;
+
+      .remark {
+        font-size: 0.8125rem;
+      }
+    }
+  }
+}
+
+/* 移动设备 (≤768px) */
+@media (max-width: 768px) {
+  .slider-content {
+    padding: 0.5rem;
+
+    .slider-item-box {
+      gap: 0.375rem;
+      padding: 0.375rem;
+    }
+
+    .slider-tag .slider-tag-item {
+      font-size: 0.6875rem; /* 11px */
+      padding: 0.3rem 0.5rem;
+      min-width: 70px;
+    }
+
+    .capacity-item {
+      min-height: 180px;
+      max-height: 250px;
+
+      .remark {
+        font-size: 0.75rem;
+        padding: 0.625rem;
+      }
+    }
+  }
+
+  .slider-item {
+    width: 28px;
+    height: 28px;
+    min-width: 28px;
+    min-height: 28px;
+    font-size: 0.6875rem;
+  }
+}
+
+/* 超大屏幕 (≥2560px) */
+@media (min-width: 2560px) {
+  .slider-content {
+    width: 18%;
+    min-width: 320px;
+    max-width: 450px;
+    margin-left: 1.5rem;
+    padding: 1.5rem 0;
+
+    .slider-item-box {
+      gap: 1.5rem;
+      margin-top: 1.5rem;
+      padding: 0 1.25rem 1.25rem 1.25rem;
+    }
+
+    .slider-tag .slider-tag-item {
+      font-size: 1rem; /* 16px */
+      padding: 0.625rem 0;
+    }
+
+    .capacity-item {
+      height: 50vh;
+      min-height: 400px;
+      max-height: 800px;
+
+      .remark {
+        font-size: 1rem; /* 16px */
+        padding: 1.25rem;
+        line-height: 2;
+      }
+    }
+  }
+
+  .slider-item {
+    width: 48px;
+    height: 48px;
+    min-width: 48px;
+    min-height: 48px;
+    font-size: 1rem;
+  }
+
+  .edit-text {
+    margin-left: 0.75rem;
+    font-size: 1rem;
+  }
+}
+
+/* 高对比度模式 */
+@media (prefers-contrast: high) {
+  .slider-content {
+    border: 2px solid currentColor;
+  }
+
+  .slider-item,
+  .capacity-item {
+    border: 2px solid currentColor;
+  }
+}
+
+/* 减少动画模式 */
+@media (prefers-reduced-motion: reduce) {
+  .slider-content,
+  .slider-item,
+  .slider-tag-item {
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: 0.01ms !important;
+  }
+}
+
+/* 打印样式 */
+@media print {
+  .slider-content {
+    display: none;
+  }
 }

+ 239 - 39
src/views/Home/component/Silder/index.vue

@@ -35,17 +35,50 @@
               <div class="slider-tag-item" @click="openEditor(data.assignment.descriptionFile, data.assignment.assignmentName)">作业要求</div>
             </template>
             <template v-if="data.assignment.attachments">
-              <div v-for="(item, index) in data.assignment.attachments" :key="index" class="slider-tag-item" @click="openEditor(item, '附件' + index)">作业描述文件下载</div>
+              <!-- <div v-for="(item, index) in data.assignment.attachments" :key="index" class="slider-tag-item" @click="openEditor(item, '附件' + index)">作业描述文件下载</div> -->
+              <el-tooltip 
+                v-for="(item, index) in data.assignment.attachments" 
+                :key="index" 
+                :content="!item || item.trim() === '' ? '暂无描述文件' : ''"
+                :disabled="item && item.trim() !== ''"
+                placement="top"
+              >
+                <div 
+                  class="slider-tag-item" 
+                  :class="{ 'disabled': !item || item.trim() === '' }"
+                  @click="item && item.trim() !== '' && openEditor(item, '附件' + index)"
+                >
+                  作业描述文件下载
+                </div>
+              </el-tooltip>
             </template>
           </div>
         </template>
         <div>智能批改</div> 
         <div class="edit-box-header">
           <el-button @click="handleCorrect" :disabled="!showCorrectBtn" @mouseover="handleCorrectTip">开始批改</el-button>
-          <el-button @click="handleCorrectToPage" :disabled="!showCorrectToPageBtn" @mouseenter="handleCorrectToPageTip">查看完整批改报告</el-button>
+          <el-button
+            v-if="showCorrectToPageBtn"
+            @click="handleCorrectToPage"
+          >
+            查看过往批改记录
+          </el-button>
+        </div>
+        <div class="correct-rule-tip">
+          提示:建议在提交前使用逐句批改,同一版本仅可批改一次。
         </div>
         <div class="capacity-item">
-          <div v-loading="loading" element-loading-text="智能批改中" class="remark">{{ data.overallEvaluation?.evaluation }}</div>
+          <div v-loading="loading" element-loading-text="智能批改中" class="remark">
+            <div v-if="data.overallEvaluation?.evaluation">
+              {{ data.overallEvaluation.evaluation }}
+            </div>
+            <div v-else class="remark-empty">
+              <el-empty
+                :image-size="96"
+                description="暂无批改结果,点击“开始批改”后在此查看智能批改内容。"
+              />
+            </div>
+          </div>
         </div>
       </template>
     </div>
@@ -54,7 +87,7 @@
 </template>
 
 <script lang="ts" setup>
-import {defineProps, nextTick, onMounted, reactive, ref, watch, watchEffect} from 'vue'
+import {defineProps, nextTick, onMounted, onUnmounted, reactive, ref, watch, watchEffect} from 'vue'
   import "./index.scss"
 import type {engagementVO, IAssignment} from "@/types/vo";
   import useApis from "@/apis";
@@ -65,7 +98,7 @@ import ViewsWordEditor from "@/components/ViewsWordEditor/ViewsWordEditor.vue";
 import {useRoute} from "vue-router";
 import {ElMessage} from "element-plus";
 import router from "@/router";
-import * as path from "node:path";
+import emitter from "@/utils/emitter";
 
 
   interface IPreviewProps {
@@ -85,7 +118,11 @@ import * as path from "node:path";
   let loading = ref(false)
   let open= ref(false)
   let showCorrectBtn = ref(false)
+  let canCorrectByVersion = ref(false)
   let showCorrectToPageBtn = ref(false)
+  let hasStagedContent = ref(false) // 用于跟踪是否已暂存内容
+  let hasUnsavedChanges = ref(false) // 用于跟踪是否有未保存改动
+  let manualCorrectRequested = ref(false) // 仅允许手动点击后触发批改
 
   // 获得路由中的assignmentId
   const route = useRoute()
@@ -128,28 +165,83 @@ import * as path from "node:path";
     });
   };
 
-  const getEvaluation = () => {
-    apis.getOverall(props.engagement.assignmentId, store.user.id,props.engagement.version)
+  const getEvaluation = (version?: number) => {
+    // 如果没有传入version,使用props中的version
+    const versionToUse = version !== undefined ? version : props.engagement.version;
+    
+    return apis.getOverall(props.engagement.assignmentId, store.user.id, versionToUse)
         .then(res => {
-          data.overallEvaluation = res.data.data
+          if (res.data.data) {
+            data.overallEvaluation = res.data.data;
+            console.log('成功获取评语:', data.overallEvaluation);
+          }
           loading.value = false;
+          return Boolean(res.data.data?.evaluation)
         })
         .catch(err => {
           ElMessage.error("获取智能批改结果异常")
           console.log(err)
           loading.value = false;
+          return false
         })
   }
 
+  const updateCorrectButtonState = () => {
+    showCorrectBtn.value = canCorrectByVersion.value && !hasUnsavedChanges.value
+  }
+
+  const isEvaluationTaskAccepted = (payload: any) => {
+    return payload?.code === 200
+      || payload?.status === 'accepted'
+      || payload?.data?.status === 'accepted'
+  }
+
+  const pollLatestEvaluationResult = (attempt = 0) => {
+    const maxAttempts = 20
+    const intervalMs = 1500
+
+    if (attempt >= maxAttempts) {
+      loading.value = false
+      ElMessage.warning("批改任务已提交,结果仍在生成中,请稍后查看")
+      return
+    }
+
+    apis.getEvaluationVersionList(props.engagement.assignmentId, store.user.id)
+      .then(async (versionRes: any) => {
+        const versionList = versionRes?.data?.data || []
+        if (!versionList.length) {
+          setTimeout(() => pollLatestEvaluationResult(attempt + 1), intervalMs)
+          return
+        }
+
+        const latestVersion = versionList[0].version
+        showCorrectToPageBtn.value = true
+        const hasEvaluation = await getEvaluation(latestVersion)
+        if (hasEvaluation) {
+          return
+        }
+
+        setTimeout(() => pollLatestEvaluationResult(attempt + 1), intervalMs)
+      })
+      .catch((err: any) => {
+        console.error('轮询批改结果失败:', err)
+        setTimeout(() => pollLatestEvaluationResult(attempt + 1), intervalMs)
+      })
+  }
+
   const toEvaluation = () => {
+    if (!manualCorrectRequested.value) {
+      return
+    }
+    manualCorrectRequested.value = false
+
     apis.overAllEvaluation(props.engagement.assignmentId, store.user.id)
         .then(res => {
-          if(res.data.code === 200){
-            getEvaluation()
-            // 允许查看详细报告
-            showCorrectToPageBtn.value = true;
-          }else{
-            ElMessage.error("智能批改请求超时")
+          if (isEvaluationTaskAccepted(res.data)) {
+            console.log('批改任务已提交,开始轮询结果...')
+            pollLatestEvaluationResult()
+          } else {
+            ElMessage.error(res.data?.message || "智能批改请求失败")
             loading.value = false;
           }
         })
@@ -162,28 +254,30 @@ import * as path from "node:path";
         .then(res => {
           if(res.data.code === 400){
             ElMessage.error(res.data.msg)
-          }else{
-            ElMessage.error("智能批改请求超时")
-            loading.value = false;
           }
         })
         .catch(err => {
-          ElMessage.error("智能批改请求异常")
+          ElMessage.error("句子批改请求异常")
           console.log(err)
         })
   }
 
   // 请求批改
   const handleCorrect = () => {
+    if (!showCorrectBtn.value) {
+      return
+    }
+    manualCorrectRequested.value = true
     loading.value = true;
-    showCorrectBtn.value = !showCorrectBtn.value
     toEvaluation()
   };
 
   const handleCorrectToPage = () => {
     router.push({
       path: `/home/assignment/${props.engagement.assignmentId}/AIEvaluation`,
-    })
+    }).then(() => {
+      window.location.reload()
+    });
   }
 
   const openEditor = (url: string, title: string) => {
@@ -213,27 +307,109 @@ import * as path from "node:path";
   };
 
   /**
-   * 用于判断是否已存在历史批改记录
+   * 优先用 /current 接口判断当前版本是否已有批改结果:
+   * - 有结果 → 直接展示,禁止重复批改
+   * - 无结果 → 再查历史列表,决定按钮状态
    */
   const getEvaluationVersionList = () => {
-    if(JSON.stringify(props.engagement) !== '{}'){
-      apis.getEvaluationVersionList(props.engagement.assignmentId, store.user.id)
+    if (JSON.stringify(props.engagement) === '{}') return
+
+    const hasContent = props.engagement.textContent && props.engagement.textContent.trim() !== ''
+    if (hasContent) {
+      hasStagedContent.value = true
+    }
+
+    apis.getCurrentVersionEvaluation(props.engagement.assignmentId, store.user.id)
+      .then((currentRes: any) => {
+        const currentEvaluation = currentRes?.data?.data
+        const currentVersion = currentEvaluation?.version
+        const overallStatus = currentEvaluation?.overallStatus ?? 0
+        const sentenceStatus = currentEvaluation?.sentenceStatus ?? 0
+
+        // /current 接口返回的是“状态”,不是“批改内容”
+        // 只有当状态显示已完成时,才认为该版本已批改;批改内容需用 /select/overall 获取
+        if (overallStatus === 1 || sentenceStatus === 1) {
+          canCorrectByVersion.value = false
+          showCorrectToPageBtn.value = true
+
+          // 优先展示整体评价内容(逐句内容在独立页面展示)
+          if (overallStatus === 1 && typeof currentVersion === 'number') {
+            console.info('[EVAL][FE][Silder] current has overall, fetch overall content', {
+              assignmentId: props.engagement.assignmentId,
+              userId: store.user.id,
+              version: currentVersion
+            })
+            apis.getOverall(props.engagement.assignmentId, store.user.id, currentVersion)
+              .then((res: any) => {
+                if (res?.data?.code === 200) {
+                  data.overallEvaluation = res.data.data
+                  console.info('[EVAL][FE][Silder] overall content loaded', {
+                    version: currentVersion,
+                    evalLen: res.data.data?.evaluation?.length ?? -1
+                  })
+                } else {
+                  data.overallEvaluation = null
+                  console.warn('[EVAL][FE][Silder] overall content non-200', {
+                    version: currentVersion,
+                    code: res?.data?.code,
+                    msg: res?.data?.msg
+                  })
+                }
+                updateCorrectButtonState()
+              })
+              .catch((err: any) => {
+                console.error('获取当前版本整体评价失败:', err)
+                data.overallEvaluation = null
+                updateCorrectButtonState()
+              })
+              .finally(() => {
+                // 避免 loading 遮罩导致用户误以为没有内容
+                loading.value = false
+              })
+          } else {
+            data.overallEvaluation = null
+            loading.value = false
+            updateCorrectButtonState()
+          }
+        } else {
+          // 当前版本尚未批改,查询历史列表决定按钮可用性
+          apis.getEvaluationVersionList(props.engagement.assignmentId, store.user.id)
+            .then(res => {
+              const list = res.data.data || []
+              if (list.length === 0) {
+                canCorrectByVersion.value = hasStagedContent.value
+                showCorrectToPageBtn.value = false
+              } else {
+                showCorrectToPageBtn.value = true
+                canCorrectByVersion.value = hasStagedContent.value
+              }
+              updateCorrectButtonState()
+            })
+            .catch(err => {
+              ElMessage.error("获取评价历史记录异常")
+              console.log(err)
+            })
+        }
+      })
+      .catch(err => {
+        console.error('检查当前版本批改状态异常,回退到历史列表查询:', err)
+        apis.getEvaluationVersionList(props.engagement.assignmentId, store.user.id)
           .then(res => {
-            // 从未批改过
-            if(res.data.data.length === 0){
-              showCorrectBtn.value = true
+            const list = res.data.data || []
+            if (list.length === 0) {
+              canCorrectByVersion.value = hasStagedContent.value
               showCorrectToPageBtn.value = false
             } else {
               showCorrectToPageBtn.value = true
-              showCorrectBtn.value = (res.data.data[0].version !== props.engagement.version)
+              canCorrectByVersion.value = (list[0].version !== props.engagement.version) && hasStagedContent.value
             }
+            updateCorrectButtonState()
           })
-          .catch(err => {
+          .catch(err2 => {
             ElMessage.error("获取评价历史记录异常")
-            console.log(err)
+            console.log(err2)
           })
-    }
-
+      })
   }
 
   // 添加节流函数
@@ -250,15 +426,16 @@ import * as path from "node:path";
 
   // 使用节流包装 handleCorrectTip
   const handleCorrectTip = throttle(() => {
-    if(!showCorrectBtn.value) {
-      ElMessage.error("请更新作文内容并暂存后批改");
+    if (!showCorrectBtn.value) {
+      if (hasUnsavedChanges.value) {
+        ElMessage.info("检测到未保存修改,请先暂存后再批改")
+      } else if (!canCorrectByVersion.value) {
+        ElMessage.info("当前版本已完成智能批改")
+      } else {
+        ElMessage.info("请更新作文内容并暂存后批改")
+      }
     }
-  }, 2000); // 2秒的节流时间
-
-  const handleCorrectToPageTip = throttle(() => {
-    if(!showCorrectToPageBtn.value)
-      ElMessage.error("批改后查看报告")
-  },1000);
+  }, 2000)
 
   // 监听props变化
   watch(() => props.engagement, (newEngagement, oldEngagement) => {
@@ -267,9 +444,32 @@ import * as path from "node:path";
     }
   }, { immediate: true });
 
+  // 监听暂存成功事件
+  const handleStageSuccess = () => {
+    console.log('收到暂存成功事件,刷新当前版本批改状态')
+    hasStagedContent.value = true
+    hasUnsavedChanges.value = false
+    // 暂存不会产生新版本:是否可再次批改取决于当前版本是否已批改
+    getEvaluationVersionList()
+  }
+
+  const handleModifiedStateChange = (state: boolean) => {
+    hasUnsavedChanges.value = Boolean(state)
+    updateCorrectButtonState()
+  }
+
+  emitter.on('stage-success', handleStageSuccess)
+  emitter.on('is-modified', handleModifiedStateChange)
+
   onMounted(() => {
     fetchData()
   });
+
+  onUnmounted(() => {
+    // 清理事件监听器
+    emitter.off('stage-success', handleStageSuccess);
+    emitter.off('is-modified', handleModifiedStateChange);
+  });
 </script>
 
 <script lang="ts">

+ 27 - 21
src/views/HomeworkPage/component/HomeworkDetail.vue

@@ -24,6 +24,13 @@
           <span style="font-weight: 600">截止日期: </span>
           {{new Date(Date.parse(data.endTime)).toLocaleString()}}
         </div>
+        <div v-if="data.examMode === true" class="item">
+          <span style="font-weight: 600">作业模式: </span>
+          <el-tag type="warning">考试模式</el-tag>
+          <span v-if="data.examDuration" style="margin-left: 10px; color: #409EFF; font-weight: bold;">
+            时长: {{ data.examDuration }}分钟
+          </span>
+        </div>
         <div class="item">
           <el-link type="primary" @click="downloadFile(data.descriptionFile, `${data.assignmentName}-描述文件`)">相关文件下载</el-link>
           <div class="button-container">
@@ -89,7 +96,9 @@ let data = reactive<IAssignment>({
   endTime: "",
   createTime: "",
   status: "",
-  type:""
+  type:"",
+  examMode: false,
+  examDuration: undefined
 })
 
 // 学生作业参与状态
@@ -101,22 +110,20 @@ const isAssignmentDisabled = computed(() => {
   const startTime = new Date(data.startTime)
   const endTime = new Date(data.endTime)
 
-  // 如果当前时间小于开始时间,则禁用
   if (now < startTime) {
     return true
   }
 
-  // 如果当前时间大于结束时间
-  if (now > endTime) {
-    // 超过截止日期时,只有未完成状态的学生可以点击
-    if (engagementStatus.value) {
-      return engagementStatus.value.status !== 'NOT_SUBMITTED'
+  // 普通作业允许多次进入并再次提交;仅考试模式限制已提交/超时后再次进入
+  if (data.examMode === true) {
+    if (engagementStatus.value?.submitted) {
+      return true
+    }
+    if (now > endTime) {
+      return true
     }
-    // 如果没有参与状态信息,允许点击(首次参与)
-    return false
   }
 
-  // 在作业时间范围内,不禁用
   return false
 })
 
@@ -126,20 +133,19 @@ const getButtonTooltip = computed(() => {
   const startTime = new Date(data.startTime)
   const endTime = new Date(data.endTime)
 
+  if (data.examMode === true && engagementStatus.value?.submitted) {
+    return '您已完成交卷,无法再次编辑'
+  }
+
   if (now < startTime) {
     return `作业尚未开始,开始时间:${startTime.toLocaleString()}`
-  } else if (now > endTime) {
-    if (engagementStatus.value) {
-      const status = engagementStatus.value.status
-      if (status === 'NOT_SUBMITTED') {
-        return '作业已截止,但您可以继续完成作业'
-      } else if (status === 'SUBMITTED') {
-        return '作业已截止且已提交,无法再次编辑'
-      } else if (status === 'CORRECTED') {
-        return '作业已截止且已批改,无法再次编辑'
-      }
+  } else if (data.examMode === true && now > endTime) {
+    return '考试已截止,无法再次进入'
+  } else if (!data.examMode && now > endTime) {
+    if (engagementStatus.value?.status === 'NOT_SUBMITTED') {
+      return '作业已截止,但您可以继续完成并补交'
     }
-    return `作业已截止,截止时间:${endTime.toLocaleString()}`
+    return '作业已截止,您可继续修改并再次提交(按补交记录)'
   } else {
     return '点击开始作业'
   }

+ 3 - 1
src/views/LoginPage/component/Login.vue

@@ -153,7 +153,9 @@
         store.setToken(data?.token)
         localStorage.setItem('Authorization', data?.token);
         localStorage.setItem('User', JSON.stringify(data));
-        router.push(`/home`)
+        router.push(`/home`).then(() => {
+          window.location.reload();
+        });
       } else if(_res.data.code === 400){
         ElMessage({message: '用户名或密码错误', type: 'error'})
       }

+ 29 - 0
vite.config.js

@@ -0,0 +1,29 @@
+import { fileURLToPath, URL } from 'node:url';
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+// https://vitejs.dev/config/
+export default defineConfig({
+    plugins: [
+        vue(),
+    ],
+    css: {
+        preprocessorOptions: {
+            scss: {
+                // Use Sass modern JS API to avoid legacy API warnings.
+                api: 'modern-compiler',
+            },
+        },
+    },
+    resolve: {
+        alias: {
+            '@': fileURLToPath(new URL('./src', import.meta.url))
+        }
+    },
+    server: {
+        host: '127.0.0.1',
+        port: 4000,
+        proxy: {
+            '/api': 'http://localhost:8090'
+        }
+    }
+});

+ 8 - 0
vite.config.ts

@@ -8,6 +8,14 @@ export default defineConfig({
   plugins: [
     vue(),
   ],
+  css: {
+    preprocessorOptions: {
+      scss: {
+        // Use Sass modern JS API to avoid legacy API warnings.
+        api: 'modern-compiler',
+      }
+    }
+  },
   resolve: {
     alias: {
       '@': fileURLToPath(new URL('./src', import.meta.url))

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác