소스 검색

feat: 轨迹图查看

zhangjie 2 달 전
부모
커밋
706c7823bf

+ 76 - 0
src/assets/styles/pages.scss

@@ -1848,3 +1848,79 @@
     flex-direction: column;
   }
 }
+
+// modal-task-detail
+.modal-task-detail {
+  &.is-fullscreen.el-dialog {
+    .el-dialog__header {
+      border-bottom: 1px solid #eee;
+    }
+    .el-dialog__body {
+      padding-top: 70px;
+      height: calc(100vh - 54px); // 54px 是头部高度
+      overflow: hidden;
+    }
+  }
+  .task-detail-content {
+    display: flex;
+    height: 100%;
+    width: 100%;
+    .left-panel,
+    .right-panel {
+      width: 50%;
+      height: 100%;
+      overflow-y: auto;
+      padding: 20px;
+      box-sizing: border-box;
+    }
+    .left-panel {
+      border-right: 1px solid #eee;
+      background-color: #f5f7fa; // 背景色以便区分
+      .image-container {
+        position: relative; // 为轨迹点定位提供基准
+        margin-bottom: 20px;
+        width: 100%; // 图片宽度自适应容器
+        img {
+          display: block;
+          max-width: 100%;
+          height: auto;
+          border: 1px solid #ccc;
+        }
+        .track-point {
+          position: absolute;
+          color: red;
+          background-color: rgba(255, 255, 255, 0.7);
+          padding: 1px 3px;
+          border-radius: 3px;
+          white-space: nowrap;
+          &.track-header {
+            color: blue;
+          }
+        }
+      }
+    }
+    .right-panel {
+      .section-box {
+        margin-bottom: 20px;
+        padding-bottom: 20px;
+        border-bottom: 1px solid #eee;
+        &:last-child {
+          border-bottom: none;
+          margin-bottom: 0;
+        }
+        h3 {
+          margin-top: 0;
+          margin-bottom: 10px;
+          font-size: 16px;
+        }
+        .error-msg {
+          color: #f56c6c;
+        }
+        ul {
+          padding-left: 20px;
+          margin: 0;
+        }
+      }
+    }
+  }
+}

+ 4 - 0
src/modules/mark/api.js

