Browse Source

Merge branch 'download_name' of FanYanPeng/EAIFrontend into host-nju

JiangPengYu 4 months ago
parent
commit
73cfc942d8

+ 8 - 0
src/apis/behavior.ts

@@ -72,6 +72,14 @@ const bevaviorApis = {
             }
         })
     },
+    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;

+ 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) {

+ 26 - 7
src/views/BehaviorPage/BehaviorBoard.vue

@@ -311,6 +311,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
+}
+
 /**
  * 下载行为数据
  */
@@ -323,7 +342,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;
@@ -332,22 +351,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;
@@ -356,14 +375,14 @@ 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'){

+ 188 - 0
src/views/CorrectPage/CoursePage.vue

@@ -62,6 +62,13 @@
           <el-icon><Download /></el-icon>
           下载文本作业
         </el-button>
+        <el-button
+          class="download-doc-button"
+          @click="openBehaviorDownloadDialog"
+        >
+          <el-icon><Download /></el-icon>
+          下载行为数据
+        </el-button>
       </div>
     </div>
     
@@ -353,6 +360,68 @@
         </el-button>
       </template>
     </el-dialog>
+
+    <el-dialog
+      v-model="behaviorDownloadDialogVisible"
+      title="下载行为数据"
+      width="520px"
+      :close-on-click-modal="false"
+    >
+      <el-form label-position="top" class="download-form">
+        <el-form-item label="作业名称">
+          <el-select
+            v-model="behaviorDownloadForm.assignmentIds"
+            multiple
+            collapse-tags
+            collapse-tags-tooltip
+            filterable
+            placeholder="请选择作业名称"
+            class="download-form-select"
+            @change="handleBehaviorDownloadAssignmentChange"
+          >
+            <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="behaviorDownloadForm.classIds"
+            multiple
+            collapse-tags
+            collapse-tags-tooltip
+            filterable
+            :placeholder="behaviorDownloadClassPlaceholder"
+            class="download-form-select"
+            :disabled="behaviorDownloadForm.assignmentIds.length === 0"
+          >
+            <el-option
+              v-for="classItem in downloadableBehaviorClasses"
+              :key="classItem.classId"
+              :label="classItem.className"
+              :value="classItem.classId"
+            />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div class="download-form-tip">
+        将按“作业 x 班级”组合逐个下载 Excel 文件。
+      </div>
+
+      <template #footer>
+        <el-button @click="closeBehaviorDownloadDialog">取消</el-button>
+        <el-button
+          type="primary"
+          :loading="behaviorDownloadSubmitting"
+          @click="handleBehaviorBatchDownload"
+        >
+          开始下载
+        </el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -374,10 +443,16 @@
   const selectedAssignment = ref(null)
   const downloadDialogVisible = ref(false)
   const downloadSubmitting = ref(false)
+  const behaviorDownloadDialogVisible = ref(false)
+  const behaviorDownloadSubmitting = ref(false)
   const downloadForm = reactive({
     assignmentIds: [] as number[],
     classIds: [] as number[]
   })
+  const behaviorDownloadForm = reactive({
+    assignmentIds: [] as number[],
+    classIds: [] as number[]
+  })
   const downloadClassCache = reactive<Record<number, ClassDTO[]>>({})
   
   // 定义课程和作业的数据结构
@@ -412,6 +487,10 @@
     return downloadableAssignments.value.filter(assignment => downloadForm.assignmentIds.includes(assignment.assignmentId))
   })
 
+  const selectedBehaviorDownloadAssignments = computed(() => {
+    return downloadableAssignments.value.filter(assignment => behaviorDownloadForm.assignmentIds.includes(assignment.assignmentId))
+  })
+
   const downloadableClasses = computed(() => {
     const classMap = new Map<number, ClassDTO>()
 
@@ -425,6 +504,19 @@
     return Array.from(classMap.values())
   })
 
+  const downloadableBehaviorClasses = computed(() => {
+    const classMap = new Map<number, ClassDTO>()
+
+    selectedBehaviorDownloadAssignments.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 '请先选择作业'
@@ -436,6 +528,18 @@
 
     return '请选择班级名称'
   })
+
+  const behaviorDownloadClassPlaceholder = computed(() => {
+    if (behaviorDownloadForm.assignmentIds.length === 0) {
+      return '请先选择作业'
+    }
+
+    if (downloadableBehaviorClasses.value.length === 0) {
+      return '当前所选作业暂无可选班级'
+    }
+
+    return '请选择班级名称'
+  })
   
   // 加载状态
   const loading = ref(false)
