<template> <div ref="elRef" class="scan-image"> <div class="img-body" :style="imageStyle" v-ele-move-directive.prevent.stop="{ moveElement: onMoveImg, emitOriginLeftTop: true, }" > <img ref="imgRef" v-if="curPage" :src="getFileUrl(curPage.sheetUri)" alt="原图" @load="initImageSize" /> <div class="img-recogs"> <div v-for="(item, index) in recogBlocks" :key="index" class="recog-block" :style="item.fillAreaStyle" @click="onAreaClick(item)" > <div v-for="(option, oindex) in item.fillOptionStyles" :key="oindex" :style="option" class="recog-item" ></div> </div> </div> </div> <div class="img-guide"> <div class="img-guide-icon is-left" @click="onPrev"><LeftOutlined /></div> <div class="img-guide-icon is-right" @click="onNext"> <RightOutlined /> </div> </div> <div class="img-actions"> <ul> <li @click="onZoomIn"><ZoomInOutlined /></li> <li @click="onZoomOut"><ZoomOutOutlined /></li> <li @click="onZoomNormal">1:1</li> <li @click="onSetRecogStyle"><BgColorsOutlined /></li> </ul> </div> <import-btn upload-url="/api/admin/scan/answer/sheet/update" :format="['jpg', 'png', 'jpeg']" :upload-data="updateSheetData" @upload-success="updateSheetSuccess" > <a-tooltip placement="top"> <template #title> <span>替换图片</span> </template> <a-button class="img-change"> <template #icon><PictureFilled /></template> </a-button> </a-tooltip> </import-btn> </div> <!-- FillAreaSetDialog --> <FillAreaSetDialog ref="fillAreaSetDialogRef" @modified="parseRecogBlocks" /> <!-- RecogEditDialog --> <RecogEditDialog v-if="curRecogBlock" ref="recogEditDialogRef" :recog-data="curRecogBlock" @confirm="onRecogEditConfirm" /> </template> <script setup lang="ts"> import { ZoomInOutlined, ZoomOutOutlined, BgColorsOutlined, LeftOutlined, RightOutlined, PictureFilled, } from "@ant-design/icons-vue"; import { message } from "ant-design-vue"; import { saveTemporaryImgViewConfig, getTemporaryImgViewConfig, } from "@/utils/index"; import { computed, nextTick, ref, unref, watch } from "vue"; import { useRoute } from "vue-router"; import { objAssign, getFileUrl, getSliceFileUrl, getBoxImageSize, } from "@/utils/tool"; import { vEleMoveDirective } from "@/directives/eleMove"; import { parseRecogData, parseDetailSize, RecognizeArea, RecogBlock, } from "@/utils/recog/recog"; import { useUserStore, useDataCheckStore } from "@/store"; import { abc } from "@/constants/enumerate"; import FillAreaSetDialog from "./FillAreaSetDialog.vue"; import RecogEditDialog from "./RecogEditDialog.vue"; import ImportBtn from "@/components/ImportBtn/index.vue"; import { debounce } from "lodash-es"; defineOptions({ name: "ScanImage", }); const route = useRoute(); const emit = defineEmits(["next", "prev"]); const userStore = useUserStore(); const dataCheckStore = useDataCheckStore(); const curPage = computed(() => dataCheckStore.curPage); const updateSheetData = computed(() => { if (!curPage.value) return {}; return { paperId: curPage.value.paperId, pageIndex: curPage.value.pageIndex + 1, }; }); const elRef = ref(); const imgRef = ref(); const imageSize = ref({ width: 0, height: 0, top: 0, left: 0, scale: 1, }); const saveImageSizeToSession = debounce(() => { saveTemporaryImgViewConfig(route.path, imageSize.value); }, 500); watch( imageSize, () => { saveImageSizeToSession(); }, { deep: true } ); const imageStyle = computed(() => { return { width: `${imageSize.value.width}px`, height: `${imageSize.value.height}px`, top: `${imageSize.value.top}px`, left: `${imageSize.value.left}px`, transform: `scale(${imageSize.value.scale})`, }; }); function initImageSize() { const imgDom = imgRef.value as HTMLImageElement; const elDom = elRef.value as HTMLDivElement; const imgSize = getBoxImageSize({ box: { width: elDom.clientWidth, height: elDom.clientHeight, }, img: { width: imgDom.naturalWidth, height: imgDom.naturalHeight, }, rotate: 0, }); imageSize.value = getTemporaryImgViewConfig(route.path) || objAssign(imageSize.value, imgSize); nextTick(() => { updateRecogList(); }); } function getNumberResult( result: Array<string | boolean>, sources: Array<string | boolean> ) { const nResult: number[] = []; result.forEach((item) => { const index = sources.indexOf(item); nResult[index] = 1; }); return Array.from(nResult).map((item) => item || 0); } // recog data const recogList = ref<RecognizeArea[]>([]); function updateRecogList() { recogList.value = [] as RecognizeArea[]; if (!dataCheckStore.curPage) return; const regdata = parseRecogData(dataCheckStore.curPage.recogData); if (!regdata) return; let index = 0; const ABC = abc.split(""); regdata.question.forEach((gGroup) => { gGroup.fill_result.forEach((qRecog) => { const result = dataCheckStore.curPage?.question?.result[index] || ""; qRecog.index = ++index; const questionResult = result ? result.split("") : []; const recogItem = parseDetailSize( qRecog, "question", qRecog.index, getNumberResult(questionResult, ABC) ); recogList.value.push(recogItem); }); }); parseRecogBlocks(); } // recogBlocks const recogBlocks = ref<RecogBlock[]>([]); const curRecogBlock = ref<RecogBlock | null>(null); function parseRecogBlocks() { const imgDom = imgRef.value as HTMLImageElement; const rate = imgDom.clientWidth / imgDom.naturalWidth; const { unfillColor, unfillShow, fillColor, fillShow, borderWidth } = userStore.recogFillSet; const curBorderWidth = Math.max(1, borderWidth * rate); recogBlocks.value = unref(recogList.value).map((item) => { const fillAreaStyle = { position: "absolute", left: `${item.fillArea.x * rate}px`, top: `${item.fillArea.y * rate}px`, width: `${item.fillArea.w * rate}px`, height: `${item.fillArea.h * rate}px`, zIndex: 9, }; const fillOptionStyles = item.optionSizes .map((op) => { const opStyle = { position: "absolute", left: `${op.x * rate}px`, top: `${op.y * rate}px`, width: `${op.w * rate}px`, height: `${op.h * rate}px`, zIndex: 9, border: "", }; if (op.filled && fillShow) { opStyle.border = `${curBorderWidth}px solid ${fillColor}`; return opStyle; } if (!op.filled && unfillShow) { opStyle.border = `${curBorderWidth}px solid ${unfillColor}`; return opStyle; } return opStyle; }) .filter((item) => item); const nitem: RecogBlock = { ...item, fillAreaStyle, fillOptionStyles, areaImg: "", }; return nitem; }); } // area click const recogEditDialogRef = ref(); async function onAreaClick(data: RecogBlock) { if (!curPage.value) return; curRecogBlock.value = data; // 基于 fillArea 四周扩展一个 fillSize 尺寸 const area = { x: data.fillArea.x - data.fillSize.w, y: data.fillArea.y - data.fillSize.h, w: data.fillArea.w + data.fillSize.w * 2, h: data.fillArea.h + data.fillSize.h * 2, }; curRecogBlock.value.areaImg = await getSliceFileUrl( curPage.value.sheetUri, area ); nextTick(() => { recogEditDialogRef.value?.open(); }); } async function onRecogEditConfirm(result: string[]) { if (!curRecogBlock.value || !dataCheckStore.curPage) return; const data = curRecogBlock.value; if (data.type === "question") { const index = data.index - 1; dataCheckStore.curPage.question.result.splice(index, 1, result.join("")); await dataCheckStore.updateField({ field: "QUESTION", value: JSON.stringify(dataCheckStore.curPage.question), }); curRecogBlock.value.result = result; } } // img action function onZoomIn() { const scale = imageSize.value.scale; if (scale >= 2) return; imageSize.value.scale = Math.min(2, scale * 1.2); } function onZoomOut() { const scale = imageSize.value.scale; if (scale <= 1) return; imageSize.value.scale = Math.max(1, scale * 0.8); } function onZoomNormal() { initImageSize(); imageSize.value.scale = 1; } interface PosSize { left: number; top: number; } function onMoveImg({ left, top }: PosSize) { imageSize.value.left = left; imageSize.value.top = top; } function onPrev() { emit("prev"); } function onNext() { emit("next"); } // change image function updateSheetSuccess(data: { uri: string }) { if (!curPage.value) return; dataCheckStore.modifySheetUri({ paperIndex: curPage.value.paperIndex, pageIndex: curPage.value.pageIndex, uri: data.uri, }); message.success("上传成功!"); } // set recog style const fillAreaSetDialogRef = ref(); function onSetRecogStyle() { fillAreaSetDialogRef.value?.open(); } // 监听question.result,同步修改客观题的识别结果 watch( () => dataCheckStore.curPage?.question?.result, (val) => { if (!val) return; updateRecogList(); }, { deep: true, } ); </script> <style lang="less" scoped> .scan-image { overflow: hidden; position: relative; height: 100%; .img-guide { &-icon { position: absolute; top: 50%; width: 28px; height: 32px; margin-top: -16px; background: #ffffff; border-radius: 6px; border: 1px solid @border-color1; line-height: 32px; text-align: center; z-index: 9; cursor: pointer; &:hover { background-color: #e8f3ff; border-color: @brand-color; color: @brand-color; } &.is-left { left: 12px; } &.is-right { right: 12px; } } } .img-change { position: absolute; top: 12px; right: 12px; width: 32px; height: 32px; line-height: 32px; background: #e8f3ff; padding: 0; border-radius: 6px; border: 1px solid #bedaff; color: #4080ff; text-align: center; z-index: 9; &:hover { opacity: 0.8; } } .img-actions { position: absolute; bottom: 12px; right: 12px; background: rgba(89, 89, 89, 0.6); border-radius: 8px; padding: 4px 8px; z-index: 9; li { display: inline-block; vertical-align: middle; width: 26px; height: 26px; border-radius: 6px; line-height: 26px; text-align: center; color: #fff; font-size: 16px; cursor: pointer; &:not(:last-child) { margin-right: 4px; } &:hover { background: rgba(89, 89, 89, 0.6); } } } .img-body { position: absolute; z-index: 2; } } </style>