import { ref } from 'vue'; import { getSingleStudentTaskOfStudentTrack, studentObjectiveConfirmData, getSingleStudentCardData, } from '@/api/task'; import { Task, Track, SpecialTag, Question } from '@/api/types/task'; import { TrackConfigType } from '@/store/modules/app/types'; import { PictureTypeEnum, PICTURE_TYPE } from '@/constants/enumerate'; import { calcSum, deepCopy, maxNum, strGbLen } from '@/utils/utils'; import { useAppStore } from '@/store'; import { DrawTrackItem } from '../../../../electron/preload/types'; import { TrackTaskData } from '../../../../electron/db/models/trackTask'; import { TrackTaskDetailData } from '../../../../electron/db/models/trackTaskDetail'; type AnswerMap = Record; interface TrackItemType { url: string; width: number; height: number; drawTrackList: DrawTrackItem[]; } type ElementType = | 'FILL_QUESTION' | 'FILL_LINE' | 'EXPLAIN' | 'COMPOSITION' | 'TOPIC_HEAD' | 'CARD_HEAD'; interface CardDataItem { exchange: { answer_area: Array<{ main_number: number; sub_number: number | string; area: [number, number, number, number]; }>; }; columns: Array<{ elements: Array<{ type: ElementType; topicNo: number; startNumber: number; questionsCount: number; }>; }>; } interface CardContentType { pages: CardDataItem[]; } interface QuestionItem { mainNumber: number; subNumber: number | string; } interface QuestionArea { i: number; x: number; y: number; w: number; h: number; qStruct: string; } type UserMapType = Record< string, { userId: string; userName: string; color: string; scores: Array<{ subNumber: string; score: number }>; } >; interface ImageItem { url: string; width: number; height: number; } interface PaperRecogData { page_index: number; question: Array<{ index: number; fill_result: Array<{ main_number: number; sub_number: number; single: number; fill_option: number[]; suspect_flag: number; fill_position: string[]; fill_size: number[]; }>; }>; } export default function useDraw(winId: number) { const curWinId = winId; let answerMap = {} as AnswerMap; let cardData = [] as CardDataItem[]; let recogDatas: string[] = []; let rawTask = {} as Task; let trackData = [] as TrackItemType[]; let originImgs = [] as ImageItem[]; let isOnlyOrigin = false; let hasPdf = false; let curStudentId = ''; const defaultColorConfig = { track: ['red', 'blue', 'gray'], head: 'green', }; let colorConfig = { track: ['red', 'blue', 'gray'], head: 'green' }; const task = ref({} as TrackTaskData); const taskDetail = ref({} as TrackTaskDetailData); const trackConfig = ref({} as TrackConfigType); const appStore = useAppStore(); function updateColorConfig() { if (trackConfig.value.trackColorType === 'ALL_RED') { colorConfig.head = 'red'; colorConfig.track = ['red', 'red', 'red']; } else { colorConfig = deepCopy(defaultColorConfig); } } function addLog(content: string, type?: 'info' | 'error') { window.api.logger(`win:${curWinId} ${content}`, type); } async function getTrackTask(schoolId: string) { const res = await window.db.getUnfinishTrackTask(schoolId); if (!res) return Promise.reject(new Error('无任务')); task.value = res; trackConfig.value = JSON.parse(res.trackConfig) as TrackConfigType; isOnlyOrigin = checkOnlyOrigin(); hasPdf = trackConfig.value.pictureType.includes('pdf'); updateColorConfig(); return res.id; } async function getTrackTaskDetail() { const res = await window.db.getUnfinishTrackTaskDetail(task.value.id); if (!res) return null; taskDetail.value = res; return taskDetail.value; } async function runTask() { initData(); curStudentId = taskDetail.value.studentId; addLog(`[${curStudentId}] 01-开始任务`); try { await getTaskData(curStudentId); addLog(`[${curStudentId}] 02-获取任务数据成功`); originImgs = await downloadImages(rawTask.sheetUrls); if (isOnlyOrigin) { return true; } await parseDrawList(); addLog(`[${curStudentId}] 03-解析绘制数据成功`); const trackFiles = await drawTask(); addLog(`[${curStudentId}] 04-绘制成功`); if (hasPdf) { await window.api.imagesToPdf(trackFiles, getOutputPath('pdf')); addLog(`[${curStudentId}] 05-生成pdf成功`); } } catch (error) { const e = error as Error; console.log(e); addLog( `[${curStudentId}-${rawTask.studentCode}] 08-任务失败,原因:${ e.message || '未知' }`, 'error' ); return Promise.reject(error); } return true; } function initData() { cardData = [] as CardDataItem[]; recogDatas = [] as string[]; rawTask = {} as Task; trackData = [] as TrackItemType[]; answerMap = {} as AnswerMap; originImgs = [] as ImageItem[]; curStudentId = ''; } function checkOnlyOrigin() { return ( trackConfig.value.pictureType.length === 1 && trackConfig.value.pictureType[0] === 'origin' ); } async function getTaskData(studentId: string) { rawTask = await getSingleStudentTaskOfStudentTrack(studentId); if (!rawTask) return; if (!rawTask.sheetUrls) rawTask.sheetUrls = []; if (isOnlyOrigin) return; // 获取客观题选项信息 const objectiveData = await studentObjectiveConfirmData(studentId); objectiveData.answers.forEach((item) => { answerMap[`${item.mainNumber}_${item.subNumber}`] = { answer: item.answer, isRight: item.answer === item.standardAnswer, }; }); recogDatas = objectiveData.sheetUrls.map((item) => item.recogData); // 获取题卡数据 const cardRes = await getSingleStudentCardData(studentId); const cardContent = cardRes && cardRes.content ? (JSON.parse(cardRes.content) as CardContentType) : { pages: [] }; cardData = cardContent.pages; } /** * 获取文件存储路径,规则:学期/考试/课程/试卷编号/教学班/下载文件类型/学生图片 */ function getOutputPath(type: PictureTypeEnum, index?: number) { const transfromStr = (str: string) => str.replace(/[*|:?<>]/g, ''); let filename = trackConfig.value.studentFileRule === 'CODE_NAME' ? `${rawTask.studentCode}-${rawTask.studentName}` : rawTask.studentCode; filename = transfromStr(filename); if (index !== undefined) { filename += `-${index}`; } filename += type === 'pdf' ? '.pdf' : '.jpg'; const paths = [ trackConfig.value.curOutputDir, transfromStr(task.value.semesterName), transfromStr(task.value.examName), transfromStr(`${rawTask.courseName}(${rawTask.courseCode})`), transfromStr(rawTask.paperNumber), transfromStr(taskDetail.value.className), PICTURE_TYPE[type], filename, ]; return window.api.joinPath(paths); } async function downloadImages(urls: string[]) { const downloads: Promise[] = []; for (let i = 0; i < urls.length; i++) { let url = urls[i]; if (!url.startsWith('http://') && !url.startsWith('https://')) { url = `${appStore.domain}/${url}`; } downloads.push( window.api.downloadImage(url, getOutputPath('origin', i + 1)) ); } const images = await Promise.all(downloads).catch((error: Error) => { console.log(error); addLog(`下载图片错误:${error.toString()}`, 'error'); }); if (!images) { return Promise.reject(new Error('下载图片失败')); } return images; } async function parseDrawList() { trackData = []; const trackLists = (rawTask.questionList || []) .map((q) => { return q.headerTrack?.length ? addHeaderTrackColorAttr(q.headerTrack) : addTrackColorAttr(q.trackList); }) .flat(); const markDeailList = parseMarkDetailList(originImgs); for (let i = 0; i < originImgs.length; i++) { const img = originImgs[i]; const drawTrackList = [] as DrawTrackItem[]; trackLists .filter((item) => item.offsetIndex === i + 1) .forEach((item) => { drawTrackList.push(getDrawTrackItem(item)); }); (rawTask.specialTagList || []) .filter((item) => item.offsetIndex === i + 1) .forEach((item) => { drawTrackList.push(getDrawTagTrackItem(item)); }); const answerTags = paserRecogData(i); drawTrackList.push(...answerTags); drawTrackList.push(...(markDeailList[i] || [])); drawTrackList.push(getTotalTrack(img)); trackData[i] = { url: img.url, width: img.width, height: img.height, drawTrackList, }; } } async function drawTask(): Promise { if (!trackData.length) return []; const draw = async (item: TrackItemType, index: number) => { const outpath = getOutputPath('track', index); const url = await window.api.drawTrack( item.url, item.drawTrackList, outpath ); return { url, width: item.width, height: item.height }; }; const tasks = trackData.map((item, index) => draw(item, index + 1)); const res = await Promise.all(tasks).catch((error: Error) => { console.log(error); addLog(`绘制轨迹错误:${error.toString()}`, 'error'); }); if (!res) { return Promise.reject(new Error('绘制轨迹失败')); } return res; } // track ----- start-> const trackTextFontSize = 30; const trackInfoFontSize = 20; const trackInfoLineHeight = 20 * 1.2; function getDrawTrackItem(track: Track): DrawTrackItem { return { type: 'text', option: { x: track.offsetX, y: track.offsetY - trackTextFontSize / 2, text: String(track.score), color: track.color, fontSize: trackTextFontSize, }, }; } function getDrawTagTrackItem(track: SpecialTag): DrawTrackItem { if (track.tagType === 'LINE') { const tagProp = JSON.parse(track.tagName) as { len: number; }; return { type: 'line', option: { x0: track.offsetX, y0: track.offsetY, x1: track.offsetX + tagProp.len, y1: track.offsetY, }, }; } if (track.tagType === 'CIRCLE') { const tagProp = JSON.parse(track.tagName) as { width: number; height: number; }; return { type: 'circle', option: { x0: track.offsetX, y0: track.offsetY, x1: track.offsetX + tagProp.width, y1: track.offsetY + tagProp.height, }, }; } return { type: 'text', option: { x: track.offsetX, y: track.offsetY - trackTextFontSize / 2, text: track.tagName, color: track.color, fontSize: trackTextFontSize, }, }; } function addHeaderTrackColorAttr(headerTrack: Track[]): Track[] { return headerTrack.map((item) => { item.color = colorConfig.head; return item; }); } function addTrackColorAttr(tList: Track[]): Track[] { let markerIds: string[] = tList.map((v) => v.userId).filter((x) => !!x); markerIds = Array.from(new Set(markerIds)); // markerIds.sort(); const colorMap: Record = {}; for (let i = 0; i < markerIds.length; i++) { const mId = markerIds[i]; if (i === 0) { colorMap[mId] = colorConfig.track[0]; } else if (i === 1) { colorMap[mId] = colorConfig.track[1]; } else if (i > 1) { colorMap[mId] = colorConfig.track[2]; } } type ColorK = keyof typeof colorMap; tList = tList.map((item: Track) => { const k = item.userId as ColorK; item.color = colorMap[k] || 'red'; item.isByMultMark = markerIds.length > 1; return item; }); return tList; } /* function addTagColorAttr(tList: SpecialTag[]): SpecialTag[] { let markerIds: string[] = tList .map((v) => String(v.userId)) .filter((x) => !!x); markerIds = Array.from(new Set(markerIds)); // markerIds.sort(); const colorMap: Record = {}; for (let i = 0; i < markerIds.length; i++) { const mId = markerIds[i]; if (i === 0) { colorMap[mId] = 'red'; } else if (i === 1) { colorMap[mId] = 'blue'; } else if (i > 1) { colorMap[mId] = 'gray'; } } type ColorK = keyof typeof colorMap; tList = tList.map((item: SpecialTag) => { const k = String(item.userId) as ColorK; item.color = colorMap[k] || 'red'; item.isByMultMark = markerIds.length > 1; return item; }); return tList; } */ // track ----- end-> // mark detail ----- start-> // 解析各试题答题区域以及评分 function parseMarkDetailList(images: ImageItem[]): Array { const dataList: Array = []; const questions = rawTask.questionList || []; const fillQues = getFillLines(); let fillQuestions = [] as Question[]; let otherQuestions = questions; if (Object.keys(fillQues).length) { const fillQNos = Object.values(fillQues).flat(); fillQuestions = questions.filter((q) => fillQNos.includes(`${q.mainNumber}_${q.subNumber}`) ); otherQuestions = questions.filter( (q) => !fillQNos.includes(`${q.mainNumber}_${q.subNumber}`) ); } // 填空题:合并所有小题为一个区域 Object.values(fillQues).forEach((qnos) => { const groupQuestions = fillQuestions.filter((q) => qnos.includes(`${q.mainNumber}_${q.subNumber}`) ); const areas = parseQuestionAreas(groupQuestions); if (!areas.length) return; const area = { ...areas[0] }; const imgIndex = area.i - 1; if (!dataList[imgIndex]) { dataList[imgIndex] = []; } const img = images[imgIndex] as ImageItem; area.x *= img.width; area.y *= img.height; area.w *= img.width; const dataArr = dataList[imgIndex]; const userMap: UserMapType = {}; const isDoubleMark = !groupQuestions.some((question) => { const userIds = question.trackList.map((track) => track.userId); const uids = new Set(userIds); return uids.size === 1; }); groupQuestions.forEach((question) => { question.trackList.forEach((track) => { if (!userMap[track.userId]) { userMap[track.userId] = { userId: track.userId, userName: track.userName, color: track.color || '', scores: [], }; } const existUserScore = userMap[track.userId].scores.find( (s) => s.subNumber === track.subNumber ); if (existUserScore) { existUserScore.score += track.score; } else { userMap[track.userId].scores.push({ score: track.score, subNumber: track.subNumber, }); } }); }); // 填空题的打分需要自动换行,目前一行只展示最多7个评分 let offsetY = -1 * trackInfoLineHeight; Object.values(userMap).forEach((user, index) => { const zhs = ['一', '二', '三']; const prename = isDoubleMark ? `${zhs[index] || ''}评` : '评卷员'; const userScore = user.scores.map( (item) => `${item.subNumber}:${item.score}分` ); const lineScoreCount = 10; const groupCount = Math.ceil(userScore.length / lineScoreCount); const groups: string[] = []; for (let i = 0; i < groupCount; i++) { groups.push( userScore .slice(i * lineScoreCount, (i + 1) * lineScoreCount) .join(',') ); } offsetY += trackInfoLineHeight; dataArr.push({ type: 'text', option: { x: area.x, y: area.y + offsetY, text: `${prename}:${user.userName},评分:`, fontSize: trackInfoFontSize, color: user.color, }, }); groups.forEach((group) => { offsetY += 20; dataArr.push({ type: 'text', option: { x: area.x, y: area.y + offsetY, text: group, fontSize: trackInfoFontSize, color: user.color, }, }); }); }); const score = calcSum(groupQuestions.map((item) => item.score || 0)); const maxScore = calcSum(groupQuestions.map((item) => item.maxScore)); const tCont = `得分:${score},满分:${maxScore}`; const tContLen = strGbLen(tCont) / 2; dataArr.push({ type: 'text', option: { x: area.x + area.w - Math.ceil(tContLen * 30), y: area.y, text: tCont, fontSize: 30, }, }); }); // 其他试题 otherQuestions.forEach((question) => { const areas = parseQuestionAreas([question]); if (!areas.length) return; const area = { ...areas[0] }; const imgIndex = area.i - 1; if (!dataList[imgIndex]) { dataList[imgIndex] = []; } const img = images[imgIndex] as ImageItem; area.x *= img.width; area.y *= img.height; area.w *= img.width; const dataArr = dataList[imgIndex]; const userMap: UserMapType = {}; const isArbitration = Boolean(question.headerTrack?.length); const tList = isArbitration ? (question.headerTrack as Track[]) : question.trackList; tList.forEach((track) => { if (!userMap[track.userId]) { userMap[track.userId] = { userId: track.userId, userName: track.userName, color: track.color || '', scores: [], }; } userMap[track.userId].scores.push({ score: track.score, subNumber: track.subNumber, }); }); const isDoubleMark = Object.values(userMap).length > 1; Object.values(userMap).forEach((user, index) => { const zhs = ['一', '二', '三']; let prename = ''; if (isArbitration) { prename = '仲裁'; } else { prename = isDoubleMark ? `${zhs[index] || ''}评` : '评卷员'; } const userScore = calcSum(user.scores.map((item) => item.score)); const content = `${prename}:${user.userName},评分:${userScore}`; dataArr.push({ type: 'text', option: { x: area.x, y: area.y + index * trackInfoLineHeight, text: content, fontSize: trackInfoFontSize, color: user.color, }, }); }); const tCont = `得分:${question.score},满分:${question.maxScore}`; const tContLen = strGbLen(tCont) / 2; dataArr.push({ type: 'text', option: { x: area.x + area.w - Math.ceil(tContLen * 30), y: area.y, text: tCont, fontSize: 30, }, }); }); return dataList; } function getTotalTrack(image: ImageItem): DrawTrackItem { const totalScore = rawTask.markerScore || 0; const objectiveScore = rawTask.objectiveScore || 0; const subjectiveScore = totalScore - objectiveScore; return { type: 'text', option: { x: 0.15 * image.width, y: 0.01 * image.height, text: `总分:${totalScore},主观题得分:${subjectiveScore},客观题得分:${objectiveScore}`, fontSize: 40, }, }; } // 获取属于填空题的试题号 function getFillLines() { const questions: Record = {}; cardData.forEach((page) => { page.columns.forEach((column) => { column.elements.forEach((element) => { if (element.type !== 'FILL_LINE') return; if (!questions[element.topicNo]) questions[element.topicNo] = []; for (let i = 0; i < element.questionsCount; i++) { questions[element.topicNo].push( `${element.topicNo}_${element.startNumber + i}` ); } }); }); }); return questions; } function parseQuestionAreas(questions: QuestionItem[]) { if (!questions.length || !cardData?.length) return []; const pictureConfigs: QuestionArea[] = []; const structs = questions.map( (item) => `${item.mainNumber}_${item.subNumber}` ); cardData.forEach((page, pindex) => { page.exchange.answer_area.forEach((area) => { const [x, y, w, h] = area.area; const qStruct = `${area.main_number}_${area.sub_number}`; const pConfig: QuestionArea = { i: pindex + 1, x, y, w, h, qStruct, }; if (typeof area.sub_number === 'number') { if (!structs.includes(qStruct)) return; pictureConfigs.push(pConfig); return; } // 复合区域处理,比如填空题,多个小题合并为一个区域 if (typeof area.sub_number === 'string') { const areaStructs = area.sub_number .split(',') .map((subNumber) => `${area.main_number}_${subNumber}`); if ( structs.some((struct) => areaStructs.includes(struct)) && !pictureConfigs.find((item) => item.qStruct === qStruct) ) { pictureConfigs.push(pConfig); } } }); }); // console.log(pictureConfigs); // 合并相邻区域 pictureConfigs.sort((a, b) => { return a.i - b.i || a.x - b.x || a.y - b.y; }); const combinePictureConfigList: QuestionArea[] = []; let prevConfig = {} as QuestionArea; pictureConfigs.forEach((item, index) => { if (!index) { prevConfig = { ...item }; combinePictureConfigList.push(prevConfig); return; } const elasticRate = 0.01; if ( prevConfig.i === item.i && prevConfig.y + prevConfig.h + elasticRate >= item.y && prevConfig.w === item.w && prevConfig.x === item.x ) { prevConfig.h = item.y + item.h - prevConfig.y; } else { prevConfig = { ...item }; combinePictureConfigList.push(prevConfig); } }); // console.log(combinePictureConfigList); return combinePictureConfigList; } // mark detail ----- end-> // answer tag ----- start-> // 解析客观题答案展示位置 function paserRecogData(imageIndex: number): DrawTrackItem[] { if (!recogDatas.length || !recogDatas[imageIndex]) return []; const recogData: PaperRecogData = JSON.parse( window.atob(recogDatas[imageIndex]) ); const answerTags: DrawTrackItem[] = []; recogData.question.forEach((question) => { question.fill_result.forEach((result) => { const fillPositions = result.fill_position.map((pos) => { return pos.split(',').map((n) => Number(n)); }); const offsetLt = result.fill_size.map((item) => item * 0.4); const tagLeft = maxNum(fillPositions.map((pos) => pos[0])) + result.fill_size[0] - offsetLt[0]; const tagTop = fillPositions[0][1] - offsetLt[1]; const { answer, isRight } = answerMap[`${result.main_number}_${result.sub_number}`] || {}; answerTags.push({ type: 'text', option: { x: tagLeft, y: tagTop, text: answer || '', color: isRight ? '#05b575' : '#f53f3f', }, }); }); }); return answerTags; } // answer tag ----- end-> return { runTask, getTrackTask, getTrackTaskDetail, addLog, }; }