@@ -912,6 +1016,20 @@
     downloadForm.classIds = []
   }
 
+  const openBehaviorDownloadDialog = () => {
+    if (downloadableAssignments.value.length === 0) {
+      ElMessage.warning('暂无可下载的写作作业')
+      return
+    }
+    behaviorDownloadDialogVisible.value = true
+  }
+
+  const closeBehaviorDownloadDialog = () => {
+    behaviorDownloadDialogVisible.value = false
+    behaviorDownloadForm.assignmentIds = []
+    behaviorDownloadForm.classIds = []
+  }
+
   const handleDownloadAssignmentChange = async () => {
     const selectedCourseIds = Array.from(new Set(selectedDownloadAssignments.value.map(item => item.courseId)))
 
@@ -923,6 +1041,17 @@
     downloadForm.classIds = downloadForm.classIds.filter(classId => validClassIds.has(classId))
   }
 
+  const handleBehaviorDownloadAssignmentChange = async () => {
+    const selectedCourseIds = Array.from(new Set(selectedBehaviorDownloadAssignments.value.map(item => item.courseId)))
+
+    for (const courseId of selectedCourseIds) {
+      await loadClassesForDownload(courseId)
+    }
+
+    const validClassIds = new Set(downloadableBehaviorClasses.value.map(item => item.classId))
+    behaviorDownloadForm.classIds = behaviorDownloadForm.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) : '')
@@ -1022,6 +1151,61 @@
       downloadSubmitting.value = false
     }
   }
+
+  const handleBehaviorBatchDownload = async () => {
+    if (behaviorDownloadForm.assignmentIds.length === 0) {
+      ElMessage.warning('请先选择作业')
+      return
+    }
+
+    if (behaviorDownloadForm.classIds.length === 0) {
+      ElMessage.warning('请选择班级')
+      return
+    }
+
+    const selectedClasses = downloadableBehaviorClasses.value.filter(classItem => behaviorDownloadForm.classIds.includes(classItem.classId))
+    const downloadTasks = selectedBehaviorDownloadAssignments.value.flatMap(assignment => {
+      return selectedClasses
+        .filter(classItem => classItem.courseId === assignment.courseId)
+        .map(classItem => ({ assignment, classItem }))
+    })
+
+    if (downloadTasks.length === 0) {
+      ElMessage.warning('所选作业与班级没有可下载的对应关系')
+      return
+    }
+
+    behaviorDownloadSubmitting.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.downLoadAllBehaviorDataByClass(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}_行为数据.xlsx`
+        const fileName = getDownloadFilename(response.headers as Record<string, any>, fallbackName)
+        triggerBrowserDownload(response.data, fileName)
+      }
+
+      ElMessage.success(`已开始下载 ${downloadTasks.length} 个行为数据文件`)
+      closeBehaviorDownloadDialog()
+    } catch (error: any) {
+      console.error('批量下载行为数据失败:', error)
+      ElMessage.error(error?.message || '批量下载失败,请稍后重试')
+    } finally {
+      behaviorDownloadSubmitting.value = false
+    }
+  }
   
   // 加载学生作业状态
   const loadStudentAssignmentStatus = async (classId: string, assignmentId: string) => {
@@ -1437,11 +1621,15 @@ export default {
 .download-section {
   padding: 1rem 1.25rem 1.25rem;
   border-top: 1px solid rgba(255, 255, 255, 0.18);
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
 }
 
 .download-doc-button {
   width: 100%;
   height: 40px;
+  margin-left: 0;
   border: 1px solid rgba(255, 255, 255, 0.35);
   background: rgba(255, 255, 255, 0.16);
   color: white;

+ 1 - 1
src/views/EditPage/EditPage.vue

@@ -133,7 +133,7 @@
   onMounted( () => {
     fetchData()
     // indexedDB.initDB("eventId")
-    registerAllEventListeners(indexedDB);
+    registerAllEventListeners(indexedDB, store.user.id, assignmentId);
   })
 
   onUnmounted(() => {

+ 85 - 19
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)
 
-// 处理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,80 @@ 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");
+
+    if (_visibilityChangeHandler) {
+        document.removeEventListener('visibilitychange', _visibilityChangeHandler);
+        _visibilityChangeHandler = null;
+    }
+
+    // 重置切换追踪状态
+    _switchEvents = [];
+    _switchCount = 0;
+};