MarkBody.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. <template>
  2. <div class="mark-body" @scroll="viewScroll">
  3. <div ref="dragContainer" class="mark-body-container">
  4. <div v-if="!store.currentTask" class="mark-body-none">
  5. <div>
  6. <img src="@/assets/image-none-task.png" />
  7. <p>
  8. {{ store.message }}
  9. </p>
  10. </div>
  11. </div>
  12. <div v-else-if="store.isScanImage" :style="{ width: answerPaperScale }">
  13. <div
  14. v-for="(item, index) in sliceImagesWithTrackList"
  15. :key="index"
  16. class="single-image-container"
  17. >
  18. <img :src="item.url" draggable="false" />
  19. <MarkDrawTrack
  20. :trackList="item.trackList"
  21. :specialTagList="item.tagList"
  22. :sliceImageHeight="item.originalImageHeight"
  23. :sliceImageWidth="item.originalImageWidth"
  24. :dx="0"
  25. :dy="0"
  26. />
  27. <!-- 客观题答案标记 -->
  28. <template v-if="item.answerTags">
  29. <div
  30. v-for="(tag, tindex) in item.answerTags"
  31. :key="`tag-${tindex}`"
  32. :style="tag.style"
  33. >
  34. {{ tag.answer }}
  35. </div>
  36. </template>
  37. <!-- 试题评分明细 -->
  38. <template v-if="item.markDetail">
  39. <div
  40. v-for="(minfo, mindex) in item.markDetail"
  41. :key="`mark-${mindex}`"
  42. :style="minfo.style"
  43. class="mark-info"
  44. >
  45. <div>
  46. <p v-for="user in minfo.users" :key="user.userId">
  47. 评卷员:{{ user.userName }},评分:{{ user.score }}
  48. </p>
  49. </div>
  50. <h3>得分:{{ minfo.score }},满分:{{ minfo.maxScore }}</h3>
  51. </div>
  52. </template>
  53. <hr class="image-seperator" />
  54. </div>
  55. </div>
  56. <div v-else>未知数据</div>
  57. </div>
  58. </div>
  59. </template>
  60. <script setup lang="ts">
  61. import { reactive, watch } from "vue";
  62. import { store } from "@/store/store";
  63. import MarkDrawTrack from "@/features/mark/MarkDrawTrack.vue";
  64. import type { SpecialTag, Track, ColorMap, PaperRecogData } from "@/types";
  65. import { useTimers } from "@/setups/useTimers";
  66. import { loadImage, addHeaderTrackColorAttr, calcSum } from "@/utils/utils";
  67. import { dragImage } from "@/features/mark/use/draggable";
  68. import { maxNum } from "@/utils/utils";
  69. interface SliceImage {
  70. url: string;
  71. trackList: Array<Track>;
  72. tagList: Array<SpecialTag>;
  73. originalImageWidth: number;
  74. originalImageHeight: number;
  75. width: string; // 图片在整个图片列表里面的宽度比例
  76. answerTags?: AnswerTagItem[];
  77. markDetail?: MarkDetailItem[];
  78. }
  79. const { origImageUrls = "sliceUrls" } = defineProps<{
  80. origImageUrls?: "sheetUrls" | "sliceUrls";
  81. }>();
  82. const emit = defineEmits(["error", "getIsMultComments", "getScrollStatus"]);
  83. const { dragContainer } = dragImage();
  84. const viewScroll = () => {
  85. if (
  86. dragContainer.value.scrollTop + dragContainer.value.offsetHeight + 50 >=
  87. dragContainer.value.scrollHeight
  88. ) {
  89. emit("getScrollStatus");
  90. }
  91. };
  92. const { addTimeout } = useTimers();
  93. let sliceImagesWithTrackList: SliceImage[] = reactive([]);
  94. let maxImageWidth = 0;
  95. function addTrackColorAttr(tList: Track[]): Track[] {
  96. let markerIds: (number | undefined)[] = tList
  97. .map((v) => v.userId)
  98. .filter((x) => !!x);
  99. markerIds = Array.from(new Set(markerIds));
  100. // markerIds.sort();
  101. let colorMap: ColorMap = {};
  102. for (let i = 0; i < markerIds.length; i++) {
  103. const mId: any = markerIds[i];
  104. if (i == 0) {
  105. colorMap[mId + ""] = "red";
  106. } else if (i == 1) {
  107. colorMap[mId + ""] = "blue";
  108. } else if (i > 1) {
  109. colorMap[mId + ""] = "gray";
  110. }
  111. }
  112. if (Object.keys(colorMap).length > 1) {
  113. emit("getIsMultComments", true);
  114. }
  115. tList = tList.map((item: Track) => {
  116. item.color = colorMap[item.userId + ""] || "red";
  117. item.isByMultMark = markerIds.length > 1;
  118. return item;
  119. });
  120. return tList;
  121. }
  122. function addTagColorAttr(tList: SpecialTag[]): SpecialTag[] {
  123. let markerIds: (number | undefined)[] = tList
  124. .map((v) => v.userId)
  125. .filter((x) => !!x);
  126. markerIds = Array.from(new Set(markerIds));
  127. // markerIds.sort();
  128. let colorMap: ColorMap = {};
  129. for (let i = 0; i < markerIds.length; i++) {
  130. const mId: any = markerIds[i];
  131. if (i == 0) {
  132. colorMap[mId + ""] = "red";
  133. } else if (i == 1) {
  134. colorMap[mId + ""] = "blue";
  135. } else if (i > 1) {
  136. colorMap[mId + ""] = "gray";
  137. }
  138. }
  139. tList = tList.map((item: SpecialTag) => {
  140. item.color = colorMap[item.userId + ""] || "red";
  141. item.isByMultMark = markerIds.length > 1;
  142. return item;
  143. });
  144. return tList;
  145. }
  146. async function processImage() {
  147. if (!store.currentTask) return;
  148. const images = [];
  149. const urls = store.currentTask[origImageUrls] || [];
  150. for (const url of urls) {
  151. const image = await loadImage(url);
  152. images.push(image);
  153. }
  154. maxImageWidth = Math.max(...images.map((i) => i.naturalWidth));
  155. // 解析各试题答题区域以及评分
  156. const markDetailList = parseMarkDetailList();
  157. const trackLists = (store.currentTask.questionList || [])
  158. // .map((q) => q.trackList)
  159. .map((q) => {
  160. let tList = q.trackList;
  161. return q.headerTrack?.length
  162. ? addHeaderTrackColorAttr(q.headerTrack)
  163. : addTrackColorAttr(tList);
  164. })
  165. .flat();
  166. store.setting.doubleTrack = trackLists.some((item) => item.isByMultMark);
  167. for (const url of urls) {
  168. const indexInSliceUrls = urls.indexOf(url) + 1;
  169. const image = images[indexInSliceUrls - 1];
  170. const thisImageTrackList = trackLists.filter(
  171. (t) => t.offsetIndex === indexInSliceUrls
  172. );
  173. const thisImageTagList = store.currentTask.headerTagList?.length
  174. ? addHeaderTrackColorAttr(
  175. (store.currentTask.headerTagList || []).filter(
  176. (t) => t.offsetIndex === indexInSliceUrls
  177. )
  178. )
  179. : addTagColorAttr(
  180. (store.currentTask.specialTagList || []).filter(
  181. (t) => t.offsetIndex === indexInSliceUrls
  182. )
  183. );
  184. const answerTags = paserRecogData(image, indexInSliceUrls - 1);
  185. sliceImagesWithTrackList.push({
  186. url,
  187. trackList: thisImageTrackList,
  188. tagList: thisImageTagList,
  189. originalImageWidth: image.naturalWidth,
  190. originalImageHeight: image.naturalHeight,
  191. width: (image.naturalWidth / maxImageWidth) * 100 + "%",
  192. answerTags,
  193. markDetail: markDetailList[indexInSliceUrls - 1],
  194. });
  195. }
  196. }
  197. // 解析客观题答案展示位置
  198. interface AnswerTagItem {
  199. mainNumber: number;
  200. subNumber: string;
  201. answer: string;
  202. style: Record<string, string>;
  203. }
  204. function paserRecogData(imgDom: HTMLImageElement, imageIndex): AnswerTagItem[] {
  205. if (
  206. !store.currentTask.recogDatas?.length ||
  207. !store.currentTask.recogDatas[imageIndex]
  208. )
  209. return [];
  210. const answerMap = store.currentTask.answerMap || {};
  211. const { naturalWidth, naturalHeight } = imgDom;
  212. const recogData: PaperRecogData = JSON.parse(
  213. window.atob(store.currentTask.recogDatas[imageIndex])
  214. );
  215. const answerTags: AnswerTagItem[] = [];
  216. // const optionsBlocks = [];
  217. recogData.question.forEach((question) => {
  218. question.fill_result.forEach((result) => {
  219. const tagSize = result.fill_size[1];
  220. const fillPositions = result.fill_position.map((pos) => {
  221. return pos.split(",").map((n) => n * 1);
  222. });
  223. const offsetLt = result.fill_size.map((item) => item * 0.4);
  224. const tagLeft =
  225. maxNum(fillPositions.map((pos) => pos[0])) +
  226. result.fill_size[0] -
  227. offsetLt[0];
  228. const tagTop = fillPositions[0][1] - offsetLt[1];
  229. answerTags.push({
  230. mainNumber: result.main_number,
  231. subNumber: result.sub_number,
  232. answer: answerMap[`${result.main_number}_${result.sub_number}`],
  233. style: {
  234. height: ((100 * tagSize) / naturalHeight).toFixed(4) + "%",
  235. fontSize: ((100 * 20) / tagSize).toFixed(4) + "%",
  236. left: ((100 * tagLeft) / naturalWidth).toFixed(4) + "%",
  237. top: ((100 * tagTop) / naturalHeight).toFixed(4) + "%",
  238. position: "absolute",
  239. color: "#f53f3f",
  240. lineHeight: 1,
  241. zIndex: 9,
  242. },
  243. });
  244. // 测试:选项框
  245. // fillPositions.forEach((fp, index) => {
  246. // optionsBlocks.push({
  247. // mainNumber: result.main_number,
  248. // subNumber: result.sub_number,
  249. // filled: !!result.fill_option[index],
  250. // style: {
  251. // width:
  252. // ((100 * result.fill_size[0]) / naturalWidth).toFixed(4) + "%",
  253. // height:
  254. // ((100 * result.fill_size[1]) / naturalHeight).toFixed(4) + "%",
  255. // left:
  256. // ((100 * (fp[0] - offsetLt[0])) / naturalWidth).toFixed(4) + "%",
  257. // top:
  258. // ((100 * (fp[1] - offsetLt[1])) / naturalHeight).toFixed(4) + "%",
  259. // position: "absolute",
  260. // border: "1px solid #f53f3f",
  261. // background: result.fill_option[index]
  262. // ? "rgba(245, 63, 63, 0.5)"
  263. // : "transparent",
  264. // zIndex: 9,
  265. // },
  266. // });
  267. // });
  268. });
  269. });
  270. return answerTags;
  271. }
  272. interface QuestionItem {
  273. mainNumber: number;
  274. subNumber: number | string;
  275. }
  276. interface QuestionArea {
  277. i: number;
  278. x: number;
  279. y: number;
  280. w: number;
  281. h: number;
  282. qStruct: string;
  283. }
  284. function parseQuestionAreas(questions: QuestionItem[]) {
  285. if (!questions.length || !store.currentTask.cardData?.length) return [];
  286. let pictureConfigs: QuestionArea[] = [];
  287. const structs = questions.map(
  288. (item) => `${item.mainNumber}_${item.subNumber}`
  289. );
  290. store.currentTask.cardData.forEach((page, pindex) => {
  291. page.exchange.answer_area.forEach((area) => {
  292. const [x, y, w, h] = area.area;
  293. const qStruct = `${area.main_number}_${area.sub_number}`;
  294. const pConfig: QuestionArea = {
  295. i: pindex + 1,
  296. x,
  297. y,
  298. w,
  299. h,
  300. qStruct,
  301. };
  302. if (typeof area.sub_number === "number") {
  303. if (!structs.includes(qStruct)) return;
  304. pictureConfigs.push(pConfig);
  305. return;
  306. }
  307. // 复合区域处理,比如填空题,多个小题合并为一个区域
  308. if (typeof area.sub_number === "string") {
  309. const areaStructs = area.sub_number
  310. .split(",")
  311. .map((subNumber) => `${area.main_number}_${subNumber}`);
  312. if (
  313. structs.some((struct) => areaStructs.includes(struct)) &&
  314. !pictureConfigs.find((item) => item.qStruct === qStruct)
  315. ) {
  316. pictureConfigs.push(pConfig);
  317. }
  318. }
  319. });
  320. });
  321. // console.log(pictureConfigs);
  322. // 合并相邻区域
  323. pictureConfigs.sort((a, b) => {
  324. return a.i - b.i || a.x - b.x || a.y - b.y;
  325. });
  326. let combinePictureConfigList: QuestionArea[] = [];
  327. let prevConfig = null;
  328. pictureConfigs.forEach((item, index) => {
  329. if (!index) {
  330. prevConfig = { ...item };
  331. combinePictureConfigList.push(prevConfig);
  332. return;
  333. }
  334. const elasticRate = 0.01;
  335. if (
  336. prevConfig.i === item.i &&
  337. prevConfig.y + prevConfig.h + elasticRate >= item.y &&
  338. prevConfig.w === item.w &&
  339. prevConfig.x === item.x
  340. ) {
  341. prevConfig.h = item.y + item.h - prevConfig.y;
  342. } else {
  343. prevConfig = { ...item };
  344. combinePictureConfigList.push(prevConfig);
  345. }
  346. });
  347. // console.log(combinePictureConfigList);
  348. return combinePictureConfigList;
  349. }
  350. // 解析各试题答题区域以及评分
  351. interface MarkDetailItem {
  352. mainNumber: number;
  353. subNumber: string;
  354. score: number;
  355. maxScore: number;
  356. users: Array<{
  357. userId: string;
  358. userName: string;
  359. scores: number[];
  360. score: number;
  361. }>;
  362. area: QuestionArea;
  363. style: Record<string, string>;
  364. }
  365. function parseMarkDetailList(): Array<MarkDetailItem[]> {
  366. const dataList: Array<MarkDetailItem[]> = [];
  367. (store.currentTask.questionList || []).forEach((question) => {
  368. const areas = parseQuestionAreas([question]);
  369. if (!areas.length) return;
  370. const area = areas[0];
  371. if (!dataList[area.i - 1]) {
  372. dataList[area.i - 1] = [];
  373. }
  374. const userMap = {};
  375. question.trackList.forEach((track) => {
  376. if (!userMap[track.userId]) {
  377. userMap[track.userId] = {
  378. userId: track.userId,
  379. userName: track.userName,
  380. scores: [],
  381. };
  382. }
  383. userMap[track.userId].scores.push(track.score);
  384. });
  385. const users = Object.values(userMap).map((user) => {
  386. return { ...user, score: calcSum(user.scores) };
  387. });
  388. dataList[area.i - 1].push({
  389. mainNumber: question.mainNumber,
  390. subNumber: question.subNumber,
  391. score: question.score,
  392. maxScore: question.maxScore,
  393. users,
  394. area,
  395. style: {
  396. position: "absolute",
  397. left: (100 * area.x).toFixed(4) + "%",
  398. top: (100 * area.y).toFixed(4) + "%",
  399. width: (100 * area.w).toFixed(4) + "%",
  400. color: "#f53f3f",
  401. fontSize: "14px",
  402. lineHeight: 1,
  403. zIndex: 9,
  404. },
  405. });
  406. });
  407. return dataList;
  408. }
  409. // should not render twice at the same time
  410. let renderLock = false;
  411. const renderPaperAndMark = async () => {
  412. if (renderLock) {
  413. console.log("上个任务还未渲染完毕,稍等一秒再尝试渲染");
  414. await new Promise((res) => setTimeout(res, 1000));
  415. await renderPaperAndMark();
  416. return;
  417. }
  418. renderLock = true;
  419. sliceImagesWithTrackList.splice(0);
  420. if (!store.currentTask) {
  421. renderLock = false;
  422. return;
  423. }
  424. try {
  425. store.globalMask = true;
  426. await processImage();
  427. } catch (error) {
  428. sliceImagesWithTrackList.splice(0);
  429. console.log("render error ", error);
  430. // 图片加载出错,自动加载下一个任务
  431. emit("error");
  432. } finally {
  433. await new Promise((res) => setTimeout(res, 500));
  434. store.globalMask = false;
  435. renderLock = false;
  436. }
  437. };
  438. watch(() => store.currentTask, renderPaperAndMark);
  439. watch(
  440. (): (number | undefined)[] => [
  441. store.minimapScrollToX,
  442. store.minimapScrollToY,
  443. ],
  444. () => {
  445. const container = document.querySelector<HTMLDivElement>(
  446. ".mark-body-container"
  447. );
  448. addTimeout(() => {
  449. if (
  450. container &&
  451. typeof store.minimapScrollToX === "number" &&
  452. typeof store.minimapScrollToY === "number"
  453. ) {
  454. const { scrollWidth, scrollHeight } = container;
  455. container.scrollTo({
  456. top: scrollHeight * store.minimapScrollToY,
  457. left: scrollWidth * store.minimapScrollToX,
  458. behavior: "smooth",
  459. });
  460. }
  461. }, 10);
  462. }
  463. );
  464. const answerPaperScale = $computed(() => {
  465. // 放大、缩小不影响页面之前的滚动条定位
  466. let percentWidth = 0;
  467. let percentTop = 0;
  468. const container = document.querySelector(".mark-body-container");
  469. if (container) {
  470. const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
  471. percentWidth = scrollLeft / scrollWidth;
  472. percentTop = scrollTop / scrollHeight;
  473. }
  474. addTimeout(() => {
  475. if (container) {
  476. const { scrollWidth, scrollHeight } = container;
  477. container.scrollTo({
  478. left: scrollWidth * percentWidth,
  479. top: scrollHeight * percentTop,
  480. });
  481. }
  482. }, 10);
  483. const scale = store.setting.uiSetting["answer.paper.scale"];
  484. return scale * 100 + "%";
  485. });
  486. </script>
  487. <style scoped>
  488. .mark-body-container {
  489. overflow: auto;
  490. background-color: var(--app-container-bg-color);
  491. background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
  492. linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
  493. linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
  494. linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
  495. background-size: 20px 20px;
  496. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  497. transform: inherit;
  498. cursor: grab;
  499. user-select: none;
  500. }
  501. .mark-body-container img {
  502. width: 100%;
  503. }
  504. .single-image-container {
  505. position: relative;
  506. }
  507. .image-seperator {
  508. border: 2px solid rgba(120, 120, 120, 0.1);
  509. }
  510. .mark-info {
  511. display: flex;
  512. justify-content: space-between;
  513. }
  514. .mark-info h3 {
  515. font-size: 20px;
  516. font-weight: bold;
  517. line-height: 1;
  518. color: #f53f3f;
  519. }
  520. .mark-info p {
  521. margin: 0;
  522. line-height: 20px;
  523. font-weight: bold;
  524. }
  525. </style>