瀏覽代碼

Merge branch 'feature/behavior_record' of FanYanPeng/EAIFrontend into host-nju

LiaoYanZhou 1 年之前
父節點
當前提交
1d186cb399

+ 1 - 0
package-lock.json

@@ -2155,6 +2155,7 @@
       "version": "5.6.0",
       "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
       "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "license": "Apache-2.0",
       "dependencies": {
         "tslib": "2.3.0",
         "zrender": "5.6.1"

+ 2 - 3
src/apis/axios.config.ts

@@ -1,9 +1,9 @@
 import axios from "axios";
 
 // const BASEURL = 'http://47.111.23.171:8081'
-// const BASEURL = 'http://localhost:8082'
+const BASEURL = 'http://localhost:8082'
 // const BASEURL = 'http://8.130.126.86:8080'
-const BASEURL = 'https://air-nju.seec.seecoder.cn/'
+// const BASEURL = 'https://air-nju.seec.seecoder.cn/'
 
 const axiosInstance = axios.create({
     baseURL: BASEURL,
@@ -11,7 +11,6 @@ const axiosInstance = axios.create({
         'Content-Type': 'application/json',
     },
 })
-
 //请求拦截器
 axiosInstance.interceptors.request.use(
     config => {

+ 44 - 2
src/apis/behavior.ts

@@ -20,7 +20,49 @@ const bevaviorApis = {
                 'Content-Type': 'multipart/form-data'
             }
         })
-    }
+    },
+    getOverview: (studentId:number,assignmentId :number) => {
+        return axiosInstance.get(`${PREFIX}/overview`, {
+            params: {
+                studentId,
+                assignmentId
+            }
+        })
+    },
+    getBehaviorRecordAll: (studentId:number,assignmentId :number) => {
+        return axiosInstance.get(`${PREFIX}/all`, {
+            params: {
+                studentId,
+                assignmentId
+            }
+        })
+    },
+    getPauseStatisticData:(studentId:number,assignmentId :number,threshold:number) =>{
+        return axiosInstance.get(`${PREFIX}/pauseStatistics`, {
+            params: {
+                studentId,
+                assignmentId,
+                threshold
+            }
+        })
+    },
+    downLoadBehaviorData:(studentId:number,assignmentId :number)=>{
+        return axiosInstance.get(`/api/export/behaviorOverview/excel`, {
+            responseType: 'blob',
+            params: {
+                studentId,
+                assignmentId
+            }
+        })
+    },
+    downLoadAllBehaviorData:(assignmentId :number)=>{
+        return axiosInstance.get(`/api/export/behaviorOverview/excel/assignment`, {
+            responseType: 'blob',
+            params: {
+                assignmentId
+            }
+        })
+    },
 }
 
-export default bevaviorApis;
+export default bevaviorApis;

+ 18 - 13
src/layout/Header/Header.vue

@@ -5,15 +5,19 @@
       <img src="../Header/seecoderLogo_wide.png" alt="logo">
     </div>
     <!-- 选择菜单 -->
+
+
     <div class="header-menu">
-      <el-menu mode="horizontal" :default-active="activePage" @select="handleSelect" :ellipsis="false" class="menu">
+      <el-menu mode="horizontal"
+               router
+               :default-active="useRoute().path" @select="handleSelect" :ellipsis="false" class="menu">
         <template v-if="role == 'student'">
-          <el-menu-item v-for="item in studentMenuItems" :key="item.key" :index="item.key" class="header-menu-item">
+          <el-menu-item v-for="item in studentMenuItems" :key="item.key" :index="item.path" class="header-menu-item">
             <div>{{ item.value }}</div>
           </el-menu-item>
         </template>
         <template v-else>
-          <el-menu-item v-for="item in teacherMenuItems" :key="item.key" :index="item.key" class="header-menu-item">
+          <el-menu-item v-for="item in teacherMenuItems" :key="item.key" :index="item.path" class="header-menu-item">
             <div>{{ item.value }}</div>
           </el-menu-item>
         </template>
@@ -32,7 +36,7 @@
             </template>
             <div class="nickName">{{ store.user.name }}</div>
           </template>
-          <template v-else>            
+          <template v-else>
             <el-avatar :size="32" :shape="'circle'">U</el-avatar>
             <div class="nickName">user</div>
           </template> -->
@@ -63,19 +67,20 @@ const store = useStore();
 const role: 'teachers' | 'student' = store.user.role === 'STUDENT' ? 'student' : 'teachers';
 
 const studentMenuItems = [
-  { value: "课程广场", key: "CourseSquare" },
-  { value: "我的课程", key: "MyCourse" },
-  { value: "我的作业", key: "MyWork" },
+  { value: "课程广场", key: "CourseSquare",path:"/home/courseSquare" },
+  { value: "我的课程", key: "MyCourse",path:"/home/myCourse" },
+  { value: "我的作业", key: "MyWork",path:"/home/myHomework" },
 ];
 
 const teacherMenuItems = [
-  { value: "课程广场", key: "CourseSquare" },
-  { value: "我的课程", key: "MyCourse" },
-  { value: "我的批改", key: "MyWork" },
+  { value: "课程广场", key: "CourseSquare" ,path:"/home/courseSquare"},
+  { value: "我的课程", key: "MyCourse",path:"/home/myCourse" },
+  { value: "我的批改", key: "MyWork",path:"/home/myCorrect/course" },
+    { value: "行为数据", key: "Behavior",path:"/home/behavior/main" },
 ];
 
 const handleSelect = (index: string) => {
-  emitter.emit('change-page', index);
+  // emitter.emit('change-page', index);
 };
 
 const handleCommand = (command: string) => {
@@ -83,7 +88,7 @@ const handleCommand = (command: string) => {
     store.setUser(null);
     store.setToken(null);
     localStorage.setItem('Authorization', null);
-    localStorage.setItem('User', null); 
+    localStorage.setItem('User', null);
     router.push({ path: "/" });
   } else if (command === "profile") {
     router.push({ path: "/profile" });
@@ -133,4 +138,4 @@ onMounted(() => {
 export default {
   name: "Layout"
 };
-</script>
+</script>

+ 2 - 2
src/layout/Layout/Layout.vue

@@ -16,7 +16,7 @@ import Role from '@/views/Home/Role.vue'
         <Header></Header>
       </el-header>
     </el-container>
-    <el-container>
+    <el-container >
       <el-main style="height: 100%;;padding: 0 0 0 0;margin-top: 40px">
         <Role></Role>
       </el-main>
@@ -39,4 +39,4 @@ import Role from '@/views/Home/Role.vue'
   padding: 0;
   height: 40px;
 }
-</style>
+</style>

+ 33 - 0
src/router/index.ts

@@ -169,6 +169,39 @@ const router = createRouter({
             },
           ]
         },
+        {
+          path:"behavior",
+          name: "behavior",
+          redirect: 'home/behavior/main',
+          component: () => import("../views/BehaviorPage/BehaviorBoard.vue"),
+          children:[
+              {
+                path: 'main',
+                name: 'behaviorHome',
+                component: () => import("../views/BehaviorPage/subPage/BehaviorHome.vue")
+              },
+            {
+              path: 'total',
+              name: 'total',
+              component: () => import("../views/BehaviorPage/subPage/TotalAnalysis.vue")
+            },
+            {
+              path: 'edit',
+              name: 'editData',
+              component: () => import("../views/BehaviorPage/subPage/EditAnalysis.vue")
+            },
+            {
+              path: 'pause',
+              name: 'pause',
+              component: () => import("../views/BehaviorPage/subPage/PauseAnalysis.vue")
+            },
+            {
+              path: 'process',
+              name: 'process',
+              component: () => import("../views/BehaviorPage/subPage/ProcessAnalysis.vue")
+            },
+          ]
+        },
       ]
     },
 

+ 445 - 0
src/views/BehaviorPage/BehaviorBoard.vue

