Ver Fonte

fix:增加热度图停顿阈值,修改数据源为数据库

wuzilong há 1 ano atrás
pai
commit
6a91b3caab
2 ficheiros alterados com 362 adições e 0 exclusões
  1. 8 0
      src/apis/behavior.ts
  2. 354 0
      src/views/HeatMapPage/HeatMap.vue

+ 8 - 0
src/apis/behavior.ts

@@ -11,6 +11,14 @@ const bevaviorApis = {
             }
         })
     },
+    selectAllBehaviorRecord: (studentId: number, assignmentId :number) => {
+        return axiosInstance.get(`${PREFIX}/all`, {
+            params: {
+                studentId,
+                assignmentId
+            }
+        })
+    },
     addBehaviorRecord: (assignmentId :number, file) => {
         const formData = new FormData();
         formData.append("assignmentId", assignmentId.toString())

+ 354 - 0
src/views/HeatMapPage/HeatMap.vue

@@ -0,0 +1,354 @@
+<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`
+              }"
+                @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>
+import {ref, computed, onMounted} from 'vue'
+import bevaviorApis from '@/apis/behavior'
+
+// 响应式数据 - 控制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 studentId = 16
+const assignmentId = 7
+
+onMounted(async () => {
+  try {
+    const res = await bevaviorApis.selectAllBehaviorRecord(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(1000, 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 + 10
+  tooltipY.value = event.clientY + 10
+}
+
+// 隐藏提示框
+const hideTooltip = () => {
+  tooltipVisible.value = false
+}
+</script>
+
+<style scoped>
+#HeatMap {
+  display: flex;
+  justify-content: center; /* 水平居中 */
+  align-items: center;      /* 垂直居中 */
+  height: 90vh;            /* 占满整个视口高度 */
+  width: 100%;              /* 占满整个宽度 */
+}
+
+.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>