@@ -168,6 +168,10 @@ export const markMarkerSetTaskCount = (datas) => {
 export const markDetailTaskListPage = (datas) => {
   return $postParam("/api/admin/mark/task/list", datas);
 };
+// 获取题目详情数据
+export function markDetailTaskDetail(id) {
+  return $postParam("/api/admin/mark/task/track", { id });
+}
 // mark-detail-reject
 export const markRejectHistoryListPage = (datas) => {
   return $postParam("/api/admin/mark/reject/list", datas);

+ 21 - 1
src/modules/mark/components/markDetail/MarkDetailTask.vue

@@ -159,10 +159,17 @@
         <el-table-column
           class-name="action-column"
           label="操作"
-          width="80"
+          width="140"
           fixed="right"
         >
           <template slot-scope="scope">
+            <el-button
+              v-if="checkPrivilege('link', 'MarkTaskTrack')"
+              class="btn-primary"
+              type="text"
+              @click="toViewDetail(scope.row)"
+              >查看</el-button
+            >
             <el-button
               v-if="checkPrivilege('link', 'MarkTaskReject')"
               class="btn-primary"
@@ -188,6 +195,10 @@
         </el-pagination>
       </div>
     </div>
+    <modal-task-detail
+      ref="ModalTaskDetail"
+      :task-id="currentTaskId"
+    ></modal-task-detail>
   </div>
 </template>
 
@@ -195,9 +206,11 @@
 import { markDetailTaskListPage, markGroupQuestions } from "../../api";
 import { MARK_TASK_STATUS } from "@/constants/enumerate";
 import markMinxin from "../../markMinxin";
+import ModalTaskDetail from "./ModalTaskDetail.vue"; // 导入新组件
 
 export default {
   name: "mark-detail-task",
+  components: { ModalTaskDetail }, // 注册新组件
   mixins: [markMinxin],
   props: {
     baseInfo: {
@@ -223,6 +236,7 @@ export default {
       dataList: [],
       questions: [],
       MARK_TASK_STATUS,
+      currentTaskId: "", // 用于存储当前要查看详情的任务ID
     };
   },
   mounted() {
@@ -256,6 +270,12 @@ export default {
     search() {
       this.toPage(1);
     },
+    toViewDetail(row) {
+      this.currentTaskId = row.id;
+      this.$nextTick(() => {
+        this.$refs.ModalTaskDetail.open();
+      });
+    },
     toReject(row) {
       this.toMarkReject({
         taskId: row.id,

+ 317 - 0
src/modules/mark/components/markDetail/ModalTaskDetail.vue

@@ -0,0 +1,317 @@
+<template>
+  <el-dialog
+    custom-class="modal-task-detail"
+    title="题目详情"
+    :visible.sync="modalVisible"
+    fullscreen
+    append-to-body
+    :close-on-click-modal="false"
+    @close="handleClose"
+  >
+    <div v-loading="loading" class="task-detail-content">
+      <div class="left-panel">
+        <!-- 答题卡裁切图片展示区域 -->
+        <div
+          v-for="(crop, index) in croppedImages"
+          :key="index"
+          class="image-container"
+        >
+          <img
+            :ref="'imageRef' + index"
+            :src="crop.src"
+            @load="onImageLoad(index)"
+          />
+          <div
+            v-for="track in crop.tracks"
+            :key="track.id"
+            class="track-point"
+            :style="getTrackStyle(track, index)"
+            :class="track.type === 'header' ? 'track-header' : 'track-normal'"
+          >
+            {{ track.score }}
+          </div>
+        </div>
+      </div>
+      <div class="right-panel">
+        <!-- OCR 结果展示区域 -->
+        <div class="ocr-section section-box">
+          <h3>OCR 识别结果</h3>
+          <div v-if="taskDetail.ocr">
+            <div v-if="taskDetail.ocr.errorMsg" class="error-msg">
+              识别失败:{{ taskDetail.ocr.errorMsg }}
+            </div>
+            <ul
+              v-else-if="taskDetail.ocr.result && taskDetail.ocr.result.length"
+            >
+              <li v-for="(item, idx) in taskDetail.ocr.result" :key="idx">
+                {{ item }}
+              </li>
+            </ul>
+            <div v-else>无识别结果</div>
+          </div>
+          <div v-else>无识别结果</div>
+        </div>
+        <!-- AI 评卷参数展示区域 -->
+        <div class="ai-param-section section-box">
+          <h3>AI 评卷参数</h3>
+          <div v-if="taskDetail.markAiQuestionParam">
+            <el-table :data="aiAnswers" border size="mini">
+              <el-table-column
+                type="index"
+                label="序号"
+                width="60"
+                align="center"
+              ></el-table-column>
+              <template v-if="taskDetail.markAiQuestionParam.mode === 'POINT'">
+                <el-table-column prop="score" label="分值" width="100">
+                  <template slot-scope="scope">
+                    {{ scope.row.score }}
+                  </template>
+                </el-table-column>
+              </template>
+              <template v-else>
+                <el-table-column label="分值区间" width="160">
+                  <template slot-scope="scope">
+                    {{ scope.row.minScore }} - {{ scope.row.maxScore }}
+                  </template>
+                </el-table-column>
+              </template>
+              <el-table-column
+                prop="answer"
+                label="标答内容"
+                show-overflow-tooltip
+              ></el-table-column>
+            </el-table>
+          </div>
+          <div v-else>未配置 AI 评卷参数</div>
+        </div>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { markDetailTaskDetail } from "../../api";
+
+export default {
+  name: "ModalTaskDetail",
+  props: {
+    taskId: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      modalVisible: false,
+      loading: false,
+      taskDetail: {
+        // 存储接口返回的完整数据
+        sheetUrls: [],
+        picList: [],
+        trackList: [],
+        headerTrackList: [],
+        ocr: null,
+        markAiQuestionParam: null,
+      },
+      croppedImages: [], // 处理后的裁切图信息和轨迹信息
+      originalImageDimensions: {}, // 存储原始图片尺寸 { url: { width, height } }
+      imageLoadPromises: [], // 存储图片加载的 Promise
+    };
+  },
+  computed: {
+    aiAnswers() {
+      if (!this.taskDetail.markAiQuestionParam) return [];
+      return this.taskDetail.markAiQuestionParam.mode === "POINT"
+        ? this.taskDetail.markAiQuestionParam.pointList || []
+        : this.taskDetail.markAiQuestionParam.levelList || [];
+    },
+  },
+  methods: {
+    open() {
+      this.modalVisible = true;
+      this.fetchTaskDetail();
+    },
+    handleClose() {
+      this.modalVisible = false;
+      // 重置数据
+      this.taskDetail = {
+        sheetUrls: [],
+        picList: [],
+        trackList: [],
+        headerTrackList: [],
+        ocr: null,
+        markAiQuestionParam: null,
+      };
+      this.croppedImages = [];
+      this.originalImageDimensions = {};
+      this.imageLoadPromises = [];
+    },
+    async fetchTaskDetail() {
+      if (!this.taskId) return;
+      this.loading = true;
+      try {
+        const res = await markDetailTaskDetail(this.taskId);
+        this.taskDetail = res || this.taskDetail; // 使用默认值防止 res 为 null
+        await this.processImageData();
+      } catch (error) {
+        console.error("获取题目详情失败:", error);
+        this.$message.error("获取题目详情失败");
+      } finally {
+        this.loading = false;
+      }
+    },
+    // 预加载所有原图并获取尺寸
+    preloadOriginalImages() {
+      this.imageLoadPromises = this.taskDetail.sheetUrls.map((url) => {
+        return new Promise((resolve, reject) => {
+          if (this.originalImageDimensions[url]) {
+            resolve(this.originalImageDimensions[url]);
+            return;
+          }
+          const img = new Image();
+          img.onload = () => {
+            this.originalImageDimensions[url] = {
+              width: img.naturalWidth,
+              height: img.naturalHeight,
+            };
+            resolve(this.originalImageDimensions[url]);
+          };
+          img.onerror = reject;
+          img.src = url;
+        });
+      });
+      return Promise.all(this.imageLoadPromises);
+    },
+
+    async processImageData() {
+      if (!this.taskDetail.picList || !this.taskDetail.sheetUrls) return;
+
+      await this.preloadOriginalImages();
+
+      const crops = [];
+      for (const pic of this.taskDetail.picList) {
+        const originalUrl = this.taskDetail.sheetUrls[pic.i - 1];
+        if (!originalUrl || !this.originalImageDimensions[originalUrl])
+          continue;
+
+        const originalDim = this.originalImageDimensions[originalUrl];
+        const cropParams = {
+          x: pic.x * originalDim.width,
+          y: pic.y * originalDim.height,
+          w: pic.w * originalDim.width,
+          h: pic.h * originalDim.height,
+        };
+
+        // 创建 Canvas 进行裁切
+        const canvas = document.createElement("canvas");
+        canvas.width = Math.round(cropParams.w); // 使用四舍五入确保整数像素
+        canvas.height = Math.round(cropParams.h);
+        const ctx = canvas.getContext("2d");
+
+        // 加载原图用于绘制
+        const img = await new Promise((resolve, reject) => {
+          const image = new Image();
+          image.onload = () => resolve(image);
+          image.onerror = reject;
+          image.crossOrigin = "anonymous"; // 如果图片跨域需要设置
+          image.src = originalUrl;
+        });
+
+        // 在 Canvas 上绘制裁切区域
+        ctx.drawImage(
+          img,
+          Math.round(cropParams.x), // 源 x
+          Math.round(cropParams.y), // 源 y
+          Math.round(cropParams.w), // 源 width
+          Math.round(cropParams.h), // 源 height
+          0, // 目标 x
+          0, // 目标 y
+          Math.round(cropParams.w), // 目标 width
+          Math.round(cropParams.h) // 目标 height
+        );
+
+        // 获取裁切后的图像 Data URL
+        const croppedSrc = canvas.toDataURL();
+
+        crops.push({
+          src: croppedSrc, // 裁切后的图片 Data URL
+          originalUrl: originalUrl,
+          originalDimensions: originalDim,
+          cropParams: cropParams, // 裁切区域在原图的像素坐标和尺寸
+          picData: pic, // 保留原始 picList 数据
+          tracks: [], // 该裁切图上的轨迹点
+          displayDimensions: { width: 0, height: 0 }, // 图片在页面上显示的实际尺寸
+        });
+      }
+
+      // 分配轨迹点到对应的裁切图
+      const allTracks = [
+        ...(this.taskDetail.trackList || []).map((t) => ({
+          ...t,
+          type: "normal",
+        })),
+        ...(this.taskDetail.headerTrackList || []).map((t) => ({
+          ...t,
+          type: "header",
+        })),
+      ];
+
+      for (const track of allTracks) {
+        const targetCropIndex = crops.findIndex(
+          (crop) => crop.picData.i === track.offsetIndex
+        );
+        if (targetCropIndex !== -1) {
+          crops[targetCropIndex].tracks.push(track);
+        }
+      }
+
+      this.croppedImages = crops;
+    },
+
+    onImageLoad(index) {
+      // 图片加载完成后获取其在页面上的实际显示尺寸
+      this.$nextTick(() => {
+        const imgRef = this.$refs["imageRef" + index];
+        if (imgRef && imgRef[0]) {
+          this.croppedImages[index].displayDimensions = {
+            width: imgRef[0].offsetWidth,
+            height: imgRef[0].offsetHeight,
+          };
+          // 强制更新,以便重新计算轨迹位置
+          this.$forceUpdate();
+        }
+      });
+    },
+
+    getTrackStyle(track, cropIndex) {
+      const crop = this.croppedImages[cropIndex];
+      if (!crop || !crop.displayDimensions.width || !crop.cropParams.w) {
+        return { display: "none" }; // 图片未加载或尺寸无效
+      }
+
+      // 原图裁切区域左上角坐标
+      const cropX = crop.cropParams.x;
+      const cropY = crop.cropParams.y;
+
+      // 轨迹点相对于原图裁切区域左上角的坐标
+      const relativeX = track.offsetX - cropX;
+      const relativeY = track.offsetY - cropY;
+
+      // 计算在显示图片上的最终坐标
+      const displayX = (relativeX / crop.cropParams.w) * 100;
+      const displayY = (relativeY / crop.cropParams.h) * 100;
+
+      return {
+        position: "absolute",
+        left: `${displayX}%`,
+        top: `${displayY}%`,
+        transform: "translate(-50%, -50%)", // 使坐标点位于数字中心
+        fontSize: "14px", // 固定字体大小
+        fontWeight: "bold",
+        zIndex: 10,
+      };
+    },
+  },
+};
+</script>