Jelajahi Sumber

增加了文本下载按钮,增加了教师端批改页面的不同版本切换

Jiang Pengyu 4 bulan lalu
induk
melakukan
440635dea9

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

+ 8 - 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: {

+ 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','/login' ];
+    const publicPaths = [ '/register', '/passwordChange', '/admin/login'];
     // 1. 检查URL中的token
     const urlToken = new URLSearchParams(window.location.search).get('token');
     if (urlToken) {

+ 14 - 0
src/types/vo.ts

@@ -16,6 +16,20 @@ export interface engagementVO {
     "htmlContent": string,
 }
 
+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 {
     "id": number,
     "assignmentVO": IAssignment,

+ 83 - 24
src/views/CorrectPage/CorrectPage.vue

@@ -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>
@@ -284,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"
@@ -294,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()
@@ -378,6 +404,13 @@ 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)
@@ -422,20 +455,6 @@ async function fetchStudentInfo() {
   }
 }
 
-// 获取评分和评语数据
-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 || ''
-    }
-  } catch (error) {
-    console.error('获取评分数据失败:', error)
-  }
-}
-
 // 获取作业详情以获取课程ID
 async function fetchAssignmentDetails() {
   try {
@@ -449,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)
@@ -470,18 +527,20 @@ 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);
     })
-  
-  // 获取现有的评分和评语
-  await fetchCorrectionData()
+
+  if (isStudent.value) {
+    studentAnswer.value = dataForm.engagement?.textContent || ''
+  } else {
+    await fetchTeacherEngagementHistory(dataForm.engagement?.textContent || '')
+  }
   
   // 获取作业详情(获取课程ID)- 必须先获取,因为获取班级信息需要用到
   await fetchAssignmentDetails()

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

@@ -53,6 +53,16 @@
           </div>
         </div>
       </div>
+
+      <div class="download-section">
+        <el-button
+          class="download-doc-button"
+          @click="openDownloadDialog"
+        >
+          <el-icon><Download /></el-icon>
+          下载文本作业
+        </el-button>
+      </div>
     </div>
     
     <!-- 右侧内容区域 -->
@@ -281,6 +291,68 @@
         </div>
       </div>
     </el-dialog>
+
+    <el-dialog
+      v-model="downloadDialogVisible"
+      title="下载学生文本作业"
+      width="520px"
+      :close-on-click-modal="false"
+    >
+      <el-form label-position="top" class="download-form">
+        <el-form-item label="作业名称">
+          <el-select
+            v-model="downloadForm.assignmentIds"
+            multiple
+            collapse-tags
+            collapse-tags-tooltip
+            filterable
+            placeholder="请选择作业名称"
+            class="download-form-select"
+            @change="handleDownloadAssignmentChange"
+          >
+            <el-option
+              v-for="assignment in downloadableAssignments"
+              :key="assignment.assignmentId"
+              :label="assignment.assignmentName"
+              :value="assignment.assignmentId"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="班级名称">
+          <el-select
+            v-model="downloadForm.classIds"
+            multiple
+            collapse-tags
+            collapse-tags-tooltip
+            filterable
+            :placeholder="downloadClassPlaceholder"
+            class="download-form-select"
+            :disabled="downloadForm.assignmentIds.length === 0"
+          >
+            <el-option
+              v-for="classItem in downloadableClasses"
+              :key="classItem.classId"
+              :label="classItem.className"
+              :value="classItem.classId"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div class="download-form-tip">
+        将按“作业 x 班级”组合逐个下载 ZIP 文件。
+      </div>
+
+      <template #footer>
+        <el-button @click="closeDownloadDialog">取消</el-button>
+        <el-button
+          type="primary"
+          :loading="downloadSubmitting"
+          @click="handleBatchDownload"
+        >
+          开始下载
+        </el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -288,9 +360,10 @@
   import useApis from "@/apis";
   import {ref, reactive, computed, onMounted, onUnmounted} from "vue";
   import {useRouter, useRoute} from "vue-router";
-  import {Refresh, Search, Document, ArrowDown, ArrowRight, User, CircleCheck, WarningFilled, Sort, ZoomIn} from "@element-plus/icons-vue";
+  import {Refresh, Search, Document, ArrowDown, ArrowRight, User, CircleCheck, WarningFilled, Sort, ZoomIn, Download} from "@element-plus/icons-vue";
   import type {ClassDTO, StudentAssignmentStatusDTO} from "@/types/dto";
   import type {CourseVO, IAssignment} from "@/types/vo";
