useDraw.ts 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255
  1. import { getStudentTrack } from '@/api/task';
  2. import { Task, Track, SpecialTag, Question, MarkArea } from '@/api/types/task';
  3. import { TrackConfigType } from '@/store/modules/app/types';
  4. import { PictureTypeEnum, PICTURE_TYPE } from '@/constants/enumerate';
  5. import {
  6. calcSumPrecision,
  7. deepCopy,
  8. maxNum,
  9. strGbLen,
  10. toPrecision,
  11. } from '@/utils/utils';
  12. import { DrawTrackItem } from '../../../../electron/preload/types';
  13. import { TrackTaskData } from '../../../../electron/db/models/trackTask';
  14. import { TrackTaskDetailData } from '../../../../electron/db/models/trackTaskDetail';
  15. type AnswerMap = Record<
  16. string,
  17. { answer: string; isRight: boolean; score: number; totalScore: number }
  18. >;
  19. interface TrackItemType {
  20. url: string;
  21. width: number;
  22. height: number;
  23. outpath: string;
  24. drawTrackList: DrawTrackItem[];
  25. }
  26. type ElementType =
  27. | 'FILL_QUESTION'
  28. | 'FILL_LINE'
  29. | 'EXPLAIN'
  30. | 'COMPOSITION'
  31. | 'TOPIC_HEAD'
  32. | 'CARD_HEAD';
  33. interface CardBaseElement {
  34. id: string;
  35. type: ElementType;
  36. topicNo: number;
  37. startNumber: number;
  38. questionsCount: number;
  39. }
  40. interface CardElement extends CardBaseElement {
  41. parent: CardBaseElement;
  42. }
  43. interface CardDataItem {
  44. exchange: {
  45. answer_area: Array<{
  46. main_number: number;
  47. sub_number: number | string;
  48. area: [number, number, number, number];
  49. }>;
  50. fill_area: Array<{
  51. field: 'question' | 'examNumber';
  52. index: number;
  53. single: boolean;
  54. horizontal: boolean;
  55. items: Array<{
  56. main_number: number;
  57. sub_number: number | string;
  58. options: [number, number, number, number][];
  59. }>;
  60. }>;
  61. };
  62. columns: Array<{
  63. elements: CardElement[];
  64. }>;
  65. }
  66. interface CardContentType {
  67. pages: CardDataItem[];
  68. }
  69. interface QuestionItem {
  70. mainNumber: number;
  71. subNumber: number | string;
  72. }
  73. interface QuestionArea {
  74. i: number;
  75. x: number;
  76. y: number;
  77. w: number;
  78. h: number;
  79. qStruct: string;
  80. }
  81. type UserMapType = Record<
  82. string,
  83. {
  84. userId: string;
  85. userName: string;
  86. color: string;
  87. scores: Array<{ subNumber: number; score: number }>;
  88. prename: string;
  89. score: number;
  90. }
  91. >;
  92. interface ImageItem {
  93. url: string;
  94. width: number;
  95. height: number;
  96. }
  97. interface PaperRecogData {
  98. page_index: number;
  99. question: Array<{
  100. index: number;
  101. fill_result: Array<{
  102. main_number: number;
  103. sub_number: number;
  104. single: number;
  105. fill_option: number[];
  106. suspect_flag: number;
  107. fill_position: string[];
  108. fill_size: number[];
  109. }>;
  110. }>;
  111. }
  112. interface DrawConfig {
  113. domain: string;
  114. task: TrackTaskData;
  115. taskDetail: TrackTaskDetailData;
  116. trackConfig: TrackConfigType;
  117. winId: number;
  118. }
  119. export default function useDraw(drawConfig: DrawConfig) {
  120. const { domain, task, taskDetail, trackConfig, winId } = drawConfig;
  121. let answerMap = {} as AnswerMap;
  122. let cardData = [] as CardDataItem[];
  123. let markAreas = [] as MarkArea[];
  124. let hasMarkArea = true;
  125. let recogDatas: string[] = [];
  126. let rawTask = {} as Task;
  127. let trackData = [] as TrackItemType[];
  128. let originImgs = [] as ImageItem[];
  129. let trackFiles = [] as ImageItem[];
  130. let curStudentId = '';
  131. const hasOrigin = trackConfig.pictureType.includes('origin');
  132. const hasTrack = trackConfig.pictureType.includes('track');
  133. const hasPdf = trackConfig.pictureType.includes('pdf');
  134. const defaultColorConfig = {
  135. track: ['red', 'blue', 'gray'],
  136. head: 'green',
  137. };
  138. let colorConfig = { track: ['red', 'blue', 'gray'], head: 'green' };
  139. function updateColorConfig() {
  140. if (trackConfig.trackColorType === 'ALL_RED') {
  141. colorConfig.head = 'red';
  142. colorConfig.track = ['red', 'red', 'red'];
  143. } else {
  144. colorConfig = deepCopy(defaultColorConfig);
  145. }
  146. }
  147. updateColorConfig();
  148. function addLog(content: string, type?: 'info' | 'error') {
  149. window.api.logger(`win:${winId} ${content}`, type);
  150. }
  151. async function runTask() {
  152. initData();
  153. curStudentId = taskDetail.studentId;
  154. addLog(`[${curStudentId}] 01-开始任务`);
  155. let result = true;
  156. try {
  157. await getTaskData(curStudentId);
  158. addLog(`[${curStudentId}] 02-获取任务数据成功`);
  159. originImgs = await downloadImages(rawTask.sheetUrls);
  160. addLog(`[${curStudentId}] 02-1-图片下载成功`);
  161. if (hasTrack || hasPdf) {
  162. await parseDrawList();
  163. addLog(`[${curStudentId}] 03-解析绘制数据成功`);
  164. trackFiles = await drawTask();
  165. addLog(`[${curStudentId}] 04-绘制成功`);
  166. if (hasPdf) {
  167. await window.api.imagesToPdf(trackFiles, getOutputPath('pdf'));
  168. addLog(`[${curStudentId}] 05-生成pdf成功`);
  169. }
  170. }
  171. clearResult();
  172. } catch (error) {
  173. const e = error as Error;
  174. console.log(e);
  175. addLog(
  176. `[${curStudentId}-${rawTask.studentCode}] 08-任务失败,原因:${
  177. e.message || '未知'
  178. }`,
  179. 'error'
  180. );
  181. result = false;
  182. }
  183. const status = result ? 'FINISH' : 'INIT';
  184. await window.db.updateTrackTaskDetailStatus({
  185. id: taskDetail.id,
  186. status,
  187. });
  188. addLog(`[${curStudentId}] 09-任务结束`);
  189. return true;
  190. }
  191. function initData() {
  192. cardData = [] as CardDataItem[];
  193. recogDatas = [] as string[];
  194. rawTask = {} as Task;
  195. trackData = [] as TrackItemType[];
  196. answerMap = {} as AnswerMap;
  197. originImgs = [] as ImageItem[];
  198. trackFiles = [] as ImageItem[];
  199. curStudentId = '';
  200. }
  201. function clearResult() {
  202. if (!hasOrigin) {
  203. window.api.clearFilesSync(originImgs.map((item) => item.url));
  204. }
  205. if (!hasTrack && trackFiles.length) {
  206. window.api.clearFilesSync(trackFiles.map((item) => item.url));
  207. }
  208. }
  209. async function getTaskData(studentId: string) {
  210. const res = await getStudentTrack(studentId);
  211. if (!res?.studentId) return;
  212. const subjectiveQuestions = res.subjectiveQuestions || [];
  213. const objectiveQuestions = res.objectiveQuestions || [];
  214. rawTask = {
  215. examId: res.examId,
  216. studentId: res.studentId,
  217. secretNumber: res.secretNumber,
  218. courseCode: res.courseCode,
  219. courseName: res.courseName,
  220. paperNumber: res.paperNumber,
  221. studentCode: res.studentCode,
  222. studentName: res.studentName,
  223. paperType: res.paperType,
  224. objectiveScore: res.objectiveScore || 0,
  225. markerScore: (res.objectiveScore || 0) + (res.subjectiveScore || 0),
  226. sheetUrls: res.sheetUrls ? res.sheetUrls.map((item) => item.url) : [],
  227. questionList: subjectiveQuestions,
  228. sliceConfig: [],
  229. jsonUrl: '',
  230. markerTime: 0,
  231. };
  232. recogDatas = (res.sheetUrls || []).map((item) => item.recogData);
  233. markAreas = subjectiveQuestions.map((item) => {
  234. return {
  235. mainNumber: item.mainNumber,
  236. subNumber: item.subNumber,
  237. questionType: item.questionType,
  238. splitConfig: item.picList || [],
  239. };
  240. });
  241. hasMarkArea = subjectiveQuestions.some((item) => {
  242. return item.picList?.length;
  243. });
  244. // 获取客观题选项信息
  245. objectiveQuestions.forEach((item) => {
  246. answerMap[`${item.mainNumber}_${item.subNumber}`] = {
  247. answer: item.answer,
  248. totalScore: item.totalScore,
  249. score: item.score,
  250. isRight: item.answer === item.standardAnswer,
  251. };
  252. });
  253. // 获取题卡数据
  254. const cardCont: CardContentType = res.cardContent
  255. ? JSON.parse(res.cardContent)
  256. : { pages: [] };
  257. cardData = cardCont.pages;
  258. }
  259. /**
  260. * 获取文件存储路径,规则:学期/考试/课程/试卷编号/教学班/下载文件类型/学生图片
  261. */
  262. function getOutputPath(type: PictureTypeEnum, index?: number) {
  263. const transfromStr = (str: string) => str.replace(/[*|:?<>]/g, '');
  264. let filename =
  265. trackConfig.studentFileRule === 'CODE_NAME'
  266. ? `${rawTask.studentCode}-${rawTask.studentName}`
  267. : rawTask.studentCode;
  268. filename = transfromStr(filename);
  269. if (index !== undefined) {
  270. filename += `-${index}`;
  271. }
  272. filename += type === 'pdf' ? '.pdf' : '.jpg';
  273. const paths = [
  274. trackConfig.curOutputDir,
  275. transfromStr(task.semesterName),
  276. transfromStr(task.examName),
  277. transfromStr(`${rawTask.courseName}(${rawTask.courseCode})`),
  278. transfromStr(rawTask.paperNumber),
  279. transfromStr(taskDetail.className),
  280. ];
  281. if (trackConfig.pictureType.includes(type)) {
  282. paths.push(PICTURE_TYPE[type]);
  283. } else {
  284. filename = `${type}-${filename}`;
  285. }
  286. paths.push(filename);
  287. return window.api.joinPath(paths);
  288. }
  289. async function downloadImages(urls: string[]) {
  290. const downloads: Promise<ImageItem>[] = [];
  291. for (let i = 0; i < urls.length; i++) {
  292. let url = urls[i];
  293. if (!url.startsWith('http://') && !url.startsWith('https://')) {
  294. url = `${domain}/${url}`;
  295. }
  296. // url += `&t=${Date.now()}`;
  297. downloads.push(
  298. window.api.downloadImage(url, getOutputPath('origin', i + 1))
  299. );
  300. }
  301. const images = await Promise.all(downloads).catch((error) => {
  302. console.log(error);
  303. });
  304. if (!images) {
  305. return Promise.reject(new Error('下载图片失败'));
  306. }
  307. return images;
  308. }
  309. async function parseDrawList() {
  310. trackData = [];
  311. const trackLists = (rawTask.questionList || [])
  312. .map((q) => {
  313. return q.headerTrackList?.length
  314. ? addHeaderTrackColorAttr<Track>(q.headerTrackList)
  315. : addTrackColorAttr<Track>(q.markerTrackList);
  316. })
  317. .flat();
  318. const tagLists = (rawTask.questionList || [])
  319. .map((q) =>
  320. q.headerTagList?.length
  321. ? addHeaderTrackColorAttr<SpecialTag>(q.headerTagList)
  322. : addTrackColorAttr<SpecialTag>(q.markerTagList || [])
  323. )
  324. .flat();
  325. const markDeailList = parseMarkDetailList(originImgs);
  326. const objectiveAnswerTagList = parseObjectiveAnswerTags(originImgs);
  327. for (let i = 0; i < originImgs.length; i++) {
  328. const img = originImgs[i];
  329. const drawTrackList = [] as DrawTrackItem[];
  330. trackLists
  331. .filter((item) => item.offsetIndex === i + 1)
  332. .forEach((item) => {
  333. drawTrackList.push(getDrawTrackItem(item));
  334. });
  335. tagLists
  336. .filter((item) => item.offsetIndex === i + 1)
  337. .forEach((item) => {
  338. drawTrackList.push(getDrawTagTrackItem(item));
  339. });
  340. const answerTags = paserRecogData(i);
  341. drawTrackList.push(...answerTags);
  342. drawTrackList.push(...(markDeailList[i] || []));
  343. const oTags = (objectiveAnswerTagList[i] || []).map(
  344. (tag) => tag.trackItem
  345. );
  346. drawTrackList.push(...oTags);
  347. drawTrackList.push(getTotalTrack(img));
  348. trackData[i] = {
  349. url: img.url,
  350. width: img.width,
  351. height: img.height,
  352. outpath: getOutputPath('track', i + 1),
  353. drawTrackList,
  354. };
  355. }
  356. if (!hasMarkArea && !cardData.length) {
  357. const summarys = parseSummaryData(originImgs[0]);
  358. trackData[0].drawTrackList.push(...summarys);
  359. }
  360. }
  361. async function drawTask(): Promise<ImageItem[]> {
  362. if (!trackData.length) return [];
  363. await window.api.drawTracks(trackData);
  364. return trackData.map((item) => {
  365. return {
  366. url: item.outpath,
  367. width: item.width,
  368. height: item.height,
  369. };
  370. });
  371. }
  372. // track ----- start->
  373. const trackTextFontSize = 30;
  374. const trackInfoFontSize = 20;
  375. const trackInfoLineHeight = 20 * 1.2;
  376. function getDrawTrackItem(track: Track): DrawTrackItem {
  377. return {
  378. type: 'text',
  379. option: {
  380. x: track.offsetX,
  381. y: track.offsetY - trackTextFontSize / 2,
  382. text: String(track.score),
  383. color: track.color,
  384. fontSize: trackTextFontSize,
  385. },
  386. };
  387. }
  388. function getDrawTagTrackItem(track: SpecialTag): DrawTrackItem {
  389. if (track.tagType === 'LINE') {
  390. const tagProp = JSON.parse(track.tagName) as {
  391. len: number;
  392. };
  393. return {
  394. type: 'line',
  395. option: {
  396. x0: track.offsetX,
  397. y0: track.offsetY,
  398. x1: track.offsetX + tagProp.len,
  399. y1: track.offsetY,
  400. },
  401. };
  402. }
  403. if (track.tagType === 'CIRCLE') {
  404. const tagProp = JSON.parse(track.tagName) as {
  405. width: number;
  406. height: number;
  407. };
  408. return {
  409. type: 'circle',
  410. option: {
  411. x0: track.offsetX,
  412. y0: track.offsetY,
  413. x1: track.offsetX + tagProp.width,
  414. y1: track.offsetY + tagProp.height,
  415. },
  416. };
  417. }
  418. return {
  419. type: 'text',
  420. option: {
  421. x: track.offsetX,
  422. y: track.offsetY - trackTextFontSize / 2,
  423. text: track.tagName,
  424. color: track.color,
  425. fontSize: trackTextFontSize,
  426. },
  427. };
  428. }
  429. function addHeaderTrackColorAttr<T extends { color?: string }>(
  430. headerTrackList: T[]
  431. ): T[] {
  432. return headerTrackList.map((item) => {
  433. item.color = colorConfig.head;
  434. return item;
  435. });
  436. }
  437. function addTrackColorAttr<
  438. T extends {
  439. userId: string;
  440. color?: string;
  441. isByMultMark?: boolean;
  442. }
  443. >(tList: T[]): T[] {
  444. let markerIds: string[] = tList.map((v) => v.userId).filter((x) => !!x);
  445. markerIds = Array.from(new Set(markerIds));
  446. // markerIds.sort();
  447. const colorMap: Record<string, string> = {};
  448. for (let i = 0; i < markerIds.length; i++) {
  449. const mId = markerIds[i];
  450. if (i === 0) {
  451. colorMap[mId] = colorConfig.track[0];
  452. } else if (i === 1) {
  453. colorMap[mId] = colorConfig.track[1];
  454. } else if (i > 1) {
  455. colorMap[mId] = colorConfig.track[2];
  456. }
  457. }
  458. type ColorK = keyof typeof colorMap;
  459. tList = tList.map((item: T) => {
  460. const k = item.userId as ColorK;
  461. item.color = colorMap[k] || 'red';
  462. item.isByMultMark = markerIds.length > 1;
  463. return item;
  464. });
  465. return tList;
  466. }
  467. // track ----- end->
  468. // mark detail ----- start->
  469. // 解析各试题答题区域以及评分
  470. function parseMarkDetailList(images: ImageItem[]): Array<DrawTrackItem[]> {
  471. const dataList: Array<DrawTrackItem[]> = [];
  472. const questions = rawTask.questionList || [];
  473. const fillQues = getFillLines();
  474. let fillQuestions = [] as Question[];
  475. let otherQuestions = questions;
  476. if (Object.keys(fillQues).length) {
  477. const fillQNos = Object.values(fillQues).flat();
  478. fillQuestions = questions.filter((q) =>
  479. fillQNos.includes(`${q.mainNumber}_${q.subNumber}`)
  480. );
  481. otherQuestions = questions.filter(
  482. (q) => !fillQNos.includes(`${q.mainNumber}_${q.subNumber}`)
  483. );
  484. }
  485. // 填空题:合并所有小题为一个区域
  486. Object.values(fillQues).forEach((qnos) => {
  487. const groupQuestions = fillQuestions.filter((q) =>
  488. qnos.includes(`${q.mainNumber}_${q.subNumber}`)
  489. );
  490. const areas = parseQuestionAreas(groupQuestions);
  491. if (!areas.length) return;
  492. const area = { ...areas[0] };
  493. const imgIndex = area.i - 1;
  494. if (!dataList[imgIndex]) {
  495. dataList[imgIndex] = [];
  496. }
  497. const img = images[imgIndex] as ImageItem;
  498. area.x *= img.width;
  499. area.y *= img.height;
  500. area.w *= img.width;
  501. const dataArr = dataList[imgIndex];
  502. const userMap: UserMapType = {};
  503. const isDoubleMark = !groupQuestions.some((question) => {
  504. let userIds = question.markerTrackList.map((track) => track.userId);
  505. if (
  506. !userIds.length &&
  507. question.markerList &&
  508. question.markerList.length
  509. ) {
  510. userIds = question.markerList
  511. .filter((marker) => !marker.header)
  512. .map((marker) => marker.userId);
  513. }
  514. const uids = new Set(userIds);
  515. return uids.size === 1;
  516. });
  517. groupQuestions.forEach((question) => {
  518. question.markerTrackList.forEach((track) => {
  519. if (!userMap[track.userId]) {
  520. userMap[track.userId] = {
  521. userId: track.userId,
  522. userName: track.userName,
  523. color: track.color || '',
  524. prename: '',
  525. scores: [],
  526. score: 0,
  527. };
  528. }
  529. const existUserScore = userMap[track.userId].scores.find(
  530. (s) => s.subNumber === track.subNumber
  531. );
  532. if (existUserScore) {
  533. existUserScore.score += track.score;
  534. } else {
  535. userMap[track.userId].scores.push({
  536. score: track.score,
  537. subNumber: track.subNumber,
  538. });
  539. }
  540. });
  541. // 普通模式没有轨迹
  542. if (
  543. !question.markerTrackList.length &&
  544. question.markerList &&
  545. question.markerList.length
  546. ) {
  547. question.markerList
  548. .filter((marker) => !marker.header)
  549. .forEach((marker) => {
  550. if (!userMap[marker.userId]) {
  551. userMap[marker.userId] = {
  552. userId: marker.userId,
  553. userName: marker.userName,
  554. color: marker.header ? 'green' : 'red',
  555. prename: '',
  556. scores: [],
  557. score: 0,
  558. };
  559. }
  560. userMap[marker.userId].scores.push({
  561. score: marker.score,
  562. subNumber: question.subNumber,
  563. });
  564. });
  565. }
  566. });
  567. const users = Object.values(userMap).map((user, index) => {
  568. const zhs = ['一', '二', '三'];
  569. const prename = isDoubleMark ? `${zhs[index] || ''}评` : '评卷员';
  570. return {
  571. ...user,
  572. prename,
  573. score: calcSumPrecision(user.scores.map((s) => s.score)),
  574. };
  575. });
  576. // 新增仲裁和复核记录
  577. const headerTrackQuestions = groupQuestions.filter(
  578. (question) => question.headerTrackList?.length
  579. );
  580. if (headerTrackQuestions.length) {
  581. const updateTypeUser = (type: 'ARBITRATED' | 'MARKED') => {
  582. const typeQuestions = headerTrackQuestions.filter(
  583. (question) => question.headerTrackList[0].headerType === type
  584. );
  585. typeQuestions.forEach((question) => {
  586. const scores = question.headerTrackList.map((track) => {
  587. return {
  588. score: track.score,
  589. subNumber: track.subNumber,
  590. };
  591. });
  592. const score = calcSumPrecision(scores.map((s) => s.score));
  593. const user = {
  594. userId: question.headerTrackList[0].userId,
  595. userName: question.headerTrackList[0].userName,
  596. color: 'green',
  597. prename: type === 'ARBITRATED' ? '仲裁' : '复核',
  598. scores,
  599. score,
  600. };
  601. users.push(user);
  602. });
  603. };
  604. // 仲裁记录
  605. updateTypeUser('ARBITRATED');
  606. // 复核记录
  607. updateTypeUser('MARKED');
  608. }
  609. // 填空题的打分需要自动换行,目前一行只展示最多10个评分
  610. let offsetY = -1 * trackInfoLineHeight;
  611. const lineScoreCount = 10;
  612. const groupLineScore = (userScore: string[], color: string) => {
  613. const groupCount = Math.ceil(userScore.length / lineScoreCount);
  614. const groups: string[] = [];
  615. for (let i = 0; i < groupCount; i++) {
  616. groups.push(
  617. userScore
  618. .slice(i * lineScoreCount, (i + 1) * lineScoreCount)
  619. .join(',')
  620. );
  621. }
  622. groups.forEach((group) => {
  623. offsetY += 20;
  624. dataArr.push({
  625. type: 'text',
  626. option: {
  627. x: area.x,
  628. y: area.y + offsetY,
  629. text: group,
  630. fontSize: trackInfoFontSize,
  631. color,
  632. },
  633. });
  634. });
  635. };
  636. users.forEach((user) => {
  637. offsetY += trackInfoLineHeight;
  638. dataArr.push({
  639. type: 'text',
  640. option: {
  641. x: area.x,
  642. y: area.y + offsetY,
  643. text: `${user.prename}:${user.userName},评分:`,
  644. fontSize: trackInfoFontSize,
  645. color: user.color,
  646. },
  647. });
  648. const userScore = user.scores.map(
  649. (item) => `${item.subNumber}:${item.score}分`
  650. );
  651. groupLineScore(userScore, user.color);
  652. });
  653. const score = calcSumPrecision(
  654. groupQuestions.map((item) => item.markerScore || 0)
  655. );
  656. const maxScore = calcSumPrecision(
  657. groupQuestions.map((item) => item.maxScore)
  658. );
  659. const tCont = `得分:${score},满分:${maxScore}`;
  660. const tContLen = strGbLen(tCont) / 2;
  661. dataArr.push({
  662. type: 'text',
  663. option: {
  664. x: area.x + area.w - Math.ceil(tContLen * trackTextFontSize),
  665. y: area.y,
  666. text: tCont,
  667. fontSize: trackTextFontSize,
  668. },
  669. });
  670. });
  671. // 其他试题
  672. otherQuestions.forEach((question) => {
  673. const areas = parseQuestionAreas([question]);
  674. if (!areas.length) return;
  675. const area = { ...areas[0] };
  676. const imgIndex = area.i - 1;
  677. if (!dataList[imgIndex]) {
  678. dataList[imgIndex] = [];
  679. }
  680. const img = images[imgIndex] as ImageItem;
  681. area.x *= img.width;
  682. area.y *= img.height;
  683. area.w *= img.width;
  684. const dataArr = dataList[imgIndex];
  685. const userMap: UserMapType = {};
  686. const hasHeaderTrack = question.headerTrackList?.length;
  687. // 是否仲裁
  688. const isArbitration =
  689. hasHeaderTrack &&
  690. question.headerTrackList[0].headerType === 'ARBITRATED';
  691. // 是否复核
  692. const isReview =
  693. hasHeaderTrack && question.headerTrackList[0].headerType === 'MARKED';
  694. const tList = hasHeaderTrack
  695. ? question.headerTrackList || []
  696. : question.markerTrackList || [];
  697. tList.forEach((track) => {
  698. if (!userMap[track.userId]) {
  699. userMap[track.userId] = {
  700. userId: track.userId,
  701. userName: track.userName,
  702. color: hasHeaderTrack ? 'green' : track.color || 'red',
  703. prename: '',
  704. scores: [],
  705. score: 0,
  706. };
  707. }
  708. userMap[track.userId].scores.push({
  709. score: track.score,
  710. subNumber: track.subNumber,
  711. });
  712. });
  713. // 是否双评,复核或者仲裁只会是一个人
  714. const isDoubleMark = Object.keys(userMap).length > 1;
  715. const zhs = ['一', '二', '三'];
  716. let users = Object.values(userMap).map((user, index) => {
  717. let prename = '';
  718. if (isArbitration) {
  719. prename = '仲裁';
  720. } else if (isReview) {
  721. prename = '复核';
  722. } else {
  723. prename = isDoubleMark ? `${zhs[index] || ''}评` : '评卷员';
  724. }
  725. return {
  726. ...user,
  727. prename,
  728. score: calcSumPrecision(user.scores.map((s) => s.score)),
  729. };
  730. });
  731. // 普通模式没有轨迹
  732. if (!tList.length && question.markerList && question.markerList.length) {
  733. let markers = question.markerList.filter((marker) => marker.header);
  734. if (!markers.length) {
  735. markers = question.markerList.filter((marker) => !marker.header);
  736. }
  737. users = markers.map((item, index) => {
  738. return {
  739. userId: item.userId,
  740. userName: item.userName,
  741. color: item.header ? 'green' : 'red',
  742. prename: markers.length > 1 ? `${zhs[index] || ''}评` : '评卷员',
  743. scores: [],
  744. score: item.score,
  745. };
  746. });
  747. }
  748. users.forEach((user, index) => {
  749. const content = `${user.prename}:${user.userName},评分:${user.score}`;
  750. dataArr.push({
  751. type: 'text',
  752. option: {
  753. x: area.x,
  754. y: area.y + index * trackInfoLineHeight,
  755. text: content,
  756. fontSize: trackInfoFontSize,
  757. color: user.color,
  758. },
  759. });
  760. });
  761. const tCont = `得分:${question.markerScore},满分:${question.maxScore}`;
  762. const tContLen = strGbLen(tCont) / 2;
  763. dataArr.push({
  764. type: 'text',
  765. option: {
  766. x: area.x + area.w - Math.ceil(tContLen * trackTextFontSize),
  767. y: area.y,
  768. text: tCont,
  769. fontSize: trackTextFontSize,
  770. },
  771. });
  772. });
  773. return dataList;
  774. }
  775. function getTotalTrack(image: ImageItem): DrawTrackItem {
  776. const totalScore = rawTask.markerScore || 0;
  777. const objectiveScore = rawTask.objectiveScore || 0;
  778. const subjectiveScore = toPrecision(totalScore - objectiveScore);
  779. return {
  780. type: 'text',
  781. option: {
  782. x: 0.15 * image.width,
  783. y: 0.01 * image.height,
  784. text: `总分:${totalScore},主观题得分:${subjectiveScore},客观题得分:${objectiveScore}`,
  785. fontSize: 40,
  786. },
  787. };
  788. }
  789. // 通过题卡获取属于填空题的试题号
  790. // function getFillLinesFromCard() {
  791. // const questions: Record<number, string[]> = {};
  792. // cardData.forEach((page) => {
  793. // page.columns.forEach((column) => {
  794. // column.elements.forEach((element) => {
  795. // if (element.type !== 'FILL_LINE') return;
  796. // if (!questions[element.topicNo]) questions[element.topicNo] = [];
  797. // for (let i = 0; i < element.questionsCount; i++) {
  798. // questions[element.topicNo].push(
  799. // `${element.topicNo}_${element.startNumber + i}`
  800. // );
  801. // }
  802. // });
  803. // });
  804. // });
  805. // return questions;
  806. // }
  807. // 通过评卷区获取属于填空题的试题号
  808. function getFillLines() {
  809. if (!markAreas?.length) return {};
  810. const questions: Record<number, string[]> = {};
  811. markAreas.forEach((markArea) => {
  812. const { mainNumber, subNumber, questionType } = markArea;
  813. if (questionType !== 4) return;
  814. if (!questions[mainNumber]) questions[mainNumber] = [];
  815. questions[mainNumber].push(`${mainNumber}_${subNumber}`);
  816. });
  817. return questions;
  818. }
  819. // 解析各试题答题区域
  820. function parseQuestionAreas(questions: QuestionItem[]) {
  821. if (!questions.length) return [];
  822. let pictureConfigs = [];
  823. if (hasMarkArea) {
  824. pictureConfigs = parseMarkQuestionAreas(questions);
  825. } else {
  826. pictureConfigs = parseCardQuestionAreas(questions);
  827. }
  828. return shrinkQuestionArea(pictureConfigs);
  829. }
  830. // 获取题型的评卷区
  831. function parseMarkQuestionAreas(questions: QuestionItem[]) {
  832. if (!questions.length || !markAreas?.length) return [];
  833. const pictureConfigs: QuestionArea[] = [];
  834. const structs = questions.map(
  835. (item) => `${item.mainNumber}_${item.subNumber}`
  836. );
  837. markAreas.forEach((markArea) => {
  838. const qStruct = `${markArea.mainNumber}_${markArea.subNumber}`;
  839. if (!structs.includes(qStruct)) return;
  840. (markArea.splitConfig || []).forEach((area) => {
  841. pictureConfigs.push({
  842. i: area.i,
  843. x: area.x,
  844. y: area.y,
  845. w: area.w,
  846. h: area.h,
  847. qStruct,
  848. });
  849. });
  850. });
  851. // 合并相邻区域
  852. const combinePictureConfigList = combinePictureConfig(pictureConfigs);
  853. // console.log(combinePictureConfigList);
  854. return combinePictureConfigList;
  855. }
  856. // 通过题卡获取试题评卷区
  857. function parseCardQuestionAreas(questions: QuestionItem[]) {
  858. if (!questions.length || !cardData?.length) return [];
  859. const pictureConfigs: QuestionArea[] = [];
  860. const structs = questions.map(
  861. (item) => `${item.mainNumber}_${item.subNumber}`
  862. );
  863. cardData.forEach((page, pindex) => {
  864. page.exchange.answer_area.forEach((area) => {
  865. const [x, y, w, h] = area.area;
  866. const qStruct = `${area.main_number}_${area.sub_number}`;
  867. const pConfig: QuestionArea = {
  868. i: pindex + 1,
  869. x,
  870. y,
  871. w,
  872. h,
  873. qStruct,
  874. };
  875. if (typeof area.sub_number === 'number') {
  876. if (!structs.includes(qStruct)) return;
  877. pictureConfigs.push(pConfig);
  878. return;
  879. }
  880. // 复合区域处理,比如填空题,多个小题合并为一个区域
  881. if (typeof area.sub_number === 'string') {
  882. const areaStructs = area.sub_number
  883. .split(',')
  884. .map((subNumber) => `${area.main_number}_${subNumber}`);
  885. if (
  886. structs.some((struct) => areaStructs.includes(struct)) &&
  887. !pictureConfigs.find((item) => item.qStruct === qStruct)
  888. ) {
  889. pictureConfigs.push(pConfig);
  890. }
  891. }
  892. });
  893. });
  894. // console.log(pictureConfigs);
  895. // 合并相邻区域
  896. const combinePictureConfigList: QuestionArea[] =
  897. combinePictureConfig(pictureConfigs);
  898. // console.log(combinePictureConfigList);
  899. return combinePictureConfigList;
  900. }
  901. // 缩小区域
  902. // 给定区域向中心缩小到原来的0.9倍,高度为原来的0.94倍
  903. function shrinkQuestionArea(pictureConfigs: QuestionArea[]) {
  904. const shrinkPictureConfigList: QuestionArea[] = [];
  905. pictureConfigs.forEach((item) => {
  906. const { x, y, w, h } = item;
  907. const newX = x + w / 2;
  908. const newY = y + h / 2;
  909. const newW = w * 0.9;
  910. const newH = h * 0.94;
  911. shrinkPictureConfigList.push({
  912. i: item.i,
  913. x: newX - newW / 2,
  914. y: newY - newH / 2,
  915. w: newW,
  916. h: newH,
  917. qStruct: item.qStruct,
  918. });
  919. });
  920. return shrinkPictureConfigList;
  921. }
  922. function combinePictureConfig(pictureConfigs: QuestionArea[]) {
  923. pictureConfigs.sort((a, b) => {
  924. return a.i - b.i || a.x - b.x || a.y - b.y;
  925. });
  926. const combinePictureConfigList: QuestionArea[] = [];
  927. const elasticRate = 0.01;
  928. let prevConfig = {} as QuestionArea;
  929. pictureConfigs.forEach((item, index) => {
  930. if (!index) {
  931. prevConfig = { ...item };
  932. combinePictureConfigList.push(prevConfig);
  933. return;
  934. }
  935. if (
  936. prevConfig.i === item.i &&
  937. prevConfig.y + prevConfig.h + elasticRate >= item.y &&
  938. prevConfig.w === item.w &&
  939. prevConfig.x === item.x
  940. ) {
  941. prevConfig.h = item.y + item.h - prevConfig.y;
  942. } else {
  943. prevConfig = { ...item };
  944. combinePictureConfigList.push(prevConfig);
  945. }
  946. });
  947. // console.log(combinePictureConfigList);
  948. return combinePictureConfigList;
  949. }
  950. // mark detail ----- end->
  951. // answer tag ----- start->
  952. // 解析客观题答案展示位置
  953. function paserRecogData(imageIndex: number): DrawTrackItem[] {
  954. if (!recogDatas.length || !recogDatas[imageIndex]) return [];
  955. const recogData: PaperRecogData = JSON.parse(
  956. window.atob(recogDatas[imageIndex])
  957. );
  958. const answerTags: DrawTrackItem[] = [];
  959. recogData.question.forEach((question) => {
  960. question.fill_result.forEach((result) => {
  961. const fillPositions = result.fill_position.map((pos) => {
  962. return pos.split(',').map((n) => Number(n));
  963. });
  964. const offsetLt = result.fill_size.map((item) => item * 0.4);
  965. const tagLeft =
  966. maxNum(fillPositions.map((pos) => pos[0])) +
  967. result.fill_size[0] -
  968. offsetLt[0];
  969. const tagTop = fillPositions[0][1] - offsetLt[1];
  970. const { answer, isRight } =
  971. answerMap[`${result.main_number}_${result.sub_number}`] || {};
  972. answerTags.push({
  973. type: 'text',
  974. option: {
  975. x: tagLeft,
  976. y: tagTop,
  977. text: answer || '',
  978. color: isRight ? '#05b575' : '#f53f3f',
  979. },
  980. });
  981. });
  982. });
  983. return answerTags;
  984. }
  985. // answer tag ----- end->
  986. // objective answer tag ----- start->
  987. interface ObjectiveAnswerTagItem {
  988. id: string;
  989. mainNumber: number;
  990. subNumbers: string;
  991. score: number;
  992. totalScore: number;
  993. trackItem: DrawTrackItem;
  994. }
  995. function parseObjectiveAnswerTags(images: ImageItem[]) {
  996. const objectiveAnswerTags: Array<ObjectiveAnswerTagItem[]> = [];
  997. if (!cardData?.length) return objectiveAnswerTags;
  998. cardData.forEach((page, pindex) => {
  999. if (!objectiveAnswerTags[pindex]) objectiveAnswerTags[pindex] = [];
  1000. const img = images[pindex] as ImageItem;
  1001. page.columns.forEach((column) => {
  1002. column.elements.forEach((element) => {
  1003. if (element.type !== 'FILL_QUESTION') return;
  1004. const ogroup = objectiveAnswerTags.find((tgroup) =>
  1005. tgroup.some((oitem) => oitem.id === element.parent.id)
  1006. );
  1007. if (ogroup) return;
  1008. const parent = element.parent;
  1009. const oaTagItem: ObjectiveAnswerTagItem = {
  1010. id: parent.id,
  1011. mainNumber: parent.topicNo,
  1012. subNumbers: `${parent.startNumber}~${
  1013. parent.startNumber + parent.questionsCount - 1
  1014. }`,
  1015. score: 0,
  1016. totalScore: 0,
  1017. trackItem: {} as DrawTrackItem,
  1018. };
  1019. // 位置
  1020. const area = { x: 0, y: 0, w: 0.44 };
  1021. page.exchange.fill_area.forEach((fa) => {
  1022. fa.items.forEach((fitem) => {
  1023. if (
  1024. fitem.main_number === oaTagItem.mainNumber &&
  1025. fitem.sub_number === parent.startNumber
  1026. ) {
  1027. const [x, y] = fitem.options[0];
  1028. area.x = x;
  1029. area.y = y;
  1030. }
  1031. });
  1032. });
  1033. area.x = (area.x - 0.015) * img.width;
  1034. area.y = (area.y - 0.04) * img.height;
  1035. area.w *= img.width;
  1036. // 分数统计
  1037. const questions: Array<{ score: number; totalScore: number }> = [];
  1038. for (let i = 0; i < parent.questionsCount; i++) {
  1039. const qans = answerMap[
  1040. `${parent.topicNo}_${i + parent.startNumber}`
  1041. ] || { score: 0, totalScore: 0 };
  1042. questions[i] = {
  1043. score: qans.score,
  1044. totalScore: qans.totalScore,
  1045. };
  1046. }
  1047. oaTagItem.score = calcSumPrecision(
  1048. questions.map((q) => q.score || 0)
  1049. );
  1050. oaTagItem.totalScore = calcSumPrecision(
  1051. questions.map((q) => q.totalScore || 0)
  1052. );
  1053. const tCont = `得分:${oaTagItem.score},满分:${oaTagItem.totalScore}`;
  1054. const tContLen = strGbLen(tCont) / 2;
  1055. oaTagItem.trackItem = {
  1056. type: 'text',
  1057. option: {
  1058. x: area.x + area.w - Math.ceil(tContLen * trackTextFontSize),
  1059. y: area.y,
  1060. text: tCont,
  1061. fontSize: trackTextFontSize,
  1062. },
  1063. };
  1064. objectiveAnswerTags[pindex].push(oaTagItem);
  1065. });
  1066. });
  1067. });
  1068. return objectiveAnswerTags;
  1069. }
  1070. // objective answer tag ----- end->
  1071. // 首页汇总信息
  1072. // 获取汇总记录中评卷员信息
  1073. function getSummaryMarkerName(q: Question): string {
  1074. let markerName = '';
  1075. if (q.headerTrackList && q.headerTrackList.length) {
  1076. markerName = q.headerTrackList[0].userName;
  1077. } else if (q.markerTrackList && q.markerTrackList.length) {
  1078. markerName = q.markerTrackList[0].userName;
  1079. } else if (q.markerList && q.markerList.length) {
  1080. let markers = q.markerList.filter((marker) => marker.header);
  1081. if (!markers.length) {
  1082. markers = q.markerList.filter((marker) => !marker.header);
  1083. }
  1084. if (markers.length) markerName = markers[0].userName;
  1085. }
  1086. return markerName;
  1087. }
  1088. // 解析首页汇总信息
  1089. function parseSummaryData(img: ImageItem): DrawTrackItem[] {
  1090. const isDoubleMark = (rawTask.questionList || []).some((question) => {
  1091. let userIds = question.markerTrackList.map((track) => track.userId);
  1092. if (
  1093. !userIds.length &&
  1094. question.markerList &&
  1095. question.markerList.length
  1096. ) {
  1097. userIds = question.markerList
  1098. .filter((marker) => !marker.header)
  1099. .map((marker) => marker.userId);
  1100. }
  1101. const uids = new Set(userIds);
  1102. return uids.size === 2;
  1103. });
  1104. if (isDoubleMark) return [];
  1105. const dataList: DrawTrackItem[] = [];
  1106. const sources: string[][] = [['主观题号', '分数', '评卷员']];
  1107. (rawTask.questionList || []).forEach((q) => {
  1108. sources.push([
  1109. `${q.mainNumber}-${q.subNumber}`,
  1110. `${q.markerScore}`,
  1111. getSummaryMarkerName(q),
  1112. ]);
  1113. });
  1114. const rowX = img.width * 0.05;
  1115. const rowY = img.height * 0.11;
  1116. // const rowW = img.width * 0.45;
  1117. const columnOffsetLeft = [0, 150, 80 + 150];
  1118. sources.forEach((source, sindex) => {
  1119. source.forEach((cont, cindex) => {
  1120. dataList.push({
  1121. type: 'text',
  1122. option: {
  1123. x: rowX + columnOffsetLeft[cindex],
  1124. y: rowY + sindex * trackInfoLineHeight,
  1125. text: cont,
  1126. color: 'red',
  1127. fontSize: trackInfoFontSize,
  1128. },
  1129. });
  1130. });
  1131. });
  1132. return dataList;
  1133. }
  1134. return {
  1135. runTask,
  1136. };
  1137. }