@@ -0,0 +1,445 @@
+<script setup lang="ts">
+import router from "@/router";
+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";
+
+const apis = useApis()
+//pageHeader
+interface CourseVO{
+  courseName: string;
+  courseId: number;
+}
+interface AssignmentVO{
+  assignmentName: string;
+  assignmentId: number;
+}
+const dialogVisible = ref(false)
+const maxPauseTime = ref(2000)
+provide('maxPauseTime',maxPauseTime)
+
+const loadingStatus = ref({
+  edit:false,
+  pause:false,
+  process:false,
+  total:false
+})
+provide('loadingStatus',loadingStatus)
+
+const selectedCourse  = ref({courseName: "", courseId: -1});
+const courseList:Ref<CourseVO[]> = ref([]);
+let courseData:any[]= []
+const selectedAssignment  = ref({assignmentName: "", assignmentId: -1});
+watch(selectedAssignment,()=>{
+  AssignmentChanged(selectedAssignment.value);
+})
+const assignmentList:Ref<AssignmentVO[]> = ref([]);
+const dataIsLoading:Ref<boolean> = ref(true);
+let assignmentData:any[]= []
+
+const emptyText = computed(()=>{
+  if (selectedCourse.value.courseId === -1){
+    return "暂无课程"
+  }
+  else{
+    return "当前作业暂无学生提交"
+  }
+})
+function getCourseList(){
+  console.log("获取课程列表")
+  let pageHTML = document.getElementById("info-card")
+  console.log(pageHTML)
+  const loading = ElLoading.service({
+    text: '获取数据中',
+    target: pageHTML,
+    background: '#F5F5F59E'
+  })
+  apis.getCourseByTeacherId(0,100)
+      .then((res: any)=>{
+        if (res&&res.data.code===200){
+          courseData = res.data.data.records as [any];
+          // console.log(courseData)
+          courseList.value = courseData.map((item:any)=>{
+            return {courseName: item.courseName, courseId: item.courseId}
+          })
+          if (courseList.value&&courseList.value.length>0){
+            selectedCourse.value = courseList.value[0];
+          }
+          else {
+            selectedCourse.value = {courseName: "", courseId: -1}
+            loading.close();
+            return;
+          }
+          apis.getAssignmentByCourseId(courseData[0].courseId,0,1000)
+            .then((res: any)=>{
+              if (res&&res.data.code===200){
+                loading.close();
+                dataIsLoading.value = false;
+                let temp = res.data.data.records.filter((item:any)=>{
+                  return item.type === 'writing' || item.type === '写作';
+                })
+                console.log("temp:",temp)
+                assignmentList.value = temp.map((item:any)=>{
+                    return {assignmentName: item.assignmentName, assignmentId: item.assignmentId}
+                })
+                console.log(assignmentList.value);
+                // assignmentData = temp as any[];
+                if (assignmentList.value&&assignmentList.value.length>0){
+                  selectedAssignment.value = assignmentList.value[0];
+                }
+                else{
+                  selectedAssignment.value = {assignmentName: "", assignmentId: -1}
+                }
+              }
+              else{
+                ElMessage.error("获取作业列表失败")
+              }
+            })
+            .catch((err: any) => {
+              console.log(err);
+              ElMessage.error("获取作业列表失败")
+            })
+        }
+        else{
+          ElMessage.error("获取课程列表失败")
+        }
+        console.log(res);
+      })
+      .catch((err: any) => {
+        console.error(err);
+      })
+}
+
+function getAssignmentList(courseId:number){
+  let pageHTML = document.getElementById("info-card")
+  // console.log(pageHTML)
+  const loading = ElLoading.service({
+    text: '获取作业列表中',
+    target: pageHTML,
+    background: '#F5F5F59E'
+  })
+  apis.getAssignmentByCourseId(courseId,0,1000)
+      .then((res: any)=>{
+        if (res&&res.data.code===200){
+          let temp = res.data.data.records.filter((item:any)=>{
+            return item.type === 'writing' || item.type === '写作';
+          })
+          assignmentList.value = temp.map((item:any)=>{
+            return {assignmentName: item.assignmentName, assignmentId: item.assignmentId}
+          })
+          // assignmentData = temp as any[];
+          if (assignmentList.value&&assignmentList.value.length>0){
+            selectedAssignment.value = assignmentList.value[0];
+          }
+          else{
+            selectedAssignment.value = {assignmentName: "", assignmentId: -1}
+          }
+          loading.close();
+        }
+      })
+}
+
+
+const courseSelected = computed(()=>{
+  return selectedCourse.value&&selectedCourse.value.courseName !== "";
+});
+function goBack() {
+  router.go(-1);
+}
+
+/**
+ * 课程切换
+ * @param value 课程名
+ */
+function courseChanged(value:CourseVO){
+  console.log("用户切换了课程")
+  //TODO
+  getAssignmentList(value.courseId);
+}
+
+
+const engagements = ref([]);
+const currentEngagementIndex = ref(-1);
+const preButtonDisabled = computed(()=>{
+  return currentEngagementIndex.value<=0
+      || loadingStatus.value.pause
+      || loadingStatus.value.process
+      || loadingStatus.value.total
+      || loadingStatus.value.edit
+});
+const nextButtonDisabled = computed(()=>{
+  return currentEngagementIndex.value>=engagements.value.length-1
+      || loadingStatus.value.pause
+      || loadingStatus.value.process
+      || loadingStatus.value.total
+      || loadingStatus.value.edit
+})
+const isLoading = computed(()=>{
+  return loadingStatus.value.pause
+      || loadingStatus.value.process
+      || loadingStatus.value.total
+      || loadingStatus.value.edit
+})
+const currentEngagement = ref()
+watch(currentEngagementIndex,()=>{
+  if (currentEngagementIndex.value>=0&&currentEngagementIndex.value<engagements.value.length){
+    currentEngagement.value =  engagements.value[currentEngagementIndex.value];
+    console.log(currentEngagement.value)
+  }
+  else{
+    currentEngagement.value = null;
+  }
+})
+provide('currentEngagement',currentEngagement);
+
+
+/**
+ * 切换作业题目
+ * @param value 作业题目
+ * @constructor
+ */
+function AssignmentChanged(value:AssignmentVO){
+  console.log("用户切换了作业")
+  if (value.assignmentId === -1){
+    currentEngagementIndex.value = -1;
+    return;
+  }
+  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){
+            currentEngagementIndex.value = 0;
+          }
+          else{
+            currentEngagementIndex.value = -1;
+          }
+        }
+      })
+      .catch((err:any) => {
+        console.log("错误信息", err)
+      })
+}
+
+/**
+ * 切换提交
+ * @param direction 1:下一个提交 -1:上一个提交 else:切换错误
+ */
+function assignmentSwitch(direction:number){
+  if (direction===1){
+    console.log("切换下一个作业")
+    currentEngagementIndex.value++;
+  }
+  else if (direction===-1){
+    console.log("切换上一个作业")
+    currentEngagementIndex.value--;
+  }
+  else{
+    ElMessage.error("切换错误")
+  }
+}
+const selectedType = ref('single');
+/**
+ * 下载行为数据
+ */
+function downLoadBehaviorData(){
+  dialogVisible.value = false
+  // ElMessage.success(selectedType.value)
+  if (selectedType.value === 'single'){
+    apis.downLoadBehaviorData(currentEngagement.value.studentId,currentEngagement.value.assignmentId)
+        .then((res:any) => {
+          console.log(res)
+          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 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';
+            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("请选择下载类型")
+  }
+  // apis.downLoadBehaviorData(selectedAssignment.value.assignmentId,selectedCourse.value.courseId)
+}
+const handleClose = (done: () => void) => {
+  done()
+}
+onMounted(()=>{
+  getCourseList();
+})
+</script>
+
+<template>
+  <div id="page" class="page">
+    <el-card class="info-card" id="info-card">
+      <template #header>
+        <el-page-header @back="goBack">
+          <template #icon>
+            <el-icon color="#909399"><back/></el-icon>
+          </template>
+          <template #title>
+            <span style="font-size: 18px;color: var(--el-text-color-secondary)" > 返回 </span>
+          </template>
+          <template #content>
+            <span > 行为数据记录 </span>
+          </template>
+          <template #extra>
+            <el-select v-model="selectedCourse"
+                       placeholder="请选择课程"
+                       value-key="courseId"
+                       :title="courseSelected?selectedCourse.courseName:'暂无课程'"
+                       @change="courseChanged"
+                       style="width: 130px;">
+              <el-option
+                  style="color: var(--el-text-color-regular);font-weight: unset"
+                  v-for="item in courseList"
+                  :key="item.courseId"
+                  :label="item.courseName"
+                  :value="item"
+              />
+            </el-select>
+            <el-select v-model="selectedAssignment"
+                       :disabled="!courseSelected"
+                       value-key="assignmentId"
+                       :class="{'disabled':!courseSelected}"
+                       placeholder="请选择作业"
+                       :title="courseSelected?selectedAssignment.assignmentName:'请先选择课程'"
+                       style="width: 130px;margin-left: 10px">
+              <el-option
+                  style="color: var(--el-text-color-regular);font-weight: unset"
+                  v-for="item in assignmentList"
+                  :key="item.assignmentId"
+                  :label="item.assignmentName"
+                  :value="item"
+              />
+            </el-select>
+          </template>
+        </el-page-header>
+      </template>
+      <el-descriptions :column="2" v-if="currentEngagement" style="width: 90%;margin-left: 5%">
+        <el-descriptions-item label="课程名">{{selectedCourse.courseName}}</el-descriptions-item>
+        <el-descriptions-item label="作业题目">{{selectedAssignment.assignmentName}}</el-descriptions-item>
+        <el-descriptions-item label="学生姓名">{{currentEngagement.studentName}}</el-descriptions-item>
+        <el-descriptions-item label="学号">
+          {{currentEngagement.studentId}}
+        </el-descriptions-item>
+<!--        <el-descriptions-item label="提交时间">-->
+<!--          2025-07-30 09:30:00-->
+<!--        </el-descriptions-item>-->
+      </el-descriptions>
+      <div v-if="currentEngagement" id="info-footer">
+        <el-button
+          :disabled="isLoading"
+          @click="dialogVisible = true" type="success">
+          导出行为数据
+        </el-button>
+        <el-button
+            :disabled="preButtonDisabled"
+            @click="assignmentSwitch(-1)" type="primary">
+          上一个
+        </el-button>
+        <el-button
+            :disabled="nextButtonDisabled"
+            @click="assignmentSwitch(1)" type="primary">
+          下一个
+        </el-button>
+      </div>
+      <el-empty v-else :description="emptyText" style="height: 227px" />
+    </el-card>
+    <router-view :class="{hidden:!(!dataIsLoading&&currentEngagement)}" >
+
+    </router-view>
+    <el-dialog
+        v-model="dialogVisible"
+        title="请选择导出的数据类型"
+        width="500"
+        :before-close="handleClose"
+    >
+      <el-radio-group v-model="selectedType">
+        <el-radio value="single">当前学生行为数据</el-radio>
+        <el-radio value="all">所有学生行为数据</el-radio>
+      </el-radio-group>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="dialogVisible = false" type="danger">取消</el-button>
+          <el-button type="primary"
+                     @click="downLoadBehaviorData">
+            确认
+          </el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+.hidden{
+  visibility: hidden;
+}
+.page{
+  height: 100%;
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 30px;
+  margin-bottom: 100px;
+}
+.info-card{
+  width: 70%;
+  margin-top: 2%;
+  border-radius: 16px;
+  min-width: 700px;
+}
+.disabled{
+  cursor: not-allowed;
+}
+#info-footer{
+  display: flex;
+  width: 100%;
+  flex-direction: row;
+  justify-content: end;
+  gap: 10px;
+  margin-top: 50px;
+}
+</style>

+ 387 - 0
src/views/BehaviorPage/component/EditCanvas.vue

@@ -0,0 +1,387 @@
+<script setup lang="ts">
+import {onMounted, type Ref, watch} from "vue";
+import * as echarts from 'echarts';
+import {inject} from "vue-demi";
+import useApis from "@/apis";
+import {type EChartsType} from "echarts";
+import {ElMessage} from "element-plus";
+const props = defineProps<{
+  assignmentId:number,
+  splitNumber:number,
+}>()
+
+const apis = useApis();
+const currentEngagement = inject('currentEngagement') as Ref<any>;
+let productData = [];
+let cursorData = [];
+let processData = [];
+watch(currentEngagement,()=>{
+  let chartDom = document.getElementById('edit-canvas');
+  let myChart = echarts.getInstanceByDom(chartDom);
+  getCanvasData(myChart);
+});
+const loadingStatus = inject('loadingStatus') as Ref<{
+  edit:boolean,
+  pause:boolean,
+  process:boolean,
+  total:boolean
+}>;
+function formatTimeWithMilliseconds(stamp:number ){
+  const date = new Date(stamp);
+  const hours = date.getHours().toString().padStart(2, '0');
+  const minutes = date.getMinutes().toString().padStart(2, '0');
+  const seconds = date.getSeconds().toString().padStart(2, '0');
+  const milliseconds = date.getMilliseconds().toString().padStart(3, '0');
+  return `${hours}:${minutes}:${seconds}`;
+}
+function getCanvasData(chart:EChartsType){
+  //TODO
+  if (!currentEngagement.value){
+    return;
+  }
+  let studentId = currentEngagement.value.studentId;
+  let assignmentId = currentEngagement.value.assignmentId;
+  chart.showLoading({text:'加载中'});
+  loadingStatus.value.edit = true;
+  apis.getBehaviorRecordAll(studentId,assignmentId)
+  .then((res:any) => {
+    if (res && res.data && res.data.code === 200){
+      let rowData = res.data.data.events as any[];
+      console.log("行为数据",rowData);
+      cursorData.length=0;
+      productData.length = 0;
+      processData.length = 0;
+      let processCount = 0;
+      let zeroCount =0;
+      if (!rowData){
+        ElMessage.error('获取修改过程数据失败')
+        loadingStatus.value.edit = false;
+        chart.hideLoading();
+        return;
+      }
+      rowData.forEach((item:any) => {
+        // console.log("item",item)
+        let startStamp = 0;
+        if (item.startStamp){
+          startStamp = item.startStamp;
+        }
+        else {
+          return;
+        }
+        if (item.pauseTime <0){
+          return;
+        }
+        if (startStamp == 0){
+          zeroCount++;
+        }
+        if (zeroCount>1){
+          zeroCount = 0;
+          return;
+        }
+        let clock = new Date(startStamp);
+        clock.setHours(clock.getHours() + 16);
+
+        cursorData.push([clock,Math.min(item.retain+1,item.docLength)])
+        productData.push([clock,item.docLength])
+        let temp = 0;
+        if (productData.length == 1){
+          temp = productData[0][1];
+        }
+        else{
+          let pre = productData[productData.length-2][1];
+          if (item.docLength > pre){
+            temp += item.docLength - pre;
+          }
+        }
+        processCount+=temp;
+        processData.push([clock,processCount])
+
+      })
+      // console.log("productData",productData)
+      // console.log("cursorData",cursorData)
+      // console.log("processData",processData)
+      let option = {
+        xAxis: {
+          type: 'time',
+          axisPointer: {
+            snap: true,
+            label: {
+              show: true,
+              formatter: function(params) {
+                return formatTimeWithMilliseconds(params.value);
+              }
+            },
+            show: true,
+            type: "line",
+          },
+          splitNumber: props.splitNumber
+        },
+        yAxis: {
+          type: 'value',
+          name:'characters'
+        },
+        tooltip:{
+
+        },
+        dataZoom: [
+          {
+            type: 'inside',
+          },
+          {
+            type: 'slider',
+            labelFormatter: function (value) {
+              return formatTimeWithMilliseconds(value);
+            }
+          },
+        ],
+        legend:{},
+        series: [
+          {
+            itemStyle: {
+              color:'#ff9300',
+              opacity: 0
+            },
+            name:'product',
+            data: productData,
+            type: 'line'
+          },
+          {
+            itemStyle: {
+              color:'#ff9300',
+              opacity: 0
+            },
+            lineStyle: {
+              type: "dotted"
+            },
+            name:'cursor',
+            data: cursorData,
+            type: 'line'
+          },
+          {
+            itemStyle: {
+              color:'#00bbff',
+              opacity: 0
+            },
+            name:'process',
+            data: processData,
+            type: 'line'
+          },
+
+        ]
+      };
+      chart.setOption( option);
+      chart.hideLoading();
+      loadingStatus.value.edit = false;
+    }
+  })
+  //测试用数据
+
+  // productData = [[new Date('2025-07-26 00:0:20'),0],
+  //   [new Date('2025-07-26 00:0:40'),15],
+  //   [new Date('2025-07-26 00:1:0'),15],
+  //   [new Date('2025-07-26 00:1:20'),17],
+  //   [new Date('2025-07-26 00:1:40'),21],
+  //   [new Date('2025-07-26 00:2:0'),39],
+  //   [new Date('2025-07-26 00:2:20'),49],
+  //   [new Date('2025-07-26 00:2:40'),61],
+  //   [new Date('2025-07-26 00:3:0'),71],
+  //   [new Date('2025-07-26 00:3:20'),89],
+  //   [new Date('2025-07-26 00:3:40'),103],
+  //   [new Date('2025-07-26 00:4:0'),109],
+  //   [new Date('2025-07-26 00:4:20'),109],
+  //   [new Date('2025-07-26 00:4:40'),128],
+  //   [new Date('2025-07-26 00:5:0'),143],
+  //   [new Date('2025-07-26 00:5:20'),143],
+  //   [new Date('2025-07-26 00:5:40'),145],
+  //   [new Date('2025-07-26 00:6:0'),160],
+  //   [new Date('2025-07-26 00:6:20'),177],
+  //   [new Date('2025-07-26 00:6:40'),177],
+  //   [new Date('2025-07-26 00:7:0'),196],
+  //   [new Date('2025-07-26 00:7:20'),199],
+  //   [new Date('2025-07-26 00:7:40'),204],
+  //   [new Date('2025-07-26 00:8:0'),209],
+  //   [new Date('2025-07-26 00:8:20'),213],
+  //   [new Date('2025-07-26 00:8:40'),229],
+  //   [new Date('2025-07-26 00:9:0'),245],
+  //   [new Date('2025-07-26 00:9:20'),250],
+  //   [new Date('2025-07-26 00:9:40'),250],
+  //   [new Date('2025-07-26 00:10:0'),265],
+  //   [new Date('2025-07-26 00:10:20'),266],
+  //   [new Date('2025-07-26 00:10:40'),282],
+  //   [new Date('2025-07-26 00:11:0'),291],
+  //   [new Date('2025-07-26 00:11:20'),308],
+  //   [new Date('2025-07-26 00:11:40'),322],
+  //   [new Date('2025-07-26 00:12:0'),335],
+  //   [new Date('2025-07-26 00:12:20'),343],
+  //   [new Date('2025-07-26 00:12:40'),350],
+  //   [new Date('2025-07-26 00:13:0'),354],
+  //   [new Date('2025-07-26 00:13:20'),367],
+  //   [new Date('2025-07-26 00:13:40'),386],
+  //   [new Date('2025-07-26 00:14:0'),392],
+  //   [new Date('2025-07-26 00:14:20'),411],
+  //   [new Date('2025-07-26 00:14:40'),414],
+  //   [new Date('2025-07-26 00:15:0'),431],
+  //   [new Date('2025-07-26 00:15:20'),437],
+  //   [new Date('2025-07-26 00:15:40'),450],
+  //   [new Date('2025-07-26 00:16:0'),453],
+  //   [new Date('2025-07-26 00:16:20'),468],
+  //   [new Date('2025-07-26 00:16:40'),469],
+  //   [new Date('2025-07-26 00:17:0'),486],
+  //   [new Date('2025-07-26 00:17:20'),486],
+  //   [new Date('2025-07-26 00:17:40'),501],
+  //   [new Date('2025-07-26 00:18:0'),508],
+  //   [new Date('2025-07-26 00:18:20'),515],
+  //   [new Date('2025-07-26 00:18:40'),523],
+  //   [new Date('2025-07-26 00:19:0'),524],
+  //   [new Date('2025-07-26 00:19:20'),529],
+  //   [new Date('2025-07-26 00:19:40'),536],
+  //   [new Date('2025-07-26 00:20:0'),554],
+  //   [new Date('2025-07-26 00:20:20'),563],
+  //   [new Date('2025-07-26 00:20:40'),570],
+  //   [new Date('2025-07-26 00:21:0'),589],
+  //   [new Date('2025-07-26 00:21:20'),600],
+  //   [new Date('2025-07-26 00:21:40'),602],
+  //   [new Date('2025-07-26 00:22:0'),602],
+  //   [new Date('2025-07-26 00:22:20'),618],
+  //   [new Date('2025-07-26 00:22:40'),620],
+  //   [new Date('2025-07-26 00:23:0'),636],
+  //   [new Date('2025-07-26 00:23:20'),650],
+  //   [new Date('2025-07-26 00:23:40'),653],
+  //   [new Date('2025-07-26 00:24:0'),668],
+  //   [new Date('2025-07-26 00:24:20'),670],
+  //   [new Date('2025-07-26 00:24:40'),680],
+  //   [new Date('2025-07-26 00:25:0'),680],
+  //   [new Date('2025-07-26 00:25:20'),695],
+  //   [new Date('2025-07-26 00:25:40'),709],
+  //   [new Date('2025-07-26 00:26:0'),714],
+  //   [new Date('2025-07-26 00:26:20'),714],
+  //   [new Date('2025-07-26 00:26:40'),729],
+  //   [new Date('2025-07-26 00:27:0'),743],
+  //   [new Date('2025-07-26 00:27:20'),747],
+  //   [new Date('2025-07-26 00:27:40'),764],
+  //   [new Date('2025-07-26 00:28:0'),783],
+  //   [new Date('2025-07-26 00:28:20'),792],
+  //   [new Date('2025-07-26 00:28:40'),806],
+  //   [new Date('2025-07-26 00:29:0'),817],
+  //   [new Date('2025-07-26 00:29:20'),823],
+  //   [new Date('2025-07-26 00:29:40'),837],
+  //   [new Date('2025-07-26 00:30:0'),854],];
+  // cursorData = [[new Date('2025-07-26 00:0:20'),0],
+  //   [new Date('2025-07-26 00:0:40'),15],
+  //   [new Date('2025-07-26 00:1:0'),15],
+  //   [new Date('2025-07-26 00:1:20'),17],
+  //   [new Date('2025-07-26 00:1:40'),21],
+  //   [new Date('2025-07-26 00:2:0'),39],
+  //   [new Date('2025-07-26 00:2:20'),49],
+  //   [new Date('2025-07-26 00:2:40'),61],
+  //   [new Date('2025-07-26 00:3:0'),71],
+  //   [new Date('2025-07-26 00:3:20'),89],
+  //   [new Date('2025-07-26 00:3:40'),103],
+  //   [new Date('2025-07-26 00:4:0'),109],
+  //   [new Date('2025-07-26 00:4:20'),109],
+  //   [new Date('2025-07-26 00:4:40'),128],
+  //   [new Date('2025-07-26 00:5:0'),143],
+  //   [new Date('2025-07-26 00:5:20'),143],
+  //   [new Date('2025-07-26 00:5:40'),145],
+  //   [new Date('2025-07-26 00:6:0'),160],
+  //   [new Date('2025-07-26 00:6:20'),177],
+  //   [new Date('2025-07-26 00:6:40'),177],
+  //   [new Date('2025-07-26 00:7:0'),196],
+  //   [new Date('2025-07-26 00:7:20'),199],
+  //   [new Date('2025-07-26 00:7:40'),204],
+  //   [new Date('2025-07-26 00:7:41'),150],
+  //   [new Date('2025-07-26 00:7:59'),158],
+  //   [new Date('2025-07-26 00:8:0'),212],
+  //   [new Date('2025-07-26 00:8:20'),213],
+  //   [new Date('2025-07-26 00:8:40'),229],
+  //   [new Date('2025-07-26 00:9:0'),245],
+  //   [new Date('2025-07-26 00:9:20'),250],
+  //   [new Date('2025-07-26 00:9:40'),250],
+  //   [new Date('2025-07-26 00:10:0'),265],
+  //   [new Date('2025-07-26 00:10:20'),266],
+  //   [new Date('2025-07-26 00:10:40'),282],
+  //   [new Date('2025-07-26 00:11:0'),291],
+  //   [new Date('2025-07-26 00:11:20'),308],
+  //   [new Date('2025-07-26 00:11:40'),322],
+  //   [new Date('2025-07-26 00:12:0'),335],
+  //   [new Date('2025-07-26 00:12:20'),343],
+  //   [new Date('2025-07-26 00:12:40'),350],
+  //   [new Date('2025-07-26 00:13:0'),354],
+  //   [new Date('2025-07-26 00:13:20'),367],
+  //   [new Date('2025-07-26 00:13:40'),386],
+  //   [new Date('2025-07-26 00:14:0'),392],
+  //   [new Date('2025-07-26 00:14:20'),411],
+  //   [new Date('2025-07-26 00:14:40'),414],
+  //   [new Date('2025-07-26 00:15:0'),431],
+  //   [new Date('2025-07-26 00:15:20'),437],
+  //   [new Date('2025-07-26 00:15:40'),450],
+  //   [new Date('2025-07-26 00:16:0'),453],
+  //   [new Date('2025-07-26 00:16:20'),468],
+  //   [new Date('2025-07-26 00:16:40'),469],
+  //   [new Date('2025-07-26 00:17:0'),486],
+  //   [new Date('2025-07-26 00:17:20'),486],
+  //   [new Date('2025-07-26 00:17:40'),501],
+  //   [new Date('2025-07-26 00:18:0'),508],
+  //   [new Date('2025-07-26 00:18:20'),515],
+  //   [new Date('2025-07-26 00:18:40'),523],
+  //   [new Date('2025-07-26 00:19:0'),524],
+  //   [new Date('2025-07-26 00:19:20'),529],
+  //   [new Date('2025-07-26 00:19:40'),536],
+  //   [new Date('2025-07-26 00:20:0'),554],
+  //   [new Date('2025-07-26 00:20:20'),563],
+  //   [new Date('2025-07-26 00:20:40'),570],
+  //   [new Date('2025-07-26 00:21:0'),589],
+  //   [new Date('2025-07-26 00:21:20'),600],
+  //   [new Date('2025-07-26 00:21:40'),602],
+  //   [new Date('2025-07-26 00:22:0'),602],
+  //   [new Date('2025-07-26 00:22:20'),618],
+  //   [new Date('2025-07-26 00:22:40'),620],
+  //   [new Date('2025-07-26 00:23:0'),636],
+  //   [new Date('2025-07-26 00:23:20'),650],
+  //   [new Date('2025-07-26 00:23:40'),653],
+  //   [new Date('2025-07-26 00:24:0'),668],
+  //   [new Date('2025-07-26 00:24:20'),670],
+  //   [new Date('2025-07-26 00:24:40'),680],
+  //   [new Date('2025-07-26 00:25:0'),680],
+  //   [new Date('2025-07-26 00:25:20'),695],
+  //   [new Date('2025-07-26 00:25:40'),709],
+  //   [new Date('2025-07-26 00:26:0'),714],
+  //   [new Date('2025-07-26 00:26:20'),714],
+  //   [new Date('2025-07-26 00:26:21'),500],
+  //   [new Date('2025-07-26 00:26:30'),515],
+  //   [new Date('2025-07-26 00:26:39'),515],
+  //   [new Date('2025-07-26 00:26:40'),729],
+  //   [new Date('2025-07-26 00:27:0'),743],
+  //   [new Date('2025-07-26 00:27:20'),747],
+  //   [new Date('2025-07-26 00:27:40'),764],
+  //   [new Date('2025-07-26 00:28:0'),783],
+  //   [new Date('2025-07-26 00:28:20'),792],
+  //   [new Date('2025-07-26 00:28:40'),806],
+  //   [new Date('2025-07-26 00:29:0'),817],
+  //   [new Date('2025-07-26 00:29:20'),823],
+  //   [new Date('2025-07-26 00:29:40'),837],
+  //   [new Date('2025-07-26 00:30:0'),854],];
+}
+
+onMounted(()=>{
+  let chartDom = document.getElementById('edit-canvas');
+  let myChart = echarts.init(chartDom);
+  getCanvasData(myChart)
+  window.addEventListener('resize', ()=>{
+    myChart.resize()
+  })
+})
+</script>
+
+<template>
+  <div id="edit-canvas" class="canvas-container">
+
+  </div>
+</template>
+
+<style scoped>
+.canvas-container{
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 377 - 0
src/views/BehaviorPage/component/HeatMap.vue

@@ -0,0 +1,377 @@
+<template>
+  <div id="HeatMap">
+    <div class="container">
+      <!-- 控制区:自定义阈值和差值选择框 -->
+      <div class="controls" style="margin-bottom: 20px;">
+        <label>
+          停顿阈值:
+          <select v-model="customThreshold">
+            <option v-for="v in [1000,2000,3000,4000,5000]" :key="v" :value="v">{{ v }}ms</option>
+          </select>
+        </label>
+        <label style="margin-left: 20px;">
+          区间差值:
+          <select v-model="customStep">
+            <option v-for="v in [500,1000,2000]" :key="v" :value="v">{{ v }}ms</option>
+          </select>
+        </label>
+      </div>
+      <!-- 文本和热度图显示区域 -->
+      <div class="text-container">
+        <div
+            v-for="(line, lineIndex) in formattedLines"
+            :key="lineIndex"
+            class="text-line"
+        >
+          <!-- 显示文本行 -->
+          <span
+              v-for="(char, charIndex) in line.chars"
+              :key="charIndex"
+              class="char"
+          >
+            {{ char }}
+          </span>
+          <!-- 显示热度图 -->
+          <div
+              class="heatmap"
+              :style="{ width: `${line.width}px` }"
+          >
+            <div
+                v-for="(item, itemIndex) in line.pauses"
+                :key="itemIndex"
+                class="heatmap-rect"
+                :style="{
+                backgroundColor: getColor(item.totalPauseTime),
+                left: `${item.position}px`,
+                width: `${heatmapRectWidth}px`,
+                height: `${heatmapRectHeight}px`
+              }"
+                :title="`totalPauseTime = ${item.totalPauseTime}ms`"
+                @mouseover="showTooltip(item.totalPauseTime, $event)"
+                @mouseout="hideTooltip"
+            />
+          </div>
+        </div>
+      </div>
+
+      <!-- 颜色图例 -->
+      <div class="legend">
+        <div
+            v-for="(item, index) in colorLegend"
+            :key="index"
+            class="legend-item"
+        >
+          <div
+              class="legend-color"
+              :style="{ backgroundColor: item.color }"
+          />
+          <span>{{ item.label }}</span>
+        </div>
+      </div>
+
+      <!-- 提示框 -->
+<!--      <div-->
+<!--          v-if="tooltipVisible"-->
+<!--          class="tooltip"-->
+<!--          :style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"-->
+<!--      >-->
+<!--        totalPauseTime = {{ tooltipText }}ms-->
+<!--      </div>-->
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {ref, computed, onMounted,type Ref, watch} from 'vue'
+import bevaviorApis from '@/apis/behavior'
+import {inject} from "vue-demi";
+import {ElMessage} from "element-plus";
+
+// 响应式数据 - 控制UI元素尺寸
+const charWidth = ref(12) // 普通字符宽度(像素)
+const spaceWidth = ref(8) // 空格字符宽度(像素)
+const heatmapRectWidth = ref(8) // 热度图矩形宽度(像素)
+const heatmapRectHeight = ref(12) // 热度图矩形高度(像素)
+const heatmapOffset = ref(5) // 热度图与文本的垂直偏移(像素)
+const lineHeight = ref(30) // 文本行高(像素)
+
+// 提示框相关数据
+const tooltipVisible = ref(false)
+const tooltipText = ref('')
+const tooltipX = ref(0)
+const tooltipY = ref(0)
+
+// 自定义停顿阈值和区间差值
+const customThreshold = ref(2000)
+const customStep = ref(1000)
+
+// 动态生成 colorLegend
+const colorLegend = computed(() => {
+  const arr = []
+  let start = customThreshold.value
+  let colorArr = ['#FFF6CC', '#FFD699', '#FFA64D', '#FF7519', '#CC2900']
+  for (let i = 0; i < colorArr.length - 1; i++) {
+    arr.push({
+      time: start + i * customStep.value,
+      color: colorArr[i],
+      label: i === 0
+          ? `≤${start}ms`
+          : `${start + (i - 1) * customStep.value}-${start + i * customStep.value}ms`
+    })
+  }
+  arr.push({
+    time: Infinity,
+    color: colorArr[colorArr.length - 1],
+    label: `≥${start + (colorArr.length - 2) * customStep.value}ms`
+  })
+  return arr
+})
+
+// 用于存储 events 数据
+const actionData = ref([]);
+// 这里 studentId 和 assignmentId 可根据实际情况传递
+
+const engagement = inject('currentEngagement') as Ref<any>;
+watch(engagement,async () => {
+  try {
+    studentId = engagement.value.studentId;
+    assignmentId = engagement.value.assignmentId;
+    const res = await bevaviorApis.getBehaviorRecordAll(studentId, assignmentId)
+    if (res.data && res.data.data && Array.isArray(res.data.data.events)) {
+      actionData.value = res.data.data.events
+    } else {
+      actionData.value = []
+    }
+  } catch (e) {
+    actionData.value = []
+    // 可加错误提示
+  }
+})
+
+let studentId = 16
+let assignmentId = 7
+
+onMounted(async () => {
+  try {
+    studentId = engagement.value.studentId;
+    assignmentId = engagement.value.assignmentId;
+    const res = await bevaviorApis.getBehaviorRecordAll(studentId, assignmentId)
+    if (res.data && res.data.data && Array.isArray(res.data.data.events)) {
+      actionData.value = res.data.data.events
+    } else {
+      actionData.value = []
+    }
+  } catch (e) {
+    actionData.value = []
+    // 可加错误提示
+  }
+})
+
+// 计算属性 - 处理原始数据并格式化为多行显示
+const formattedLines = computed(() => {
+  // 计算每行最大宽度(适应窗口大小)
+  const maxLineWidth = Math.min(900, window.innerWidth - 40)
+  // 使用Map存储每个位置的字符、第一次操作的startStamp、最后一次操作的startStamp和保留位置
+  const positionMap = new Map()
+  let lastStartStamp = 0;
+
+  actionData.value.forEach(item => {
+    // 只处理 insert、delete、copy 类型的数据条目
+    if (["insert", "delete", "copy"].includes(item.type)) {
+      const {retain, insert, startStamp} = item
+      // 删除操作显示为空格
+      const char = item.type === 'delete' ? ' ' : insert
+
+      if (positionMap.has(retain)) {
+        // 已有记录,更新字符和最后一次操作的startStamp
+        const record = positionMap.get(retain)
+        record.char = char
+        record.lastStartStamp = startStamp
+      } else {
+        // 新增记录
+        positionMap.set(retain, {
+          char,
+          firstStartStamp: lastStartStamp,
+          lastStartStamp: startStamp,
+          retain
+        })
+      }
+      lastStartStamp = startStamp;
+    }
+  })
+
+  // 计算每个位置的totalPauseTime
+  positionMap.forEach(record => {
+    record.totalPauseTime = record.lastStartStamp - record.firstStartStamp
+  })
+
+  // 将Map转换为数组并按位置排序
+  const finalPositions = Array.from(positionMap.values()).sort((a, b) => a.retain - b.retain)
+  const lines = []
+  // 当前行对象,存储字符、停顿信息和宽度
+  let currentLine = {chars: [], pauses: [], width: 0, retainOffset: 0}
+
+  // 处理每个字符,生成多行显示
+  finalPositions.forEach(item => {
+    // 处理单个字符的函数
+    const processCharacter = (char, isFirstChar = false) => {
+      // 计算字符宽度(空格和普通字符不同)
+      const charWidthVal = char === ' ' ? spaceWidth.value : charWidth.value
+      const nextLineWidth = currentLine.width + charWidthVal
+
+      // 如果添加字符后超过最大宽度且当前行已有内容,则换行
+      if (nextLineWidth > maxLineWidth && currentLine.chars.length > 0) {
+        lines.push(currentLine)
+        // 创建新行,计算保留位置偏移量
+        currentLine = {
+          chars: [],
+          pauses: [],
+          width: 0,
+          retainOffset: lines.reduce((sum, line) => sum + line.chars.length, 0)
+        }
+      }
+
+      // 添加字符到当前行
+      currentLine.chars.push(char)
+      currentLine.width += charWidthVal
+
+      // 如果停顿时间超过自定义阈值且是字符的第一个字符,则记录停顿
+      if (item.totalPauseTime > customThreshold.value && isFirstChar) {
+        // 计算热度图矩形位置(字符中心偏左一点)
+        const position = currentLine.chars.length * charWidth.value - (charWidth.value / 1.2)
+        currentLine.pauses.push({
+          retain: item.retain,
+          totalPauseTime: item.totalPauseTime,
+          position
+        })
+      }
+    }
+
+    // 处理多字符插入(如复制粘贴的文本)
+    if (item.char && item.char.length > 1) {
+      item.char.split('').forEach((char, index) => {
+        // 只对第一个字符标记停顿
+        processCharacter(char, index === 0)
+      })
+    } else {
+      // 处理单字符插入
+      processCharacter(item.char, true)
+    }
+  })
+
+  // 添加最后一行(如果有内容)
+  if (currentLine.chars.length > 0) {
+    lines.push(currentLine)
+  }
+
+  return lines
+})
+
+// 根据停顿时间获取对应的颜色
+const getColor = (pauseTime) => {
+  // 查找第一个满足 pauseTime <= item.time 的颜色项
+  const legend = colorLegend.value.find(item => pauseTime <= item.time)
+  // 返回找到的颜色,或默认最深的颜色
+  return legend?.color || colorLegend.value[colorLegend.value.length - 1].color
+}
+
+// 显示提示框
+const showTooltip = (totalPauseTime, event) => {
+  tooltipVisible.value = true
+  tooltipText.value = totalPauseTime
+  tooltipX.value = event.clientX
+  tooltipY.value = event.clientY
+}
+
+// 隐藏提示框
+const hideTooltip = () => {
+  tooltipVisible.value = false
+}
+</script>
+
+<style scoped>
+#HeatMap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;      /* 垂直居中 */
+  height: 500px;            /* 占满整个视口高度 */
+  width: 95%;              /* 占满整个宽度 */
+}
+
+.container {
+  max-width: 1000px;
+  margin: 0 auto;
+  padding: 20px;
+  font-family: Arial, sans-serif;
+}
+
+.text-container {
+  position: relative;
+  margin-bottom: 30px;
+}
+
+.text-line {
+  position: relative;
+  margin-bottom: 25px;
+  white-space: nowrap;
+}
+
+.char {
+  display: inline-block;
+  width: v-bind('charWidth + "px"');
+  text-align: center;
+}
+
+.heatmap {
+  position: absolute;
+  bottom: -20px;
+  left: 0;
+  height: v-bind('heatmapRectHeight + "px"');
+}
+
+.heatmap-rect {
+  position: absolute;
+  border-radius: 2px;
+  cursor: pointer;
+}
+
+.legend {
+  display: flex;
+  gap: 15px;
+  margin-top: 50px;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.legend-color {
+  width: 20px;
+  height: 20px;
+  border-radius: 2px;
+}
+
+.tooltip {
+  position: absolute;
+  background-color: rgba(0, 0, 0, 0.8);
+  color: white;
+  padding: 5px;
+  border-radius: 3px;
+  font-size: 12px;
+  z-index: 10;
+}
+
+.controls {
+  font-size: 15px;
+  margin-bottom: 20px;
+}
+.controls label {
+  margin-right: 20px;
+}
+.controls select {
+  margin-left: 5px;
+  padding: 2px 8px;
+}
+</style>

+ 182 - 0
src/views/BehaviorPage/component/PauseCanvas.vue

@@ -0,0 +1,182 @@
+<script setup lang="ts">
+import {onMounted, type Ref, ref, watch} from "vue";
+import * as echarts from 'echarts';
+import {inject} from "vue-demi";
+import useApis from "@/apis";
+import type {EChartsType} from "echarts";
+import {ElMessage} from "element-plus";
+const props = defineProps<{
+  assignmentId:number,
+  splitNumber:number,
+  barWidth:number,
+}>()
+const apis = useApis();
+const pauseData:any[] = [];
+let chartData:any[] = [];
+const limit = {
+  max:0,
+  min:0
+}
+function formatTimeWithMilliseconds(stamp:number ){
+  const date = new Date(stamp);
+  const hours = date.getHours().toString().padStart(2, '0');
+  const minutes = date.getMinutes().toString().padStart(2, '0');
+  const seconds = date.getSeconds().toString().padStart(2, '0');
+  const milliseconds = date.getMilliseconds().toString().padStart(3, '0');
+  return `${hours}:${minutes}:${seconds}`;
+}
+
+const currentEngagement = inject('currentEngagement') as Ref<any>;
+watch(currentEngagement,()=>{
+  let chartDom = document.getElementById('pause-canvas');
+  let myChart = echarts.getInstanceByDom(chartDom);
+  getCanvasData(myChart);
+});
+
+const maxPauseTime = inject('maxPauseTime') as Ref<number>;
+const loadingStatus = inject('loadingStatus') as Ref<{
+  edit:boolean,
+  pause:boolean,
+  process:boolean,
+  total:boolean
+}>;
+function getCanvasData(chart:EChartsType){
+  if (!currentEngagement.value){
+    return;
+  }
+  let studentId = currentEngagement.value.studentId;
+  let assignmentId = currentEngagement.value.assignmentId;
+  chart.showLoading({text:'加载中'});
+  loadingStatus.value.pause = true;
+  apis.getBehaviorRecordAll(studentId,assignmentId)
+      .then((res:any) => {
+        if (res && res.data && res.data.code === 200){
+          let rowData = res.data.data.events as any[];
+          console.log("行为数据",rowData);
+          let zeroCount =0;
+          pauseData.length = 0;
+          chartData.length = 0;
+          if (!rowData){
+            ElMessage.error('获取停顿数据失败')
+            loadingStatus.value.pause = false;
+            chart.hideLoading();
+            return;
+          }
+          if (rowData.length>0){
+            limit.max = rowData[0].pauseTime;
+          }
+          rowData.forEach((item:any) => {
+            // console.log("item",item)
+            let startStamp = 0;
+            if (item.startStamp){
+              startStamp = item.startStamp;
+            }
+            else {
+              return;
+            }
+            if (item.pauseTime < 0){
+              return;
+            }
+            if (startStamp == 0){
+              zeroCount++;
+            }
+            if (zeroCount>1){
+              zeroCount = 0;
+              return;
+            }
+            let clock = new Date(startStamp);
+            clock.setHours(clock.getHours() + 16);
+            pauseData.push([clock,item.pauseTime])
+            chartData.push([clock,item.pauseTime])
+            limit.max = Math.max(limit.max,item.pauseTime);
+          })
+          maxPauseTime.value = limit.max;
+          let option = {
+            xAxis: {
+              type:'time',
+              splitNumber: props.splitNumber,
+            },
+            yAxis: {
+              type:'value',
+              name:'停顿时间(ms)',
+
+            },
+            tooltip:{
+              formatter:(value:any)=>{
+                let date = value.data[0];
+                let data = value.data[1];
+                return `停顿时刻:${[date.getHours(), date.getMinutes(), date.getSeconds()]
+                    .map(num => num.toString().padStart(2, '0'))
+                    .join(':')}\n  停顿时长:${data}ms`
+              }
+            },
+            visualMap: {
+              type: 'continuous', // 连续型映射
+              calculable: true,
+              min: limit.min,             // 数据最小值
+              max: limit.max,           // 数据最大值
+              dimension: 1,       // 根据第2个维度(y值)映射颜色
+              inRange: {
+                color: ['orange', 'red'] // 渐变范围:小值→蓝色,大值→红色
+              },
+              itemWidth: props.barWidth,
+              bottom:0,
+              right:0,
+            },
+            dataZoom: [
+              {
+                type: 'inside',
+              },
+              {
+                type: 'slider',
+                labelFormatter: function (value) {
+                  return formatTimeWithMilliseconds(value);
+                }
+              }
+            ],
+            series: [
+              {
+                symbolSize: 10,
+
+                data: chartData,
+                type: 'scatter'
+              }
+            ]
+          };
+          chart.setOption( option);
+          chart.hideLoading();
+          loadingStatus.value.pause = false;
+          chart.on('datarangeselected', (params:any) => {
+            let range = params.selected as [number,number];
+            chartData = pauseData.filter((item:any) => {
+              return item[1] >= range[0] && item[1] <= range[1];
+            })
+            option.series[0].data = chartData;
+            chart.setOption(option)
+          });
+        }
+      })
+
+}
+onMounted(()=>{
+  let chartDom = document.getElementById('pause-canvas');
+  let myChart = echarts.init(chartDom);
+  getCanvasData(myChart)
+  window.addEventListener('resize', ()=>{
+    myChart.resize()
+  })
+})
+</script>
+
+<template>
+  <div id="pause-canvas" class="canvas-container">
+
+  </div>
+</template>
+
+<style scoped>
+.canvas-container{
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 253 - 0
src/views/BehaviorPage/component/ProcessCanvas.vue

@@ -0,0 +1,253 @@
+<script setup lang="ts">
+import {onMounted, type Ref, watch} from "vue";
+import * as echarts from 'echarts';
+import {inject} from "vue-demi";
+import type {EChartsType} from "echarts";
+import useApis from "@/apis";
+const props = defineProps<{
+  assignmentId:number,
+  splitNumber:number,
+}>()
+
+const currentEngagement = inject('currentEngagement') as Ref<any>;
+let productData = [];
+let cursorData = [];
+let processData = [];
+watch(currentEngagement,()=>{
+  let chartDom = document.getElementById('process-canvas');
+  let myChart = echarts.getInstanceByDom(chartDom);
+  getCanvasData(myChart);
+});
+const loadingStatus = inject('loadingStatus') as Ref<{
+  edit:boolean,
+  pause:boolean,
+  process:boolean,
+  total:boolean
+}>;
+function formatTimeWithMilliseconds(stamp:number ){
+  const date = new Date(stamp);
+  const hours = date.getHours().toString().padStart(2, '0');
+  const minutes = date.getMinutes().toString().padStart(2, '0');
+  const seconds = date.getSeconds().toString().padStart(2, '0');
+  const milliseconds = date.getMilliseconds().toString().padStart(3, '0');
+  return `${hours}:${minutes}:${seconds}.${milliseconds}`;
+}
+const apis = useApis();
+//停顿数据
+const pauseData:any[] = [];
+let chartData:any[] = [];
+//visualMap 上下限
+const limit = {
+  max:0,
+  min:0
+}
+function getCanvasData(chart:EChartsType){
+  //TODO
+  if (!currentEngagement.value){
+    return;
+  }
+  chart.showLoading({text:'加载中'});
+  loadingStatus.value.process = true;
+  let studentId = currentEngagement.value.studentId;
+  let assignmentId = currentEngagement.value.assignmentId;
+  apis.getBehaviorRecordAll(studentId,assignmentId)
+      .then((res:any) => {
+        if (res && res.data && res.data.code === 200){
+          let rowData = res.data.data.events as any[];
+          console.log("行为数据",rowData);
+          //数组初始化
+          cursorData.length=0;
+          productData.length = 0;
+          processData.length = 0;
+          pauseData.length = 0;
+          chartData.length = 0;
+          let processCount = 0;
+          let zeroCount =0;
+          if (!rowData){
+            chart.hideLoading();
+            loadingStatus.value.process = false;
+            return
+          }
+          rowData.forEach((item:any) => {
+            // console.log("item",item)
+            let startStamp = 0;
+            if (item.startStamp){
+              startStamp = item.startStamp;
+            }
+            else{
+              return;
+            }
+            if (item.pauseTime < 0){
+              return;
+            }
+            if (startStamp == 0){
+              zeroCount++;
+            }
+            if (zeroCount>1){
+              zeroCount = 0;
+              return;
+            }
+            let clock = new Date(startStamp);
+            clock.setHours(clock.getHours() + 16);
+            clock.setFullYear(2025)
+            clock.setMonth(6)
+            clock.setDate(26);
+            cursorData.push([clock,item.retain+1])
+            productData.push([clock,item.docLength])
+            pauseData.push([clock,item.pauseTime])
+            chartData.push([clock,item.pauseTime])
+
+            limit.max = Math.max(limit.max,item.pauseTime);
+            let temp = 0;
+            if (productData.length == 1){
+              temp = productData[0][1];
+            }
+            else{
+              let pre = productData[productData.length-2][1];
+              if (item.docLength > pre){
+                temp += item.docLength - pre;
+              }
+            }
+            processCount+=temp;
+            processData.push([clock,processCount])
+          })
+          let option = {
+            xAxis: {
+              type: 'time',
+              axisPointer: {
+                label: {
+                  show: true,
+                  formatter: function(params) {
+                    return formatTimeWithMilliseconds(params.value);
+                  }
+                },
+                show: true,
+                type: "line"
+              },
+              splitNumber: props.splitNumber
+            },
+            yAxis: [{
+              type: 'value',
+              name:'characters',
+              position:'left',
+              alignTicks:true
+            },{
+              type: 'value',
+              name:'停顿时间(ms)',
+              position: 'right',
+            }],
+            tooltip:{
+
+            },
+            dataZoom: [
+              {
+                type: 'inside',
+              },
+              {
+                type: 'slider',
+              }
+            ],
+            legend:{
+              left:0
+            },
+            visualMap: {
+              type: 'continuous', // 连续型映射
+              calculable: true,
+              min: 0,             // 数据最小值
+              max: limit.max,           // 数据最大值
+              dimension: 1,       // 根据第2个维度(y值)映射颜色
+              inRange: {
+                color: ['orange', 'red'] // 渐变范围:小值→蓝色,大值→红色
+              },
+              seriesIndex: [3],
+              itemWidth: 10,
+              top:0,
+              right:0,
+              orient:'horizontal',
+              align: 'right',
+            },
+            series: [
+              {
+                itemStyle: {
+                  color:'#37ff00',
+                  opacity: 0
+                },
+                name:'product',
+                data: productData,
+                type: 'line',
+                yAxisIndex: 0
+              },
+              {
+                itemStyle: {
+                  color:'#37ff00',
+                  opacity: 0
+                },
+                lineStyle: {
+                  type: "dotted"
+                },
+                name:'cursor',
+                data: cursorData,
+                type: 'line',
+                yAxisIndex: 0
+              },
+              {
+                itemStyle: {
+                  color:'#1d86dc',
+                  opacity: 0
+                },
+                name:'process',
+                data: processData,
+                type: 'line',
+                yAxisIndex: 0
+              },
+              {
+                symbolSize: 10,
+
+                data: chartData,
+                type: 'scatter',
+                yAxisIndex: 1
+              }
+            ]
+          };
+          chart.setOption( option);
+          chart.hideLoading();
+          loadingStatus.value.process = false;
+          chart.on('datarangeselected', (params:any) => {
+            let range = params.selected as [number,number];
+            chartData = pauseData.filter((item:any) => {
+              return item[1] >= range[0] && item[1] <= range[1];
+            })
+            option.series[3].data = chartData;
+            chart.setOption(option)
+          });
+        }
+      })
+  //测试用数据
+
+}
+
+
+
+
+onMounted(()=>{
+  let chartDom = document.getElementById('process-canvas');
+  let myChart = echarts.init(chartDom);
+  getCanvasData(myChart)
+  window.addEventListener('resize', ()=>{
+    myChart.resize()
+  })
+})
+</script>
+
+<template>
+  <div id="process-canvas" class="canvas-container">
+
+  </div>
+</template>
+
+<style scoped>
+.canvas-container{
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 115 - 0
src/views/BehaviorPage/component/TotalCanvas.vue

@@ -0,0 +1,115 @@
+<script setup lang="ts">
+import {onMounted, type Ref, watch} from "vue";
+import * as echarts from 'echarts';
+import {inject} from "vue-demi";
+
+import useApis from "@/apis";
+import {type EChartsType} from "echarts";
+
+const apis = useApis()
+defineProps<{
+  assignmentId:number
+}>()
+const currentEngagement = inject('currentEngagement') as Ref<any>;
+
+const loadingStatus = inject('loadingStatus') as Ref<{
+  edit:boolean,
+  pause:boolean,
+  process:boolean,
+  total:boolean
+}>;
+
+watch(currentEngagement,()=>{
+  if (currentEngagement.value === null){
+    return;
+  }
+  let chartDom = document.getElementById('total-canvas');
+  let myChart = echarts.getInstanceByDom(chartDom);
+  drawChart(myChart);
+},{deep:true})
+
+let data = []
+function drawChart(chart: EChartsType){
+  //TODO
+  if (!currentEngagement.value){
+    return;
+  }
+  chart.showLoading({
+    text: '加载中'
+  });
+  loadingStatus.value.total = true;
+  apis.getOverview(currentEngagement.value.studentId,currentEngagement.value.assignmentId)
+  .then((res:any) => {
+    // console.log(res);
+    if (res&&res.data&&res.data.code === 200){
+      data = [
+        { value: res.data.data.insertCount, name: '产出行为' },
+        { value: res.data.data.longPauseCount, name: '停顿行为' },
+        { value: res.data.data.deleteCount, name: '修改行为' },
+      ]
+      let option = {
+        tooltip: {
+          trigger: 'item'
+        },
+        legend: {
+          top: '5%',
+          left: 'center'
+        },
+        series: [
+          {
+            name: '总体分析',
+            type: 'pie',
+            radius: ['40%', '70%'],
+            avoidLabelOverlap: false,
+            bottom:-50,
+            itemStyle: {
+              borderRadius: 10,
+              borderColor: '#fff',
+              borderWidth: 2
+            },
+            label: {
+              show: false,
+              position: 'center'
+            },
+            emphasis: {
+              label: {
+                show: true,
+                fontSize: 20,
+                fontWeight: 'bold'
+              }
+            },
+            labelLine: {
+              show: false
+            },
+            data: data
+          }
+        ]
+      };
+      chart.setOption( option);
+      chart.hideLoading();
+      loadingStatus.value.total = false;
+    }
+  })
+}
+onMounted(()=>{
+  let chartDom = document.getElementById('total-canvas');
+  let myChart = echarts.init(chartDom);
+  drawChart(myChart)
+  window.addEventListener('resize', ()=>{
+    myChart.resize()
+  })
+})
+</script>
+
+<template>
+<div id="total-canvas" class="canvas-container">
+
+</div>
+</template>
+
+<style scoped>
+.canvas-container{
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 140 - 0
src/views/BehaviorPage/subPage/BehaviorHome.vue

@@ -0,0 +1,140 @@
+<script setup lang="ts">
+
+import router from "@/router";
+import TotalCanvas from "@/views/BehaviorPage/component/TotalCanvas.vue";
+import PauseCanvas from "@/views/BehaviorPage/component/PauseCanvas.vue";
+import EditCanvas from "@/views/BehaviorPage/component/EditCanvas.vue";
+import ProcessCanvas from "@/views/BehaviorPage/component/ProcessCanvas.vue";
+
+function toDetail(index:number){
+  if ( index === 1){
+    router.push("/home/behavior/total")
+  }
+  else if ( index === 2){
+    router.push("/home/behavior/pause")
+  }
+  else if ( index === 3){
+    router.push("/home/behavior/edit")
+  }
+  else{
+    router.push("/home/behavior/process")
+  }
+}
+
+</script>
+
+<template>
+<div class="behavior-main">
+  <el-card class="small-card">
+    <template #header>
+      <div class="small-card-header">
+        <span class="card-title">
+          总体分析
+        </span>
+        <span
+            @click="toDetail(1)"
+            class="card-text-button">
+          查看详情
+        </span>
+      </div>
+    </template>
+    <div style="width: 100%;height: 350px">
+      <TotalCanvas
+          :assignment-id="1"/>
+    </div>
+  </el-card>
+  <el-card class="small-card">
+    <template #header>
+      <div class="small-card-header">
+        <span class="card-title">
+          停顿分析
+        </span>
+        <span
+            @click="toDetail(2)"
+            class="card-text-button">
+          查看详情
+        </span>
+      </div>
+    </template>
+    <div style="width: 100%;height: 350px">
+      <PauseCanvas
+          :split-number="3"
+          :bar-width="10"
+          :assignment-id="1"/>
+    </div>
+  </el-card>
+  <el-card class="small-card">
+    <template #header>
+      <div class="small-card-header">
+        <span class="card-title">
+          修改分析
+        </span>
+        <span
+            @click="toDetail(3)"
+            class="card-text-button">
+          查看详情
+        </span>
+      </div>
+    </template>
+    <div style="width: 100%;height: 350px">
+      <EditCanvas
+          :split-number="4"
+          :assignment-id="1"/>
+    </div>
+  </el-card>
+  <el-card class="small-card">
+    <template #header>
+      <div class="small-card-header">
+        <span class="card-title">
+          写作过程
+        </span>
+        <span
+            @click="toDetail(4)"
+            class="card-text-button">
+          查看详情
+        </span>
+      </div>
+    </template>
+    <div style="width: 100%;height: 350px">
+      <ProcessCanvas
+          :split-number="4"
+          :assignment-id="1"/>
+    </div>
+  </el-card>
+</div>
+</template>
+
+<style scoped>
+.behavior-main{
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  grid-gap: 16px;
+  width: 70%;
+  min-width: 700px;
+}
+.small-card{
+  width: 100%;
+  border-radius: 16px;
+}
+.small-card-header{
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  width: 100%;
+}
+.canvas{
+  width: 100%;
+  height: 300px;
+}
+.card-title{
+  font-size: 16px;
+  font-weight: bold;
+}
+.card-text-button{
+  font-size: 16px;
+}
+.card-text-button:hover{
+  color: var(--main-color);
+  cursor: pointer;
+}
+</style>

+ 1 - 0
src/views/BehaviorPage/subPage/EditAnalysis.scss

@@ -0,0 +1 @@
+

+ 162 - 0
src/views/BehaviorPage/subPage/EditAnalysis.vue

@@ -0,0 +1,162 @@
+<script setup lang="ts">
+import "./TotalAnalysis.scss"
+import EditCanvas from "@/views/BehaviorPage/component/EditCanvas.vue";
+import {onMounted, type Ref, ref, watch} from "vue";
+import {inject} from "vue-demi";
+import * as echarts from "echarts";
+import useApis from "@/apis";
+let tableData = ref([])
+
+const currentEngagement = inject('currentEngagement') as Ref<any>;
+watch(currentEngagement,()=>{
+  getTableData();
+});
+
+const apis = useApis();
+
+function formatTimeWithMilliseconds(stamp:number ){
+  const date = new Date(stamp);
+  date.setHours(date.getHours()+16)
+  const hours = date.getHours().toString().padStart(2, '0');
+  const minutes = date.getMinutes().toString().padStart(2, '0');
+  const seconds = date.getSeconds().toString().padStart(2, '0');
+  const milliseconds = date.getMilliseconds().toString().padStart(3, '0');
+  return `${hours}:${minutes}:${seconds}.${milliseconds}`;
+}
+
+function getTableData() {
+  tableData.value = [
+    {
+      revision: 0,
+      type: "Normal Production",
+      content: "This is a test",
+      edits: "???",
+      start: "125",
+      end: "145",
+      duration: "10s",
+      beginPos: "0",
+      endPos: "10",
+      length: "10",
+    },
+    {
+      revision: 1,
+      type: "Delete",
+      content: "This is a test",
+      edits: "???",
+      start: "125",
+      end: "145",
+      duration: "10s",
+      beginPos: "0",
+      endPos: "10",
+      length: "10",
+    },
+    {
+      revision: 0,
+      type: "Normal Production",
+      content: "This is a test",
+      edits: "???",
+      start: "125",
+      end: "145",
+      duration: "10s",
+      beginPos: "0",
+      endPos: "10",
+      length: "10",
+    },
+    {
+      revision: 2,
+      type: "Insert",
+      content: "This is a test",
+      edits: "???",
+      start: "125",
+      end: "145",
+      duration: "10s",
+      beginPos: "0",
+      endPos: "10",
+      length: "10",
+    },
+
+  ]
+  if (!currentEngagement.value){
+    return;
+  }
+  let studentId = currentEngagement.value.studentId;
+  let assignmentId = currentEngagement.value.assignmentId;
+  apis.getBehaviorRecordAll(studentId,assignmentId)
+      .then((res:any) => {
+        if (res && res.data && res.data.code === 200){
+          let rowData = res.data.data.events as any[];
+          console.log("行为数据",rowData);
+          tableData.value.length = 0;
+          rowData.forEach((item:any,index:number) => {
+            if (item.type === 'insert'){
+              tableData.value.push({
+                revision: index,
+                type: "Normal Production",
+                content: item.insert,
+                start: formatTimeWithMilliseconds(item.startStamp),
+                end: formatTimeWithMilliseconds(item.endStamp),
+                duration: item.pauseTime/1000,
+                beginPos: item.retain,
+                endPos: item.retain+1,
+                length: item.docLength,
+              })
+            }
+            else if (item.type === 'delete'){
+              tableData.value.push({
+                revision: index,
+                type: "Delete",
+                content: item.delete,
+                start: formatTimeWithMilliseconds(item.startStamp),
+                end: formatTimeWithMilliseconds(item.endStamp),
+                duration: item.pauseTime/1000,
+                beginPos: item.retain,
+                endPos: item.retain+1,
+                length: item.docLength,
+              })
+            }
+          })
+        }
+      })
+}
+onMounted(()=>{
+  getTableData()
+})
+</script>
+
+<template>
+  <el-card class="detail-card">
+    <template #header>
+      <div>
+        <span class="card-title">
+          修改分析
+        </span>
+      </div>
+    </template>
+    <div class="total-canvas-container">
+      <EditCanvas
+          :split-number="10"
+          :assignment-id="1"/>
+    </div>
+    <div class="table-container">
+      <el-table
+          empty-text="暂无数据"
+          :data="tableData" style="width: 90%" max-height="500">
+        <el-table-column prop="revision" label="#Revision" width="95"/>
+        <el-table-column prop="type" label="Type" width="100">
+        </el-table-column>
+        <el-table-column prop="content" label="content" show-overflow-tooltip width="110"/>
+<!--        <el-table-column prop="edits" label="Edits"  />-->
+        <el-table-column prop="start" label="Start" />
+        <el-table-column prop="end" label="End"/>
+        <el-table-column prop="duration" label="Duration" />
+        <el-table-column prop="beginPos" label="BeginPos" width="90"/>
+        <el-table-column prop="endPos" label="EndPos"/>
+        <el-table-column prop="length" label="Length" />
+      </el-table>
+    </div>
+  </el-card>
+</template>
+
+<style scoped>
+
+</style>

+ 146 - 0
src/views/BehaviorPage/subPage/PauseAnalysis.vue

@@ -0,0 +1,146 @@
+<script setup lang="ts">
+import "./TotalAnalysis.scss"
+import PauseCanvas from "@/views/BehaviorPage/component/PauseCanvas.vue";
+import {onMounted, provide, reactive, ref, type Ref, watch} from "vue";
+import HeatMap from "@/views/BehaviorPage/component/HeatMap.vue";
+import {inject} from "vue-demi";
+import useApis from "@/apis";
+import {ElMessage} from "element-plus";
+
+interface StatisticData{
+  totalTime:number,
+  pauseTimes:number,
+  averagePauseTime:number,
+  medianPauseTime:number,
+  standardDeviation:number,
+}
+const currentEngagement = inject('currentEngagement') as Ref<any>;
+const maxPauseTime = inject('maxPauseTime') as Ref<number>;
+
+const threshold = ref(2000);
+watch(currentEngagement,()=>{
+  getStatisticData()
+});
+
+const data:Ref<StatisticData> = ref({
+  totalTime:undefined,
+  pauseTimes:undefined,
+  averagePauseTime:undefined,
+  medianPauseTime:undefined,
+  standardDeviation:undefined
+})
+const dataLoading = ref(true);
+const apis = useApis();
+function getStatisticData(){
+  //TODO
+  if (!currentEngagement.value){
+    return;
+  }
+  // threshold.value = 0;
+  dataLoading.value = true;
+  apis.getPauseStatisticData(currentEngagement.value.studentId,currentEngagement.value.assignmentId,threshold.value)
+      .then((res:any) => {
+        console.log(res);
+        if (res && res.data &&res.data.code === 200){
+          data.value.averagePauseTime = res.data.data.averagePauseTime;
+          data.value.medianPauseTime = res.data.data.medianPauseTime;
+          data.value.pauseTimes = res.data.data.pauseCount;
+          data.value.standardDeviation = res.data.data.standardDeviation;
+          data.value.totalTime = res.data.data.totalPauseTime;
+          dataLoading.value = false;
+        }
+      })
+      .catch((err:any) => {
+        ElMessage.error("获取停顿统计数据失败");
+        dataLoading.value = false;
+      })
+}
+
+onMounted(()=>{
+  getStatisticData();
+})
+</script>
+
+<template>
+  <el-card class="detail-card">
+    <template #header>
+      <div>
+        <span class="card-title">
+          停顿分析
+        </span>
+      </div>
+    </template>
+    <div id="threshold-container">
+      <span class="threshold-title">停顿阈值</span>
+      <el-slider v-model="threshold"
+                 :min="0"
+                 show-input
+                 :disabled="dataLoading"
+                 style="width: 50%;margin-left: 50px"
+                 :format-tooltip="(value:number)=>{
+                   return value+'ms';
+                 }"
+                 @change="getStatisticData"
+                 :max="maxPauseTime"/>
+    </div>
+    <div class="statistic-container" v-loading="dataLoading">
+      <el-statistic
+          :value="data.totalTime"
+          suffix="min"
+          title="停顿总时长">
+        <template #suffix>
+          <span class="statistic-suffix" >ms</span>
+        </template>
+      </el-statistic>
+      <el-divider direction="vertical" style="height: 50px;margin: 0"/>
+      <el-statistic
+          :value="data.pauseTimes"
+          title="停顿总次数">
+        <template #suffix>
+          <span class="statistic-suffix" >times</span>
+        </template>
+      </el-statistic>
+      <el-divider direction="vertical" style="height: 50px;margin: 0"/>
+      <el-statistic
+          :value="data.averagePauseTime"
+          title="平均停顿时长">
+        <template #suffix>
+          <span class="statistic-suffix" >ms</span>
+        </template>
+      </el-statistic>
+      <el-divider direction="vertical" style="height: 50px;margin: 0"/>
+      <el-statistic
+          :value="data.medianPauseTime"
+          title="停顿时长中位数">
+        <template #suffix>
+          <span class="statistic-suffix" >ms</span>
+        </template>
+      </el-statistic>
+      <el-divider direction="vertical" style="height: 50px;margin: 0"/>
+      <el-statistic
+          :value="data.standardDeviation"
+          title="停顿时长标准差"/>
+    </div>
+    <div class="total-canvas-container">
+      <PauseCanvas
+          :split-number="10"
+          :bar-width="20"
+          :assignment-id="1"/>
+    </div>
+    <HeatMap/>
+  </el-card>
+</template>
+
+<style scoped>
+.statistic-suffix{
+  font-size: 12px;
+  color: var(--el-text-color-regular);
+}
+#threshold-container{
+  width: 100%;
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  margin: 20px 0 20px 0;
+}
+</style>

+ 24 - 0
src/views/BehaviorPage/subPage/ProcessAnalysis.vue

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+import "./TotalAnalysis.scss"
+import TotalCanvas from "@/views/BehaviorPage/component/TotalCanvas.vue";
+import ProcessCanvas from "@/views/BehaviorPage/component/ProcessCanvas.vue";
+</script>
+
+<template>
+  <el-card class="detail-card">
+    <template #header>
+      <div>
+        <span class="card-title">
+          写作过程
+        </span>
+      </div>
+    </template>
+    <div class="total-canvas-container">
+      <ProcessCanvas assignment-id="-1"  split-number="3"/>
+    </div>
+  </el-card>
+</template>
+
+<style scoped>
+
+</style>

+ 29 - 0
src/views/BehaviorPage/subPage/TotalAnalysis.scss

@@ -0,0 +1,29 @@
+.detail-card{
+  width: 70%;
+  height: auto;
+  border-radius: 16px;
+  margin-bottom: 50px;
+  min-width: 700px;
+}
+.card-title{
+  font-size: 16px;
+  font-weight: bold;
+}
+.total-canvas-container{
+  width: 100%;
+  height: 400px;
+}
+.table-container{
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.statistic-container{
+  width: 90%;
+  margin-left: 5%;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+  align-items: center;
+}

+ 123 - 0
src/views/BehaviorPage/subPage/TotalAnalysis.vue

@@ -0,0 +1,123 @@
+<script setup lang="ts">
+import "./TotalAnalysis.scss"
+import TotalCanvas from "@/views/BehaviorPage/component/TotalCanvas.vue";
+import {onMounted, type Ref, ref, watch} from "vue";
+import useApis from "@/apis";
+import {inject} from "vue-demi";
+import * as echarts from "echarts";
+import {type TableColumnCtx} from "element-plus";
+
+let tableData = ref([])
+const apis = useApis();
+
+const currentEngagement = inject('currentEngagement') as Ref<any>;
+watch(currentEngagement,()=>{
+  getBehaviorData()
+},{deep:true})
+
+function getTagType(type:string){
+  if (type === "insert"){
+    return "primary"
+  }
+  else if (type === "cursor_move"){
+    return "success"
+  }
+  else if (type === "delete"){
+    return "danger"
+  }
+}
+interface Event {
+  Id:number,
+  EventType:string,
+  Output:string|null|undefined,
+  Position:number,
+}
+
+function getBehaviorData(){
+  //TODO
+  // tableData.value = [
+  //   {Id:0,EventType:"focus",Output:"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",Position:"12",DocLength:"12",StartTime:"00:00:00",EndTime:"00:01:05"},
+  //   {Id:1,EventType:"mouse",Output:"xxxx",Position:"12",DocLength:"12",StartTime:"00:00:00",EndTime:"00:01:05"},
+  //   {Id:2,EventType:"keyboard",Output:"xxxx",Position:"12",DocLength:"12",StartTime:"00:00:00",EndTime:"00:01:05"},
+  //   {Id:3,EventType:"focus",Output:"xxxx",Position:"12",DocLength:"12",StartTime:"00:00:00",EndTime:"00:01:05"},
+  //   {Id:4,EventType:"focus",Output:"xxxx",Position:"12",DocLength:"12",StartTime:"00:00:00",EndTime:"00:01:05"},
+  //   {Id:5,EventType:"mouse",Output:"xxxx",Position:"12",DocLength:"12",StartTime:"00:00:00",EndTime:"00:01:05"},
+  //   {Id:6,EventType:"keyboard",Output:"xxxx",Position:"12",DocLength:"12",StartTime:"00:00:00",EndTime:"00:01:05"},
+  // ];
+  if (!currentEngagement.value){
+    return;
+  }
+  apis.getBehaviorRecordAll(currentEngagement.value.studentId,currentEngagement.value.assignmentId)
+    .then((res: any) => {
+      if (res && res.data && res.data.code === 200){
+        let rowData = res.data.data
+        console.log("行为数据",res)
+        tableData.value = res.data.data.events.map((item:any,index:number) => {
+          return {
+            Id:index,
+            EventType:item.type,
+            Output:item.insert,
+            Position:item.retain,
+          }
+        })
+        console.log("tableData",tableData)
+      }
+    })
+}
+const filterHandler = (
+    value: string,
+    row: Event,
+    column: TableColumnCtx<Event>
+) => {
+  const property = column['property']
+  return row[property] === value
+}
+
+
+onMounted(()=>{
+  getBehaviorData();
+})
+
+
+</script>
+
+<template>
+  <el-card class="detail-card">
+    <template #header>
+      <div>
+        <span class="card-title">
+          总体分析
+        </span>
+      </div>
+    </template>
+    <div class="total-canvas-container">
+      <TotalCanvas
+          :assignment-id="1"/>
+    </div>
+    <div class="table-container">
+      <el-table :data="tableData" style="width: 90%" max-height="500">
+        <el-table-column prop="Id" label="Id" width="120" sortable/>
+        <el-table-column prop="EventType"
+                         :filters="[
+                             {text:'insert',value:'insert'},
+                             {text:'delete',value:'delete'},
+                             {text:'cursor_move',value:'cursor_move'}]"
+                         :filter-method="filterHandler"
+                         label="Event Type" >
+          <template #default="scope">
+            <el-tag :type="getTagType(scope.row.EventType)">{{ scope.row.EventType }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="Output" label="Output" show-overflow-tooltip width="160"/>
+        <el-table-column prop="Position" label="Position"  />
+<!--        <el-table-column prop="DocLength" label="DocLength" />-->
+<!--        <el-table-column prop="StartTime" label="StartTime"/>-->
+<!--        <el-table-column prop="EndTime" label="EndTime" />-->
+      </el-table>
+    </div>
+  </el-card>
+</template>
+
+<style scoped>
+
+</style>

+ 4 - 1
src/views/Home/Role.vue

@@ -4,7 +4,7 @@
   Created Date: 2024-6-30
 -->
 <template>
-  <div class="home-container">
+  <div id="home-container" class="home-container">
     <router-view v-if="isRouterAlive"></router-view>
   </div>
 </template>
@@ -61,6 +61,9 @@
         case 'MyWork':
           router.push("/home/myCorrect/course");
           break;
+        case 'Behavior':
+          router.push("/home/behavior/main");
+          break;
       }
     }
   }