+  import {ElMessage} from "element-plus";
   import "./CoursePage.scss"
   import {useStore} from "@/store";
   const store = useStore()
@@ -299,6 +372,13 @@
 
   const apis = useApis()
   const selectedAssignment = ref(null)
+  const downloadDialogVisible = ref(false)
+  const downloadSubmitting = ref(false)
+  const downloadForm = reactive({
+    assignmentIds: [] as number[],
+    classIds: [] as number[]
+  })
+  const downloadClassCache = reactive<Record<number, ClassDTO[]>>({})
   
   // 定义课程和作业的数据结构
   interface CourseWithAssignments {
@@ -323,6 +403,39 @@
   
   // 原始作业数据(从API获取)
   const rawAssignments = reactive<IAssignment[]>([])
+
+  const downloadableAssignments = computed(() => {
+    return rawAssignments.filter(assignment => assignment.type === 'WRITING' || assignment.type === '写作')
+  })
+
+  const selectedDownloadAssignments = computed(() => {
+    return downloadableAssignments.value.filter(assignment => downloadForm.assignmentIds.includes(assignment.assignmentId))
+  })
+
+  const downloadableClasses = computed(() => {
+    const classMap = new Map<number, ClassDTO>()
+
+    selectedDownloadAssignments.value.forEach(assignment => {
+      const classes = downloadClassCache[assignment.courseId] || []
+      classes.forEach(classItem => {
+        classMap.set(classItem.classId, classItem)
+      })
+    })
+
+    return Array.from(classMap.values())
+  })
+
+  const downloadClassPlaceholder = computed(() => {
+    if (downloadForm.assignmentIds.length === 0) {
+      return '请先选择作业'
+    }
+
+    if (downloadableClasses.value.length === 0) {
+      return '当前所选作业暂无可选班级'
+    }
+
+    return '请选择班级名称'
+  })
   
   // 加载状态
   const loading = ref(false)
@@ -761,12 +874,154 @@
     try {
       const response = await apis.getClassesByCourse(courseId)
       if (response.data.code === 200) {
-        classList.splice(0, classList.length, ...response.data.data)
+        const classes = response.data.data || []
+        classList.splice(0, classList.length, ...classes)
+        downloadClassCache[courseId] = classes
       }
     } catch (error) {
       console.error('加载班级列表失败:', error)
     }
   }
