MarkBody.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. <template>
  2. <div class="mark-body-container tw-flex-auto" ref="dragContainer">
  3. <div v-if="!store.currentTask" class="tw-text-center">暂无评卷任务</div>
  4. <div v-else :style="{ width: answerPaperScale }">
  5. <div
  6. v-for="(item, index) in sliceImagesWithTrackList"
  7. :key="index"
  8. class="single-image-container"
  9. >
  10. <img
  11. :src="item.url"
  12. @click="(event) => makeScoreTrack(event, item)"
  13. draggable="false"
  14. />
  15. <MarkDrawTrack
  16. :track-list="item.trackList"
  17. :original-image="item.originalImage"
  18. :slice-image="item.sliceImage"
  19. :dx="item.dx"
  20. :dy="item.dy"
  21. />
  22. <hr class="image-seperator" />
  23. </div>
  24. </div>
  25. </div>
  26. <div class="cursor">
  27. <div class="cursor-border">
  28. <span class="text">{{ store.currentScore }}</span>
  29. </div>
  30. </div>
  31. </template>
  32. <script lang="ts">
  33. import {
  34. computed,
  35. defineComponent,
  36. onMounted,
  37. onUnmounted,
  38. reactive,
  39. watch,
  40. watchEffect,
  41. } from "vue";
  42. import { findCurrentTaskMarkResult, store } from "./store";
  43. import filters from "@/filters";
  44. import MarkDrawTrack from "./MarkDrawTrack.vue";
  45. import { ModeEnum, Track } from "@/types";
  46. import { useTimers } from "@/setups/useTimers";
  47. import { loadImage } from "@/utils/utils";
  48. import { groupBy, sortBy } from "lodash";
  49. // @ts-ignore
  50. import CustomCursor from "custom-cursor.js";
  51. import { dragImage } from "./use/draggable";
  52. interface SliceImage {
  53. url: string;
  54. indexInSliceUrls: number;
  55. trackList: Array<Track>;
  56. originalImage: HTMLImageElement;
  57. sliceImage: HTMLImageElement;
  58. dx: number;
  59. dy: number;
  60. accumTopHeight: number;
  61. effectiveWidth: number;
  62. }
  63. export default defineComponent({
  64. name: "MarkBody",
  65. components: { MarkDrawTrack },
  66. setup() {
  67. const { dragContainer } = dragImage();
  68. const { addTimeout } = useTimers();
  69. function hasSliceConfig() {
  70. return store.currentTask?.sliceConfig?.length;
  71. }
  72. let sliceImagesWithTrackList: Array<SliceImage> = reactive([]);
  73. let _studentId = -1; // 判断是否改变了任务
  74. let maxSliceWidth = 0; // 最大的裁切块宽度,图片容器以此为准
  75. let theFinalHeight = 0; // 最终宽度,用来定位轨迹在第几张图片,不包括image-seperator高度
  76. async function processSliceConfig() {
  77. // check if have MarkResult for currentTask
  78. let markResult = findCurrentTaskMarkResult();
  79. if (!markResult || !store.currentTask) return;
  80. // TODO: 图片加载出错,自动加载下一个任务
  81. for (const url of store.currentTask.sliceUrls) {
  82. await loadImage(filters.toCompleteUrl(url));
  83. }
  84. // 必须要先加载一遍,把“选择整图”的宽高重置后,再算总高度
  85. for (const sliceConfig of store.currentTask.sliceConfig) {
  86. const url = filters.toCompleteUrl(
  87. store.currentTask.sliceUrls[sliceConfig.i - 1]
  88. );
  89. const image = await loadImage(url);
  90. if (sliceConfig.w === 0 && sliceConfig.h === 0) {
  91. // 选择整图时,w/h 为0
  92. sliceConfig.w = image.naturalWidth;
  93. sliceConfig.h = image.naturalHeight;
  94. }
  95. }
  96. theFinalHeight = store.currentTask.sliceConfig
  97. .map((v) => v.h)
  98. .reduce((acc, v) => (acc += v));
  99. maxSliceWidth = Math.max(
  100. ...store.currentTask.sliceConfig.map((v) => v.w)
  101. );
  102. // 用来保存sliceImage在整个图片容器中(不包括image-seperator)的高度范围
  103. let accumTopHeight = 0;
  104. let accumBottomHeight = 0;
  105. for (const sliceConfig of store.currentTask.sliceConfig) {
  106. accumBottomHeight += sliceConfig.h;
  107. const url = filters.toCompleteUrl(
  108. store.currentTask.sliceUrls[sliceConfig.i - 1]
  109. );
  110. const image = await loadImage(url);
  111. const canvas = document.createElement("canvas");
  112. // canvas.width = sliceConfig.w;
  113. canvas.width = Math.max(sliceConfig.w, maxSliceWidth);
  114. canvas.height = sliceConfig.h;
  115. const ctx = canvas.getContext("2d");
  116. if (!ctx) {
  117. console.log('canvas.getContext("2d") error');
  118. }
  119. // drawImage 画图软件透明色
  120. ctx?.drawImage(
  121. image,
  122. sliceConfig.x,
  123. sliceConfig.y,
  124. sliceConfig.w,
  125. sliceConfig.h,
  126. 0,
  127. 0,
  128. sliceConfig.w,
  129. sliceConfig.h
  130. );
  131. // console.log(image, canvas.height, sliceConfig, ctx);
  132. // console.log(canvas.toDataURL());
  133. const thisImageTrackList = markResult.trackList.filter(
  134. (v) => v.offsetIndex === sliceConfig.i
  135. );
  136. const dataUrl = canvas.toDataURL();
  137. const sliceImage = new Image();
  138. sliceImage.src = dataUrl;
  139. // sliceConfig.x + sliceConfig.w
  140. sliceImagesWithTrackList.push({
  141. url: dataUrl,
  142. indexInSliceUrls: sliceConfig.i,
  143. // 通过positionY来定位是第几张slice的还原,并过滤出相应的track
  144. trackList: thisImageTrackList.filter(
  145. (t) =>
  146. t.positionY >= accumTopHeight / theFinalHeight &&
  147. t.positionY < accumBottomHeight / theFinalHeight
  148. ),
  149. originalImage: image,
  150. sliceImage,
  151. dx: sliceConfig.x,
  152. dy: sliceConfig.y,
  153. accumTopHeight,
  154. effectiveWidth: sliceConfig.w,
  155. });
  156. accumTopHeight = accumBottomHeight;
  157. }
  158. }
  159. async function processSplitConfig() {
  160. // check if have MarkResult for currentTask
  161. let markResult = findCurrentTaskMarkResult();
  162. if (!markResult || !store.currentTask) return;
  163. const images = [];
  164. for (const url of store.currentTask.sliceUrls) {
  165. const image = await loadImage(filters.toCompleteUrl(url));
  166. images.push(image);
  167. }
  168. // TODO: add loading
  169. const splitConfigPairs = (store.setting.splitConfig
  170. .map((v, index, ary) => (index % 2 === 0 ? [v, ary[index + 1]] : false))
  171. .filter((v) => v) as unknown) as Array<[number, number]>;
  172. const maxSplitConfig = Math.max(...store.setting.splitConfig);
  173. maxSliceWidth =
  174. Math.max(...images.map((v) => v.naturalWidth)) * maxSplitConfig;
  175. theFinalHeight =
  176. splitConfigPairs.length *
  177. images.reduce((acc, v) => (acc += v.naturalHeight), 0);
  178. let accumTopHeight = 0;
  179. let accumBottomHeight = 0;
  180. for (const url of store.currentTask.sliceUrls) {
  181. const completeUrl = filters.toCompleteUrl(url);
  182. for (const config of splitConfigPairs) {
  183. const image = await loadImage(completeUrl);
  184. accumBottomHeight += image.naturalHeight;
  185. const width = image.naturalWidth * (config[1] - config[0]);
  186. const canvas = document.createElement("canvas");
  187. canvas.width = Math.max(width, maxSliceWidth);
  188. canvas.height = image.naturalHeight;
  189. const ctx = canvas.getContext("2d");
  190. if (!ctx) {
  191. console.log('canvas.getContext("2d") error');
  192. }
  193. // drawImage 画图软件透明色
  194. ctx?.drawImage(
  195. image,
  196. image.naturalWidth * config[0],
  197. 0,
  198. image.naturalWidth * config[1],
  199. image.naturalHeight,
  200. 0,
  201. 0,
  202. image.naturalWidth * config[1],
  203. image.naturalHeight
  204. );
  205. // console.log(image, canvas.height, sliceConfig, ctx);
  206. // console.log(canvas.toDataURL());
  207. const thisImageTrackList = markResult.trackList.filter(
  208. (t) =>
  209. t.offsetIndex ===
  210. (store.currentTask &&
  211. store.currentTask.sliceUrls.indexOf(url) + 1)
  212. );
  213. const dataUrl = canvas.toDataURL();
  214. const sliceImage = new Image();
  215. sliceImage.src = dataUrl;
  216. sliceImagesWithTrackList.push({
  217. url: canvas.toDataURL(),
  218. indexInSliceUrls: store.currentTask.sliceUrls.indexOf(url) + 1,
  219. trackList: thisImageTrackList.filter(
  220. (t) =>
  221. t.positionY >= accumTopHeight / theFinalHeight &&
  222. t.positionY < accumBottomHeight / theFinalHeight
  223. ),
  224. originalImage: image,
  225. sliceImage,
  226. dx: image.naturalWidth * config[0],
  227. dy: 0,
  228. accumTopHeight,
  229. effectiveWidth: image.naturalWidth * config[1],
  230. });
  231. accumTopHeight = accumBottomHeight;
  232. }
  233. }
  234. }
  235. // 供回退和清除使用
  236. // let trackLen = store.currentMarkResult?.trackList.length;
  237. const renderPaperAndMark = async () => {
  238. // check if have MarkResult for currentTask
  239. let markResult = findCurrentTaskMarkResult();
  240. if (!markResult || !store.currentTask) return;
  241. // console.log(markResult.trackList.length);
  242. // if (markResult.trackList.length !== trackLen) {
  243. // sliceImagesWithTrackList.splice(0);
  244. // trackLen = markResult.trackList.length;
  245. // }
  246. // reset sliceImagesWithTrackList ,当切换任务时,要重新绘制图片和轨迹
  247. if (_studentId !== store.currentTask.studentId) {
  248. // 还原轨迹用得上
  249. sliceImagesWithTrackList.splice(0);
  250. _studentId = store.currentTask.studentId;
  251. }
  252. if (hasSliceConfig()) {
  253. await processSliceConfig();
  254. } else {
  255. await processSplitConfig();
  256. }
  257. };
  258. watchEffect(renderPaperAndMark);
  259. const answerPaperScale = computed(() => {
  260. // 放大、缩小不影响页面之前的滚动条定位
  261. let percentWidth = 0;
  262. let percentTop = 0;
  263. const container = document.querySelector(
  264. ".mark-body-container"
  265. ) as HTMLDivElement;
  266. if (container) {
  267. const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
  268. percentWidth = scrollLeft / scrollWidth;
  269. percentTop = scrollTop / scrollHeight;
  270. }
  271. addTimeout(() => {
  272. if (container) {
  273. const { scrollWidth, scrollHeight } = container;
  274. container.scrollTo({
  275. left: scrollWidth * percentWidth,
  276. top: scrollHeight * percentTop,
  277. });
  278. }
  279. }, 10);
  280. const scale = store.setting.uiSetting["answer.paper.scale"];
  281. return scale * 100 + "%";
  282. });
  283. const makeScoreTrack = (event: MouseEvent, item: SliceImage) => {
  284. // console.log(item);
  285. if (!store.currentQuestion || typeof store.currentScore === "undefined")
  286. return;
  287. const target = event.target as HTMLImageElement;
  288. const track = {} as Track;
  289. track.mainNumber = store.currentQuestion?.mainNumber;
  290. track.subNumber = store.currentQuestion?.subNumber;
  291. track.number = (Date.now() - new Date(2010, 0, 0).valueOf()) / 10e7;
  292. track.score = store.currentScore;
  293. track.offsetIndex = item.indexInSliceUrls;
  294. track.offsetX = Math.round(
  295. event.offsetX * (target.naturalWidth / target.width) + item.dx
  296. );
  297. track.offsetY = Math.round(
  298. event.offsetY * (target.naturalHeight / target.height) + item.dy
  299. );
  300. track.positionX = (track.offsetX - item.dx) / maxSliceWidth;
  301. track.positionY =
  302. (track.offsetY - item.dy + item.accumTopHeight) / theFinalHeight;
  303. if (track.offsetX > item.effectiveWidth + item.dx) {
  304. console.log("不在有效宽度内,轨迹不生效");
  305. return;
  306. }
  307. // 是否保留当前的轨迹分
  308. const ifKeepScore =
  309. store.currentQuestion.maxScore -
  310. (store.currentQuestion.score || 0) -
  311. store.currentScore * 2;
  312. if (
  313. (ifKeepScore < 0 && store.currentScore > 0) ||
  314. (ifKeepScore * 10) % (store.currentQuestion.intervalScore * 10) !== 0
  315. ) {
  316. store.currentScore = undefined;
  317. }
  318. const markResult = findCurrentTaskMarkResult();
  319. if (markResult) {
  320. markResult.trackList = [...markResult.trackList, track];
  321. }
  322. item.trackList.push(track);
  323. };
  324. // 清除分数轨迹
  325. watchEffect(() => {
  326. for (const track of store.removeScoreTracks) {
  327. for (const sliceImage of sliceImagesWithTrackList) {
  328. sliceImage.trackList = sliceImage.trackList.filter(
  329. (t) =>
  330. !(
  331. t.mainNumber === track.mainNumber &&
  332. t.subNumber === track.subNumber &&
  333. t.number === track.number
  334. )
  335. );
  336. }
  337. }
  338. });
  339. // 轨迹模式下,添加轨迹,更新分数
  340. watch(
  341. () => store.currentMarkResult?.trackList,
  342. () => {
  343. const markResult = findCurrentTaskMarkResult();
  344. if (markResult && store.currentMarkResult) {
  345. const scoreGroups = groupBy(
  346. markResult.trackList,
  347. (obj) =>
  348. (obj.mainNumber + "").padStart(10, "0") +
  349. obj.subNumber.padStart(10, "0")
  350. );
  351. const questionWithScore = Object.entries(scoreGroups);
  352. const questionWithTotalScore = questionWithScore.map((v) => [
  353. v[0],
  354. v[1].reduce((acc, c) => (acc += c.score), 0),
  355. ]);
  356. const questionWithTotalScoreSorted = sortBy(
  357. questionWithTotalScore,
  358. (obj) => obj[0]
  359. );
  360. const scoreList = questionWithTotalScoreSorted.map((s) => s[1]);
  361. // console.log(
  362. // scoreGroups,
  363. // questionWithScore,
  364. // questionWithTotalScore,
  365. // questionWithTotalScoreSorted,
  366. // scoreList
  367. // );
  368. const cq = store.currentQuestion;
  369. if (cq) {
  370. cq.score =
  371. markResult.trackList
  372. .filter(
  373. (v) =>
  374. v.mainNumber === cq.mainNumber &&
  375. v.subNumber === cq.subNumber
  376. )
  377. .map((v) => v.score)
  378. .reduce((acc, v) => (acc += v * 100), 0) / 100;
  379. }
  380. markResult.scoreList = scoreList as number[];
  381. // const sortScore = orderBy(markResult.trackList, ['mainNumber', 'subNumber', 'score']);
  382. // markResult.scoreList = sortScore.reduce((acc, pre) => {
  383. // if(pre.mainNumber === cur.mainNumber && pre.subNumber === cur.subNumber) {
  384. // acc[acc.length-1] += cur.score
  385. // }
  386. // }, [0])
  387. markResult.markerScore =
  388. markResult.scoreList
  389. .filter((v): v is number => v !== null)
  390. .reduce((acc, v) => (acc += v * 100), 0) / 100;
  391. // console.log(markResult.scoreList, markResult.markerScore);
  392. // renderPaperAndMark();
  393. }
  394. },
  395. { deep: true }
  396. );
  397. watch(
  398. () => store.setting.mode,
  399. () => {
  400. const shouldHide = store.setting.mode === ModeEnum.COMMON;
  401. if (shouldHide) {
  402. // console.log("hide cursor", theCursor);
  403. theCursor && theCursor.destroy();
  404. } else {
  405. if (document.querySelector(".cursor")) {
  406. // console.log("show cursor", theCursor);
  407. // theCursor && theCursor.enable();
  408. theCursor = new CustomCursor(".cursor", {
  409. focusElements: [
  410. {
  411. selector: ".mark-body-container",
  412. focusClass: "cursor--focused-view",
  413. },
  414. ],
  415. }).initialize();
  416. }
  417. }
  418. }
  419. );
  420. let theCursor = null as any;
  421. onMounted(() => {
  422. if (store.setting.mode === ModeEnum.TRACK) {
  423. theCursor = new CustomCursor(".cursor", {
  424. focusElements: [
  425. {
  426. selector: ".mark-body-container",
  427. focusClass: "cursor--focused-view",
  428. },
  429. ],
  430. }).initialize();
  431. }
  432. });
  433. onUnmounted(() => {
  434. theCursor && theCursor.destroy();
  435. });
  436. return {
  437. dragContainer,
  438. store,
  439. sliceImagesWithTrackList,
  440. answerPaperScale,
  441. makeScoreTrack,
  442. };
  443. },
  444. // renderTriggered({ key, target, type }) {
  445. // console.log({ key, target, type });
  446. // },
  447. });
  448. </script>
  449. <style scoped>
  450. .mark-body-container {
  451. height: calc(100vh - 41px);
  452. overflow: scroll;
  453. background-size: 8px 8px;
  454. background-image: linear-gradient(to right, #e7e7e7 4px, transparent 4px),
  455. linear-gradient(to bottom, transparent 4px, #e7e7e7 4px);
  456. }
  457. .mark-body-container img {
  458. width: 100%;
  459. }
  460. .single-image-container {
  461. position: relative;
  462. }
  463. .image-seperator {
  464. border: 2px solid rgba(120, 120, 120, 0.1);
  465. }
  466. .hide-cursor {
  467. display: none !important;
  468. }
  469. .cursor {
  470. color: #ff5050;
  471. display: none;
  472. pointer-events: none;
  473. -webkit-user-select: none;
  474. -moz-user-select: none;
  475. -ms-user-select: none;
  476. user-select: none;
  477. top: 0;
  478. left: 0;
  479. position: fixed;
  480. will-change: transform;
  481. z-index: 1000;
  482. }
  483. .cursor-border {
  484. position: absolute;
  485. box-sizing: border-box;
  486. align-items: center;
  487. border: 1px solid #ff5050;
  488. border-radius: 50%;
  489. display: flex;
  490. justify-content: center;
  491. height: 0px;
  492. width: 0px;
  493. left: 0;
  494. top: 0;
  495. transform: translate(-50%, -50%);
  496. transition: all 360ms cubic-bezier(0.23, 1, 0.32, 1);
  497. }
  498. .cursor.cursor--initialized {
  499. display: block;
  500. }
  501. .cursor .text {
  502. font-size: 2rem;
  503. opacity: 0;
  504. transition: opacity 80ms cubic-bezier(0.23, 1, 0.32, 1);
  505. }
  506. .cursor.cursor--off-screen {
  507. opacity: 0;
  508. }
  509. .cursor.cursor--focused .cursor-border,
  510. .cursor.cursor--focused-view .cursor-border {
  511. width: 90px;
  512. height: 90px;
  513. }
  514. .cursor.cursor--focused-view .text {
  515. opacity: 1;
  516. transition: opacity 360ms cubic-bezier(0.23, 1, 0.32, 1);
  517. }
  518. </style>