+ 11 - 11
src/views/HomeworkPage/component/HomeworkDetail.vue

@@ -28,10 +28,10 @@
           <el-link type="primary" @click="downloadFile(data.descriptionFile, `${data.assignmentName}-描述文件`)">相关文件下载</el-link>
           <div class="button-container">
             <el-button style="margin-left: 550px; color: #FFFFFF;" color="#7EB5E1" @click="handleBack">返回</el-button>
-            <el-button 
-              style="color: #FFFFFF;" 
-              color="#7ADBB0" 
-              @click="handleStart()" 
+            <el-button
+              style="color: #FFFFFF;"
+              color="#7ADBB0"
+              @click="handleStart()"
               :disabled="isAssignmentDisabled"
               :title="getButtonTooltip"
             >
@@ -100,12 +100,12 @@ 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) {
     // 超过截止日期时,只有未完成状态的学生可以点击
@@ -115,7 +115,7 @@ const isAssignmentDisabled = computed(() => {
     // 如果没有参与状态信息,允许点击(首次参与)
     return false
   }
-  
+
   // 在作业时间范围内,不禁用
   return false
 })
@@ -125,7 +125,7 @@ const getButtonTooltip = computed(() => {
   const now = new Date()
   const startTime = new Date(data.startTime)
   const endTime = new Date(data.endTime)
-  
+
   if (now < startTime) {
     return `作业尚未开始,开始时间:${startTime.toLocaleString()}`
   } else if (now > endTime) {
@@ -241,17 +241,17 @@ export default {
   .button-container {
     display: flex;
   }
-  
+
   .el-button {
     margin-right: 0px;
   }
-  
+
   /* 禁用状态的开始按钮样式 */
   .el-button:disabled {
     opacity: 0.6;
     cursor: not-allowed;
   }
-  
+
   .el-button:disabled:hover {
     opacity: 0.6;
   }