|
@@ -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>
|