+
+  const loadClassesForDownload = async (courseId: number) => {
+    if (downloadClassCache[courseId]) {
+      return downloadClassCache[courseId]
+    }
+
+    const response = await apis.getClassesByCourse(courseId)
+    if (response.data.code === 200) {
+      downloadClassCache[courseId] = response.data.data || []
+      return downloadClassCache[courseId]
+    }
+
+    downloadClassCache[courseId] = []
+    return []
+  }
+
+  const openDownloadDialog = () => {
+    if (downloadableAssignments.value.length === 0) {
+      ElMessage.warning('暂无可下载的写作作业')
+      return
+    }
+    downloadDialogVisible.value = true
+  }
+
+  const closeDownloadDialog = () => {
+    downloadDialogVisible.value = false
+    downloadForm.assignmentIds = []
+    downloadForm.classIds = []
+  }
+
+  const handleDownloadAssignmentChange = async () => {
+    const selectedCourseIds = Array.from(new Set(selectedDownloadAssignments.value.map(item => item.courseId)))
+
+    for (const courseId of selectedCourseIds) {
+      await loadClassesForDownload(courseId)
+    }
+
+    const validClassIds = new Set(downloadableClasses.value.map(item => item.classId))
+    downloadForm.classIds = downloadForm.classIds.filter(classId => validClassIds.has(classId))
+  }
+
+  const getHeaderValue = (headers: Record<string, any>, key: string) => {
+    const value = headers[key] ?? headers[key.toLowerCase()] ?? headers[key.toUpperCase()]
+    return typeof value === 'string' ? value : (value != null ? String(value) : '')
+  }
+
+  const getDownloadFilename = (headers: Record<string, any>, fallbackName: string) => {
+    const disposition = getHeaderValue(headers, 'content-disposition')
+    if (!disposition) {
+      return fallbackName
+    }
+
+    const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
+    if (utf8Match?.[1]) {
+      return decodeURIComponent(utf8Match[1])
+    }
+
+    const plainMatch = disposition.match(/filename="?([^"]+)"?/i)
+    if (plainMatch?.[1]) {
+      return decodeURIComponent(plainMatch[1])
+    }
+
+    return fallbackName
+  }
+
+  const triggerBrowserDownload = (blob: Blob, fileName: string) => {
+    const url = window.URL.createObjectURL(blob)
+    const link = document.createElement('a')
+    link.href = url
+    link.download = fileName
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link)
+    window.URL.revokeObjectURL(url)
+  }
+
+  const parseBlobErrorMessage = async (blob: Blob) => {
+    try {
+      const text = await blob.text()
+      const json = JSON.parse(text)
+      return json.msg || '下载失败'
+    } catch {
+      return '下载失败'
+    }
+  }
+
+  const handleBatchDownload = async () => {
+    if (downloadForm.assignmentIds.length === 0) {
+      ElMessage.warning('请先选择作业')
+      return
+    }
+
+    if (downloadForm.classIds.length === 0) {
+      ElMessage.warning('请选择班级')
+      return
+    }
+
+    const selectedClasses = downloadableClasses.value.filter(classItem => downloadForm.classIds.includes(classItem.classId))
+    const downloadTasks = selectedDownloadAssignments.value.flatMap(assignment => {
+      return selectedClasses
+        .filter(classItem => classItem.courseId === assignment.courseId)
+        .map(classItem => ({ assignment, classItem }))
+    })
+
+    if (downloadTasks.length === 0) {
+      ElMessage.warning('所选作业与班级没有可下载的对应关系')
+      return
+    }
+
+    downloadSubmitting.value = true
+
+    try {
+      for (const task of downloadTasks) {
+        console.log('下载文本作业参数:', {
+          classId: task.classItem.classId,
+          assignmentId: task.assignment.assignmentId,
+          className: task.classItem.className,
+          assignmentName: task.assignment.assignmentName
+        })
+        const response = await apis.downloadClassAssignmentDocument(task.classItem.classId, task.assignment.assignmentId)
+        const contentType = getHeaderValue(response.headers as Record<string, any>, 'content-type')
+
+        if (contentType.includes('application/json')) {
+          throw new Error(await parseBlobErrorMessage(response.data))
+        }
+
+        const fallbackName = `${task.classItem.className}_${task.assignment.assignmentName}.zip`
+        const fileName = getDownloadFilename(response.headers as Record<string, any>, fallbackName)
+        triggerBrowserDownload(response.data, fileName)
+      }
+
+      ElMessage.success(`已开始下载 ${downloadTasks.length} 个文件`)
+      closeDownloadDialog()
+    } catch (error: any) {
+      console.error('批量下载文本作业失败:', error)
+      ElMessage.error(error?.message || '批量下载失败,请稍后重试')
+    } finally {
+      downloadSubmitting.value = false
+    }
+  }
   
   // 加载学生作业状态
   const loadStudentAssignmentStatus = async (classId: string, assignmentId: string) => {
@@ -1179,6 +1434,37 @@ export default {
   scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
 }
 
+.download-section {
+  padding: 1rem 1.25rem 1.25rem;
+  border-top: 1px solid rgba(255, 255, 255, 0.18);
+}
+
+.download-doc-button {
+  width: 100%;
+  height: 40px;
+  border: 1px solid rgba(255, 255, 255, 0.35);
+  background: rgba(255, 255, 255, 0.16);
+  color: white;
+  font-family: "Noto Sans SC", sans-serif;
+  font-weight: 600;
+}
+
+.download-doc-button:hover {
+  background: rgba(255, 255, 255, 0.24);
+  border-color: rgba(255, 255, 255, 0.55);
+  color: white;
+}
+
+.download-form-select {
+  width: 100%;
+}
+
+.download-form-tip {
+  color: #666;
+  font-size: 13px;
+  line-height: 1.6;
+}
+
 /* Webkit浏览器滚动条美化 */
 .menu-section::-webkit-scrollbar {
   width: 6px;

+ 35 - 0
src/views/CorrectPage/index.scss

@@ -1088,6 +1088,41 @@
       }
     }
 
+    .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;
+      }
+    }
+
     .student-answer-content {
       flex: 1;
       padding: 15px;