Prechádzať zdrojové kódy

feat:添加考试模式

lalala 4 mesiacov pred
rodič
commit
0a0e4d786b

+ 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. 提交接口校验考试模式只能提交一次

+ 8 - 0
src/apis/engagement.ts

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

+ 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;
+      }
     }
   }
 

+ 15 - 15
src/router/index.ts

@@ -16,24 +16,24 @@ 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";
-        // {本地门户首页地址}?from={本地eai首页地址}
-        return "/"; // 占位返回值,实际不会执行
-    },
-    },
     // {
     //   path: "/",
-    //   redirect: "/login"
+    //   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";
+    //     // {本地门户首页地址}?from={本地eai首页地址}
+    //     return "/"; // 占位返回值,实际不会执行
     // },
-    // {
-    //   path: '/login',
-    //   name: 'login',
-    //   component: () => import('../views/LoginPage/LoginPage.vue')
     // },
+    {
+      path: "/",
+      redirect: "/login"
+    },
+    {
+      path: '/login',
+      name: 'login',
+      component: () => import('../views/LoginPage/LoginPage.vue')
+    },
     {
       path: '/register',
       name: 'register',
@@ -260,7 +260,7 @@ const router = createRouter({
  */
   router.beforeEach(async (to) => {
     const store = useStore();
-    const publicPaths = [ '/register', '/passwordChange', '/admin/login' ];
+    const publicPaths = [ '/login', '/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 {

+ 26 - 9
src/types/vo.ts

@@ -14,6 +14,7 @@ export interface engagementVO {
     "quillContent": string,
     "textContent": string,
     "htmlContent": string,
+    "submitted": boolean,
 }
 
 export interface EngagementHistoryStatusVO {
@@ -54,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;
 
@@ -71,6 +72,10 @@ export interface IAssignment {
     createTime: string;
 
     status: string;
+
+    examMode?: boolean;
+
+    examDuration?: number;
 }
 
 export interface AssignmentVO {
@@ -82,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;
 
@@ -97,6 +102,10 @@ export interface AssignmentVO {
     createTime: string;
 
     status: string;
+
+    examMode?: boolean;
+
+    examDuration?: number;
 }
 
 export interface IAssignmentTableColumn {
@@ -186,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("删除失败");

+ 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;
         }
       });
   };

+ 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']);

+ 200 - 21
src/views/EditPage/EditPage.vue

@@ -6,15 +6,24 @@
 <template>
 
   <div class="my-work" >
-
     <div class="my-work-slider">
-      <el-button class="back" color="#4A90C4" style="color: #FFFFFF;" @click="handleBack">返回</el-button>
+      <el-button class="back" color="#4A90C4" style="color: #FFFFFF;" @click="handleBack" :disabled="isExamMode">返回</el-button>
       <AIChatter :dialogueId="dataForm.dialogueId" :messages="dataForm.messages" :is-teacher="false" />
       <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 +36,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 {onMounted, onUnmounted, reactive, computed, ref, watch} from "vue";
+  import type {engagementVO, IAssignment, ExamTimeVO} from "@/types/vo";
   import type {IDialogue} from "@/types/dialogue.ts";
   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 +60,151 @@
   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('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(
@@ -84,7 +232,24 @@
     }
   }
 
-  // 获取数据
+  async function fetchAssignmentData() {
+    return apis.getAssignmentById(assignmentId)
+      .then((res: any) => {
+        dataForm.assignment = res.data.data;
+      });
+  }
+
+  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);
+      });
+  }
+
   async function fetchData(){
     await apis.getAIDialogues(assignmentId, store.user.id)
         .then((_res:any) => {
@@ -105,15 +270,11 @@
             messages: dataForm.messages,
             engagement: _res.data.data
           })
-          // if(!dataForm.engagement){
-          //   fetchData()
-          // }
         }).catch((_err:any) => {
           console.log("错误", _err)
         })
   }
 
-  // 保存AI对话内容
   const saveAIDialogue = async () => {
     if (dataForm.dialogueId && dataForm.messages.length > 0) {
       try {
@@ -125,24 +286,42 @@
     }
   }
 
-  // 监听提交前的保存对话事件
   emitter.on('save-ai-dialogue-before-submit', async () => {
     await saveAIDialogue()
   })
 
-  onMounted( () => {
-    fetchData()
-    // indexedDB.initDB("eventId")
+  emitter.on('exam-submitted', () => {
+    dataForm.engagement.submitted = true;
+    examTimeInfo.value = examTimeInfo.value ? { ...examTimeInfo.value, submitted: true } : null;
+  })
+
+  onMounted(async () => {
+    await fetchAssignmentData();
+    await Promise.all([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

@@ -26,3 +26,34 @@
     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;
+  }
+}

+ 1 - 1
src/views/EditPage/userWritingRecord.ts

@@ -3,7 +3,7 @@ import IndexedDB from '@/utils/indexedDBUtil';
 import {ref} from "vue";
 import useApis from '@/apis';
 
-export let isModified = ref(false)
+export const isModified = ref(false)
 
 // ──────────────────────────────────────────────
 // 窗口切换追踪状态

+ 21 - 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()
       })

+ 18 - 6
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,21 @@ 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'
     }
-    // 如果没有参与状态信息,允许点击(首次参与)
     return false
   }
 
-  // 在作业时间范围内,不禁用
+  if (data.examMode === true && engagementStatus.value?.submitted) {
+    return true
+  }
+
   return false
 })
 
@@ -126,6 +134,10 @@ 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) {

+ 21 - 0
vite.config.js

@@ -0,0 +1,21 @@
+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(),
+    ],
+    resolve: {
+        alias: {
+            '@': fileURLToPath(new URL('./src', import.meta.url))
+        }
+    },
+    server: {
+        host: '127.0.0.1',
+        port: 4000,
+        proxy: {
+            '/api': 'http://localhost:8090'
+        }
+    }
+});