feat(tms): 完成课程考试自测等页面与接口对接
Co-authored-by: Cursor <cursoragent@cursor.com>
| | |
| | | import type { CourseItem, CoursePageQuery, PaperOption } from '#/views/x/tms/course/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { |
| | | mockDeleteCourse, |
| | | mockGetCourse, |
| | | mockPaperOptions, |
| | | mockQueryCourses, |
| | | mockSaveCourse, |
| | | mockSetCourseEnabled, |
| | | } from '#/views/x/tms/course/mock'; |
| | | |
| | | const USE_MOCK = true; |
| | | /** 正式路径:/api/tms/course/** */ |
| | | const prefix = '/api/tms/course'; |
| | | |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getCourseList(params: CoursePageQuery) { |
| | | if (USE_MOCK) return mockQueryCourses(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | return unwrapData<{ list: CourseItem[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/list`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getCourseInfo(id: string) { |
| | | if (USE_MOCK) return mockGetCourse(id); |
| | | return defHttp.get<CourseItem>({ url: `${prefix}/${id}` }); |
| | | return unwrapData<CourseItem>(defHttp.get({ url: `${prefix}/${id}` })); |
| | | } |
| | | |
| | | export function getCoursePaperOptions() { |
| | | if (USE_MOCK) return mockPaperOptions(); |
| | | return defHttp.get<PaperOption[]>({ url: `${prefix}/paper-options` }); |
| | | return unwrapData<PaperOption[]>(defHttp.get({ url: `${prefix}/paper-options` })); |
| | | } |
| | | |
| | | export function createCourse(data: Partial<CourseItem>) { |
| | | if (USE_MOCK) return mockSaveCourse(data); |
| | | return defHttp.post({ url: prefix, data }); |
| | | return unwrapData<string>(defHttp.post({ url: prefix, data })); |
| | | } |
| | | |
| | | export function updateCourse(data: Partial<CourseItem> & { id: string }) { |
| | | if (USE_MOCK) return mockSaveCourse(data); |
| | | return defHttp.put({ url: `${prefix}/${data.id}`, data }); |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data })); |
| | | } |
| | | |
| | | export function setCourseEnabled(id: string, enabled: '0' | '1') { |
| | | if (USE_MOCK) return mockSetCourseEnabled(id, enabled); |
| | | return defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } }); |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } })); |
| | | } |
| | | |
| | | export function deleteCourse(id: string) { |
| | | if (USE_MOCK) return mockDeleteCourse(id); |
| | | return defHttp.delete({ url: `${prefix}/${id}` }); |
| | | return unwrapData(defHttp.delete({ url: `${prefix}/${id}` })); |
| | | } |
| | |
| | | import type { EvalModeItem, EvalModePageQuery } from '#/views/x/tms/evalMode/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { |
| | | mockDeleteEvalMode, |
| | | mockGetEvalMode, |
| | | mockQueryEvalModes, |
| | | mockSaveEvalMode, |
| | | mockSetEvalModeEnabled, |
| | | } from '#/views/x/tms/evalMode/mock'; |
| | | |
| | | const USE_MOCK = true; |
| | | /** 正式路径:/api/tms/eval-mode/** */ |
| | | const prefix = '/api/tms/eval-mode'; |
| | | |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getEvalModeList(params: EvalModePageQuery) { |
| | | if (USE_MOCK) return mockQueryEvalModes(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | return unwrapData<{ list: EvalModeItem[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/list`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getEvalModeInfo(id: string) { |
| | | if (USE_MOCK) return mockGetEvalMode(id); |
| | | return defHttp.get<EvalModeItem>({ url: `${prefix}/${id}` }); |
| | | return unwrapData<EvalModeItem>(defHttp.get({ url: `${prefix}/${id}` })); |
| | | } |
| | | |
| | | export function createEvalMode(data: Partial<EvalModeItem>) { |
| | | if (USE_MOCK) return mockSaveEvalMode(data); |
| | | return defHttp.post({ url: prefix, data }); |
| | | return unwrapData<string>(defHttp.post({ url: prefix, data })); |
| | | } |
| | | |
| | | export function updateEvalMode(data: Partial<EvalModeItem> & { id: string }) { |
| | | if (USE_MOCK) return mockSaveEvalMode(data); |
| | | return defHttp.put({ url: `${prefix}/${data.id}`, data }); |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data })); |
| | | } |
| | | |
| | | export function setEvalModeEnabled(id: string, enabled: '0' | '1') { |
| | | if (USE_MOCK) return mockSetEvalModeEnabled(id, enabled); |
| | | return defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } }); |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } })); |
| | | } |
| | | |
| | | export function deleteEvalMode(id: string) { |
| | | if (USE_MOCK) return mockDeleteEvalMode(id); |
| | | return defHttp.delete({ url: `${prefix}/${id}` }); |
| | | return unwrapData(defHttp.delete({ url: `${prefix}/${id}` })); |
| | | } |
| | |
| | | import type { |
| | | GradeExamDetail, |
| | | GradeExamListItem, |
| | | GradeSessionListItem, |
| | | GradeSessionPageQuery, |
| | | GradeSubmitPayload, |
| | | } from '#/views/x/tms/examGrade/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { |
| | | mockGetGradeExamDetail, |
| | | mockGetGradeSession, |
| | | mockQueryGradeExams, |
| | | mockQueryGradeSessions, |
| | | mockSubmitGrade, |
| | | } from '#/views/x/tms/examGrade/mock'; |
| | | |
| | | /** 后端未就绪前走 mock;联调后改为 false */ |
| | | const USE_MOCK = true; |
| | | const prefix = '/api/tms/exam-grade'; |
| | | |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getGradeSessionList(params: GradeSessionPageQuery) { |
| | | if (USE_MOCK) return mockQueryGradeSessions(params); |
| | | return defHttp.get({ url: `${prefix}/sessions`, params }); |
| | | return unwrapData<{ list: GradeSessionListItem[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/sessions`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getGradeSessionInfo(id: string) { |
| | | if (USE_MOCK) return mockGetGradeSession(id); |
| | | return defHttp.get<GradeSessionListItem>({ url: `${prefix}/sessions/${id}` }); |
| | | return unwrapData<GradeSessionListItem>(defHttp.get({ url: `${prefix}/sessions/${id}` })); |
| | | } |
| | | |
| | | export function getGradeExamList(sessionId: string) { |
| | | if (USE_MOCK) return mockQueryGradeExams(sessionId); |
| | | return defHttp.get({ url: `${prefix}/sessions/${sessionId}/exams` }); |
| | | return unwrapData<GradeExamListItem[]>( |
| | | defHttp.get({ url: `${prefix}/sessions/${sessionId}/exams` }), |
| | | ); |
| | | } |
| | | |
| | | export function getGradeExamDetail(examId: string) { |
| | | if (USE_MOCK) return mockGetGradeExamDetail(examId); |
| | | return defHttp.get<GradeExamDetail>({ url: `${prefix}/exams/${examId}` }); |
| | | return unwrapData<GradeExamDetail>(defHttp.get({ url: `${prefix}/exams/${examId}` })); |
| | | } |
| | | |
| | | export function submitGrade(data: GradeSubmitPayload) { |
| | | if (USE_MOCK) return mockSubmitGrade(data); |
| | | return defHttp.post({ url: `${prefix}/exams/${data.examId}/submit`, data }); |
| | | return unwrapData<{ |
| | | examId: string; |
| | | subjectiveScore?: number; |
| | | totalScore?: number; |
| | | passFlag?: string; |
| | | gradeStatus?: string; |
| | | nextExamId?: string; |
| | | }>(defHttp.post({ url: `${prefix}/exams/${data.examId}/submit`, data })); |
| | | } |
| | |
| | | import type { ExamPaperView, ExamScoreDetail, ExamScorePageQuery } from '#/views/x/tms/examScore/types'; |
| | | import type { ExamPaperView, ExamScoreDetail, ExamScoreDetailRow, ExamScorePageQuery, ExamScoreSummaryItem } from '#/views/x/tms/examScore/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { |
| | | mockGetExamPaperView, |
| | | mockGetExamScoreDetail, |
| | | mockQueryExamScores, |
| | | } from '#/views/x/tms/examScore/mock'; |
| | | |
| | | /** 后端未就绪前走 mock;联调后改为 false */ |
| | | const USE_MOCK = true; |
| | | const prefix = '/api/tms/exam-score'; |
| | | |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getExamScoreList(params: ExamScorePageQuery) { |
| | | if (USE_MOCK) return mockQueryExamScores(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | return unwrapData<{ list: ExamScoreSummaryItem[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/list`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getExamScoreDetail(id: string) { |
| | | if (USE_MOCK) return mockGetExamScoreDetail(id); |
| | | return defHttp.get<ExamScoreDetail>({ url: `${prefix}/${id}` }); |
| | | return unwrapData<ExamScoreDetail>(defHttp.get({ url: `${prefix}/${id}` })); |
| | | } |
| | | |
| | | export function getExamPaperView(examId: string) { |
| | | if (USE_MOCK) return mockGetExamPaperView(examId); |
| | | return defHttp.get<ExamPaperView>({ url: `${prefix}/paper/${examId}` }); |
| | | return unwrapData<ExamPaperView>(defHttp.get({ url: `${prefix}/paper/${examId}` })); |
| | | } |
| | | |
| | | export function exportExamScores(ids: string[]) { |
| | | return unwrapData<ExamScoreDetailRow[]>(defHttp.post({ url: `${prefix}/export`, data: { ids } })); |
| | | } |
| | |
| | | import type { |
| | | MyPaperListItem, |
| | | MyPaperPageQuery, |
| | | OnlineExamDetail, |
| | | OnlineExamPaper, |
| | | OnlineExamSubmitResult, |
| | | } from '#/views/x/tms/onlineExam/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { |
| | | mockGetMyPaperDetail, |
| | | mockQueryMyPapers, |
| | | mockStartExam, |
| | | mockSubmitExam, |
| | | } from '#/views/x/tms/onlineExam/mock'; |
| | | |
| | | /** 后端未就绪前走 mock;联调后改为 false */ |
| | | const USE_MOCK = true; |
| | | const prefix = '/api/tms/online-exam'; |
| | | |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getMyPaperList(params: MyPaperPageQuery) { |
| | | if (USE_MOCK) return mockQueryMyPapers(params); |
| | | return defHttp.get({ url: `${prefix}/my-papers`, params }); |
| | | return unwrapData<{ list: MyPaperListItem[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/my-papers`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getMyPaperDetail(id: string) { |
| | | if (USE_MOCK) return mockGetMyPaperDetail(id); |
| | | return defHttp.get<OnlineExamDetail>({ url: `${prefix}/my-papers/${id}` }); |
| | | return unwrapData<OnlineExamDetail>(defHttp.get({ url: `${prefix}/my-papers/${id}` })); |
| | | } |
| | | |
| | | export function getExamReview(examId: string) { |
| | | return unwrapData<OnlineExamDetail>(defHttp.get({ url: `${prefix}/exams/${examId}` })); |
| | | } |
| | | |
| | | export function startOnlineExam(myPaperId: string) { |
| | | if (USE_MOCK) return mockStartExam(myPaperId); |
| | | return defHttp.post<OnlineExamPaper>({ url: `${prefix}/start`, data: { myPaperId } }); |
| | | return unwrapData<OnlineExamPaper>( |
| | | defHttp.post({ url: `${prefix}/start`, data: { myPaperId } }, { errorMessageMode: 'none' }), |
| | | ); |
| | | } |
| | | |
| | | export function submitOnlineExam(examId: string, data: { score: number; answers: Record<string, string | string[]> }) { |
| | | if (USE_MOCK) return mockSubmitExam(examId, data.score); |
| | | return defHttp.post({ url: `${prefix}/${examId}/submit`, data }); |
| | | export function saveOnlineExam(examId: string, answers: Record<string, string | string[]>) { |
| | | return unwrapData( |
| | | defHttp.post({ url: `${prefix}/${examId}/save`, data: { answers } }, { errorMessageMode: 'none' }), |
| | | ); |
| | | } |
| | | |
| | | export function submitOnlineExam(examId: string, answers: Record<string, string | string[]>) { |
| | | return unwrapData<OnlineExamSubmitResult>( |
| | | defHttp.post({ url: `${prefix}/${examId}/submit`, data: { answers } }, { errorMessageMode: 'none' }), |
| | | ); |
| | | } |
| | |
| | | import type { QuestionBankOption, QuestionEntity, QuestionPageQuery } from '#/views/x/tms/question/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { |
| | | mockCreateQuestion, |
| | | mockDeleteQuestion, |
| | | mockGetAdmins, |
| | | mockGetBanks, |
| | | mockGetQuestion, |
| | | mockQueryQuestions, |
| | | mockUpdateQuestion, |
| | | } from '#/views/x/tms/question/mock'; |
| | | |
| | | /** |
| | | * 后端 API 未就绪前走本地 mock;联调 jnpf-tms 后改为 false。 |
| | | * 正式路径约定:/api/tms/question/** |
| | | */ |
| | | const USE_MOCK = true; |
| | | |
| | | /** 正式路径:/api/tms/question/** */ |
| | | const prefix = '/api/tms/question'; |
| | | |
| | | /** defHttp 成功时返回整包 ActionResult,页面需要裸 data */ |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getQuestionBanks() { |
| | | if (USE_MOCK) return mockGetBanks(); |
| | | return defHttp.get<QuestionBankOption[]>({ url: `${prefix}/banks` }); |
| | | return unwrapData<QuestionBankOption[]>(defHttp.get({ url: `${prefix}/banks` })); |
| | | } |
| | | |
| | | export function getQuestionAdmins() { |
| | | if (USE_MOCK) return mockGetAdmins(); |
| | | return defHttp.get<{ id: string; fullName: string }[]>({ url: `${prefix}/admins` }); |
| | | return unwrapData<{ id: string; fullName: string }[]>(defHttp.get({ url: `${prefix}/admins` })); |
| | | } |
| | | |
| | | export function getQuestionList(params: QuestionPageQuery) { |
| | | if (USE_MOCK) return mockQueryQuestions(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | return unwrapData<{ list: QuestionEntity[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/list`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getQuestionInfo(id: string) { |
| | | if (USE_MOCK) return mockGetQuestion(id); |
| | | return defHttp.get<QuestionEntity>({ url: `${prefix}/${id}` }); |
| | | return unwrapData<QuestionEntity>(defHttp.get({ url: `${prefix}/${id}` })); |
| | | } |
| | | |
| | | export function createQuestion(data: QuestionEntity) { |
| | | if (USE_MOCK) return mockCreateQuestion(data); |
| | | return defHttp.post({ url: prefix, data }); |
| | | return unwrapData<string>(defHttp.post({ url: prefix, data })); |
| | | } |
| | | |
| | | export function updateQuestion(data: QuestionEntity) { |
| | | if (USE_MOCK) return mockUpdateQuestion(data); |
| | | return defHttp.put({ url: `${prefix}/${data.id}`, data }); |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data })); |
| | | } |
| | | |
| | | export function deleteQuestion(id: string) { |
| | | if (USE_MOCK) return mockDeleteQuestion(id); |
| | | return defHttp.delete({ url: `${prefix}/${id}` }); |
| | | /** 废弃 = 逻辑删除 */ |
| | | export function invalidateQuestion(id: string) { |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${id}/invalidate` })); |
| | | } |
| | |
| | | import type { TmsRecordDetail, TmsRecordPageQuery } from '#/views/x/tms/record/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { |
| | | mockArchiveRecords, |
| | | mockDeleteRecords, |
| | | mockGetRecordDetail, |
| | | mockQueryRecords, |
| | | mockUpdateRecord, |
| | | } from '#/views/x/tms/record/mock'; |
| | | |
| | | /** 后端未就绪前走 mock;联调后改为 false */ |
| | | const USE_MOCK = true; |
| | | /** 正式路径:/api/tms/record/** */ |
| | | const prefix = '/api/tms/record'; |
| | | |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getTmsRecordList(params: TmsRecordPageQuery) { |
| | | if (USE_MOCK) return mockQueryRecords(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | return unwrapData<{ list: any[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/list`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getTmsRecordDetail(id: string) { |
| | | if (USE_MOCK) return mockGetRecordDetail(id); |
| | | return defHttp.get<TmsRecordDetail>({ url: `${prefix}/${id}` }); |
| | | return unwrapData<TmsRecordDetail>(defHttp.get({ url: `${prefix}/${id}` })); |
| | | } |
| | | |
| | | export function updateTmsRecord(id: string, data: Record<string, any>) { |
| | | if (USE_MOCK) return mockUpdateRecord(id, data); |
| | | return defHttp.put({ url: `${prefix}/${id}`, data }); |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${id}`, data })); |
| | | } |
| | | |
| | | export function archiveTmsRecords(ids: string[]) { |
| | | if (USE_MOCK) return mockArchiveRecords(ids); |
| | | return defHttp.post({ url: `${prefix}/archive`, data: { ids } }); |
| | | return unwrapData<number>(defHttp.post({ url: `${prefix}/archive`, data: { ids } })); |
| | | } |
| | | |
| | | export function deleteTmsRecords(ids: string[]) { |
| | | if (USE_MOCK) return mockDeleteRecords(ids); |
| | | return defHttp.delete({ url: `${prefix}`, data: { ids } }); |
| | | return unwrapData(defHttp.delete({ url: `${prefix}`, data: { ids } })); |
| | | } |
| | |
| | | import type { QuestionBankOption } from '#/views/x/tms/question/types'; |
| | | import type { SelfTestPaper, SelfTestStartPayload } from '#/views/x/tms/selfTest/types'; |
| | | import type { |
| | | SelfTestPaper, |
| | | SelfTestStartPayload, |
| | | SelfTestSubmitPayload, |
| | | SelfTestSubmitResult, |
| | | SelfTestRecordItem, |
| | | } from '#/views/x/tms/selfTest/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { getQuestionBanks } from '#/api/x/tms/question'; |
| | | import { mockStartSelfTest } from '#/views/x/tms/selfTest/mock'; |
| | | |
| | | /** 后端未就绪前走 mock;联调后改为 false */ |
| | | const USE_MOCK = true; |
| | | /** 正式路径:/api/tms/self-test/** */ |
| | | const prefix = '/api/tms/self-test'; |
| | | |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getSelfTestBanks() { |
| | | return getQuestionBanks() as Promise<QuestionBankOption[]>; |
| | | } |
| | | |
| | | export function startSelfTest(data: SelfTestStartPayload) { |
| | | if (USE_MOCK) return mockStartSelfTest(data); |
| | | return defHttp.post<SelfTestPaper>({ url: `${prefix}/start`, data }); |
| | | return unwrapData<SelfTestPaper>(defHttp.post({ url: `${prefix}/start`, data })); |
| | | } |
| | | |
| | | /** 暂存作答(不交卷,便于继续考试) */ |
| | | export function saveSelfTest(paperId: string, data: SelfTestSubmitPayload) { |
| | | return unwrapData<string>(defHttp.post({ url: `${prefix}/${paperId}/save`, data })); |
| | | } |
| | | |
| | | export function submitSelfTest(paperId: string, data: SelfTestSubmitPayload) { |
| | | return unwrapData<SelfTestSubmitResult>(defHttp.post({ url: `${prefix}/${paperId}/submit`, data })); |
| | | } |
| | | |
| | | export function getSelfTestList(params?: { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | bankId?: string; |
| | | testStatus?: string; |
| | | startTimeBegin?: string; |
| | | startTimeEnd?: string; |
| | | }) { |
| | | return unwrapData<{ list: SelfTestRecordItem[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/list`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getSelfTestInfo(id: string) { |
| | | return unwrapData<SelfTestPaper>(defHttp.get({ url: `${prefix}/${id}` })); |
| | | } |
| | |
| | | import type { SignModeItem, SignModePageQuery } from '#/views/x/tms/signMode/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { |
| | | mockDeleteSignMode, |
| | | mockGetSignMode, |
| | | mockQuerySignModes, |
| | | mockSaveSignMode, |
| | | mockSetSignModeEnabled, |
| | | } from '#/views/x/tms/signMode/mock'; |
| | | |
| | | const USE_MOCK = true; |
| | | /** 正式路径:/api/tms/sign-mode/** */ |
| | | const prefix = '/api/tms/sign-mode'; |
| | | |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getSignModeList(params: SignModePageQuery) { |
| | | if (USE_MOCK) return mockQuerySignModes(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | return unwrapData<{ list: SignModeItem[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/list`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getSignModeInfo(id: string) { |
| | | if (USE_MOCK) return mockGetSignMode(id); |
| | | return defHttp.get<SignModeItem>({ url: `${prefix}/${id}` }); |
| | | return unwrapData<SignModeItem>(defHttp.get({ url: `${prefix}/${id}` })); |
| | | } |
| | | |
| | | export function createSignMode(data: Partial<SignModeItem>) { |
| | | if (USE_MOCK) return mockSaveSignMode(data); |
| | | return defHttp.post({ url: prefix, data }); |
| | | return unwrapData<string>(defHttp.post({ url: prefix, data })); |
| | | } |
| | | |
| | | export function updateSignMode(data: Partial<SignModeItem> & { id: string }) { |
| | | if (USE_MOCK) return mockSaveSignMode(data); |
| | | return defHttp.put({ url: `${prefix}/${data.id}`, data }); |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data })); |
| | | } |
| | | |
| | | export function setSignModeEnabled(id: string, enabled: '0' | '1') { |
| | | if (USE_MOCK) return mockSetSignModeEnabled(id, enabled); |
| | | return defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } }); |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } })); |
| | | } |
| | | |
| | | export function deleteSignMode(id: string) { |
| | | if (USE_MOCK) return mockDeleteSignMode(id); |
| | | return defHttp.delete({ url: `${prefix}/${id}` }); |
| | | return unwrapData(defHttp.delete({ url: `${prefix}/${id}` })); |
| | | } |
| | |
| | | import type { TrainArchiveDetail } from '#/views/x/tms/trainArchive/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { mockGetMyTrainArchive } from '#/views/x/tms/trainArchive/mock'; |
| | | |
| | | /** 后端未就绪前走 mock;联调后改为 false */ |
| | | const USE_MOCK = true; |
| | | /** 正式路径:/api/tms/train-archive/** */ |
| | | const prefix = '/api/tms/train-archive'; |
| | | |
| | | export function getMyTrainArchive(user?: { |
| | | userId?: string; |
| | | userName?: string; |
| | | userAccount?: string; |
| | | organizeName?: string; |
| | | positionName?: string; |
| | | }) { |
| | | if (USE_MOCK) return mockGetMyTrainArchive(user); |
| | | return defHttp.get<TrainArchiveDetail>({ url: `${prefix}/mine` }); |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | /** 当前登录用户的个人培训档案 */ |
| | | export function getMyTrainArchive() { |
| | | return unwrapData<TrainArchiveDetail>(defHttp.get({ url: `${prefix}/mine` })); |
| | | } |
| | |
| | | import type { TrainModeItem, TrainModePageQuery } from '#/views/x/tms/trainMode/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { |
| | | mockDeleteTrainMode, |
| | | mockGetTrainMode, |
| | | mockQueryTrainModes, |
| | | mockSaveTrainMode, |
| | | mockSetTrainModeEnabled, |
| | | } from '#/views/x/tms/trainMode/mock'; |
| | | |
| | | const USE_MOCK = true; |
| | | /** 正式路径:/api/tms/train-mode/** */ |
| | | const prefix = '/api/tms/train-mode'; |
| | | |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getTrainModeList(params: TrainModePageQuery) { |
| | | if (USE_MOCK) return mockQueryTrainModes(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | return unwrapData<{ list: TrainModeItem[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/list`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getTrainModeInfo(id: string) { |
| | | if (USE_MOCK) return mockGetTrainMode(id); |
| | | return defHttp.get<TrainModeItem>({ url: `${prefix}/${id}` }); |
| | | return unwrapData<TrainModeItem>(defHttp.get({ url: `${prefix}/${id}` })); |
| | | } |
| | | |
| | | export function createTrainMode(data: Partial<TrainModeItem>) { |
| | | if (USE_MOCK) return mockSaveTrainMode(data); |
| | | return defHttp.post({ url: prefix, data }); |
| | | return unwrapData<string>(defHttp.post({ url: prefix, data })); |
| | | } |
| | | |
| | | export function updateTrainMode(data: Partial<TrainModeItem> & { id: string }) { |
| | | if (USE_MOCK) return mockSaveTrainMode(data); |
| | | return defHttp.put({ url: `${prefix}/${data.id}`, data }); |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data })); |
| | | } |
| | | |
| | | export function setTrainModeEnabled(id: string, enabled: '0' | '1') { |
| | | if (USE_MOCK) return mockSetTrainModeEnabled(id, enabled); |
| | | return defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } }); |
| | | return unwrapData(defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } })); |
| | | } |
| | | |
| | | export function deleteTrainMode(id: string) { |
| | | if (USE_MOCK) return mockDeleteTrainMode(id); |
| | | return defHttp.delete({ url: `${prefix}/${id}` }); |
| | | return unwrapData(defHttp.delete({ url: `${prefix}/${id}` })); |
| | | } |
| | |
| | | } from '#/views/x/tms/trainRecord/types'; |
| | | |
| | | import { defHttp } from '#/api/request'; |
| | | import { mockGetTrainRecordCatalog, mockQueryTrainRecords } from '#/views/x/tms/trainRecord/mock'; |
| | | |
| | | /** 后端未就绪前走 mock;联调后改为 false */ |
| | | const USE_MOCK = true; |
| | | const prefix = '/api/tms/train-record'; |
| | | |
| | | async function unwrapData<T>(promise: Promise<any>): Promise<T> { |
| | | const res = await promise; |
| | | if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) { |
| | | return res.data as T; |
| | | } |
| | | return res as T; |
| | | } |
| | | |
| | | export function getTrainRecordList(params: TrainRecordPageQuery) { |
| | | if (USE_MOCK) return mockQueryTrainRecords(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | return unwrapData<{ list: any[]; pagination: Record<string, any> }>( |
| | | defHttp.get({ url: `${prefix}/list`, params }), |
| | | ); |
| | | } |
| | | |
| | | export function getTrainRecordCatalog(params: TrainRecordCatalogQuery) { |
| | | if (USE_MOCK) return mockGetTrainRecordCatalog(params); |
| | | return defHttp.get<TrainRecordCatalogDetail>({ url: `${prefix}/${params.recordId}/catalog`, params }); |
| | | const { recordId, startDate, endDate } = params; |
| | | return unwrapData<TrainRecordCatalogDetail>( |
| | | defHttp.get({ |
| | | url: `${prefix}/${recordId}/catalog`, |
| | | params: { startDate, endDate }, |
| | | }), |
| | | ); |
| | | } |
| | |
| | | |
| | | /** |
| | | * 列表可由后台菜单挂载:pageAddress = x/tms/question/index,路由 /tms/question |
| | | * 创建/编辑已挂到 basicRoutes(不依赖菜单) |
| | | * 创建/编辑/详情已挂到 basicRoutes(不依赖菜单) |
| | | */ |
| | | const tmsQuestionRoutes: RouteRecordRaw[] = []; |
| | | |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** |
| | | * 自我检测可由后台菜单挂载: |
| | | * 路由地址 /tms/selfTest |
| | | * 页面地址 x/tms/selfTest/index |
| | | * 答题页已挂 basicRoutes:/tms/selfTest/exam |
| | | * 答题/记录/详情页已挂 basicRoutes。 |
| | | */ |
| | | const tmsSelfTestRoutes: RouteRecordRaw[] = []; |
| | | const tmsSelfTestRoutes: import('vue-router').RouteRecordRaw[] = []; |
| | | |
| | | export default tmsSelfTestRoutes; |
| | |
| | | getCoursePaperOptions, |
| | | updateCourse, |
| | | } from '#/api/x/tms/course'; |
| | | |
| | | import { useBaseStore } from '#/store'; |
| | | import { |
| | | CATEGORY_OPTIONS, |
| | | ENABLE_OPTIONS, |
| | | EVAL_MODE_OPTIONS, |
| | | TRAIN_MODE_OPTIONS, |
| | | evalModeNeedPaper, |
| | | } from './constants'; |
| | | TMS_DIC, |
| | | TMS_DIC_FIELD_NAMES, |
| | | loadTmsDic, |
| | | } from '#/views/x/tms/shared/dic'; |
| | | |
| | | import { evalModeNeedPaper } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsCourseForm' }); |
| | | |
| | | const emit = defineEmits(['register', 'reload']); |
| | | const { createMessage } = useMessage(); |
| | | const baseStore = useBaseStore(); |
| | | |
| | | const state = reactive({ id: '' }); |
| | | const { id } = toRefs(state); |
| | |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: CATEGORY_OPTIONS, |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: TRAIN_MODE_OPTIONS, |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: EVAL_MODE_OPTIONS, |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | onChange: (val: string) => syncPaperRequired(val), |
| | | }, |
| | | }, |
| | |
| | | label: '启用状态', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | } |
| | | } |
| | | |
| | | async function loadDicOptions() { |
| | | const [category, trainMode, evalMode, enable] = await Promise.all([ |
| | | loadTmsDic(baseStore, TMS_DIC.courseCategory), |
| | | loadTmsDic(baseStore, TMS_DIC.trainMode), |
| | | loadTmsDic(baseStore, TMS_DIC.evalMode), |
| | | loadTmsDic(baseStore, TMS_DIC.enableStatus), |
| | | ]); |
| | | updateSchema([ |
| | | { |
| | | field: 'category', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: category, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'trainMode', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: trainMode, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'evalMode', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: evalMode, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | onChange: (val: string) => syncPaperRequired(val), |
| | | }, |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: enable, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | ]); |
| | | } |
| | | |
| | | async function loadPaperOptions() { |
| | | const list = await getCoursePaperOptions(); |
| | | updateSchema({ |
| | |
| | | resetFields(); |
| | | state.id = data?.id || ''; |
| | | try { |
| | | await loadPaperOptions(); |
| | | await Promise.all([loadDicOptions(), loadPaperOptions()]); |
| | | if (state.id) { |
| | | const info = await getCourseInfo(state.id); |
| | | setFieldsValue(info); |
| | |
| | | export const ENABLE_OPTIONS = [ |
| | | { id: '1', fullName: '启用' }, |
| | | { id: '0', fullName: '停用' }, |
| | | ]; |
| | | |
| | | /** tmsCourseCategory */ |
| | | export const CATEGORY_OPTIONS = [ |
| | | { id: 'gmp', fullName: 'GMP' }, |
| | | { id: 'sop', fullName: 'SOP' }, |
| | | { id: 'safety', fullName: '安全' }, |
| | | { id: 'skill', fullName: '技能' }, |
| | | { id: 'other', fullName: '其他' }, |
| | | ]; |
| | | |
| | | /** 与培训方式配置编码对齐 */ |
| | | export const TRAIN_MODE_OPTIONS = [ |
| | | { id: 'onsite', fullName: '集中授课' }, |
| | | { id: 'practice', fullName: '操作授课' }, |
| | | { id: 'online', fullName: '在线学习' }, |
| | | ]; |
| | | |
| | | /** 与考核方式配置编码对齐 */ |
| | | export const EVAL_MODE_OPTIONS = [ |
| | | { id: 'quiz', fullName: '提问' }, |
| | | { id: 'practice', fullName: '现场操作' }, |
| | | { id: 'exam', fullName: '在线考试' }, |
| | | { id: 'none', fullName: '无需考核' }, |
| | | ]; |
| | | |
| | | export function labelOfEnabled(v?: string) { |
| | | return ENABLE_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| | | |
| | | export function labelOfCategory(v?: string) { |
| | | return CATEGORY_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| | | |
| | | export function labelOfTrainMode(v?: string) { |
| | | return TRAIN_MODE_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| | | |
| | | export function labelOfEvalMode(v?: string) { |
| | | return EVAL_MODE_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| | | |
| | | /** 在线考试时关联试卷建议必填 */ |
| | | export function evalModeNeedPaper(evalMode?: string) { |
| | | return evalMode === 'exam'; |
| | |
| | | |
| | | import type { CourseItem } from './types'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { useModal } from '@jnpf/ui/modal'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { deleteCourse, getCourseList, setCourseEnabled } from '#/api/x/tms/course'; |
| | | import { useBaseStore } from '#/store'; |
| | | import { |
| | | TMS_DIC, |
| | | TMS_DIC_FIELD_NAMES, |
| | | labelOfDic, |
| | | loadTmsDic, |
| | | type TmsDicOpt, |
| | | } from '#/views/x/tms/shared/dic'; |
| | | |
| | | import Form from './Form.vue'; |
| | | import { |
| | | CATEGORY_OPTIONS, |
| | | ENABLE_OPTIONS, |
| | | EVAL_MODE_OPTIONS, |
| | | TRAIN_MODE_OPTIONS, |
| | | labelOfCategory, |
| | | labelOfEnabled, |
| | | labelOfEvalMode, |
| | | labelOfTrainMode, |
| | | } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsCourse' }); |
| | | |
| | | const { createMessage } = useMessage(); |
| | | const baseStore = useBaseStore(); |
| | | const [registerForm, { openModal: openFormModal }] = useModal(); |
| | | |
| | | const categoryOpts = ref<TmsDicOpt[]>([]); |
| | | const trainModeOpts = ref<TmsDicOpt[]>([]); |
| | | const evalModeOpts = ref<TmsDicOpt[]>([]); |
| | | const enableOpts = ref<TmsDicOpt[]>([]); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '课程编号', dataIndex: 'courseNo', width: 140 }, |
| | |
| | | title: '课程分类', |
| | | dataIndex: 'category', |
| | | width: 100, |
| | | customRender: ({ record }) => labelOfCategory((record as CourseItem).category), |
| | | customRender: ({ record }) => labelOfDic(categoryOpts.value, (record as CourseItem).category), |
| | | }, |
| | | { |
| | | title: '培训方式', |
| | | dataIndex: 'trainMode', |
| | | width: 110, |
| | | customRender: ({ record }) => labelOfTrainMode((record as CourseItem).trainMode), |
| | | customRender: ({ record }) => labelOfDic(trainModeOpts.value, (record as CourseItem).trainMode), |
| | | }, |
| | | { |
| | | title: '考核方式', |
| | | dataIndex: 'evalMode', |
| | | width: 110, |
| | | customRender: ({ record }) => labelOfEvalMode((record as CourseItem).evalMode), |
| | | customRender: ({ record }) => labelOfDic(evalModeOpts.value, (record as CourseItem).evalMode), |
| | | }, |
| | | { |
| | | title: '关联考核试卷', |
| | |
| | | { title: '备注', dataIndex: 'remark', minWidth: 140 }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload }] = useVxeTable({ |
| | | const [registerTable, { reload, getForm }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | immediate: false, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: CATEGORY_OPTIONS, |
| | | options: categoryOpts.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: TRAIN_MODE_OPTIONS, |
| | | options: trainModeOpts.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: EVAL_MODE_OPTIONS, |
| | | options: evalModeOpts.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: ENABLE_OPTIONS, |
| | | options: enableOpts.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | ], |
| | |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | onMounted(async () => { |
| | | const [category, trainMode, evalMode, enable] = await Promise.all([ |
| | | loadTmsDic(baseStore, TMS_DIC.courseCategory), |
| | | loadTmsDic(baseStore, TMS_DIC.trainMode), |
| | | loadTmsDic(baseStore, TMS_DIC.evalMode), |
| | | loadTmsDic(baseStore, TMS_DIC.enableStatus), |
| | | ]); |
| | | categoryOpts.value = category; |
| | | trainModeOpts.value = trainMode; |
| | | evalModeOpts.value = evalMode; |
| | | enableOpts.value = enable; |
| | | getForm()?.updateSchema?.([ |
| | | { |
| | | field: 'category', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: category, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'trainMode', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: trainMode, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'evalMode', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: evalMode, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: enable, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | ]); |
| | | reload(); |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | |
| | | } |
| | | |
| | | async function handleDelete(record: CourseItem) { |
| | | if (record.enabled !== '0') { |
| | | createMessage.warning('请先停用后再删除'); |
| | | return; |
| | | } |
| | | try { |
| | | await deleteCourse(record.id); |
| | | createMessage.success('删除成功'); |
| | |
| | | |
| | | function getTableActions(record: CourseItem): ActionItem[] { |
| | | const enableLabel = record.enabled === '1' ? '停用' : '启用'; |
| | | return [ |
| | | const actions: ActionItem[] = [ |
| | | { label: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { |
| | | label: enableLabel, |
| | |
| | | onOk: handleToggleEnabled.bind(null, record), |
| | | }, |
| | | }, |
| | | { |
| | | ]; |
| | | if (record.enabled === '0') { |
| | | actions.push({ |
| | | label: '删除', |
| | | color: 'error', |
| | | modelConfirm: { |
| | | content: `确定删除培训课程「${record.courseName}」吗?`, |
| | | onOk: handleDelete.bind(null, record), |
| | | }, |
| | | }, |
| | | ]; |
| | | }); |
| | | } |
| | | return actions; |
| | | } |
| | | </script> |
| | | |
| | |
| | | </template> |
| | | <template #enabled="{ record }"> |
| | | <a-tag :color="record.enabled === '1' ? 'success' : 'default'"> |
| | | {{ labelOfEnabled(record.enabled) }} |
| | | {{ labelOfDic(enableOpts, record.enabled) }} |
| | | </a-tag> |
| | | </template> |
| | | <template #action="{ record }"> |
| | |
| | | import { BasicModal, useModalInner } from '@jnpf/ui/modal'; |
| | | |
| | | import { createEvalMode, getEvalModeInfo, updateEvalMode } from '#/api/x/tms/evalMode'; |
| | | |
| | | import { ENABLE_OPTIONS, YES_NO_OPTIONS } from './constants'; |
| | | import { useBaseStore } from '#/store'; |
| | | import { |
| | | TMS_DIC, |
| | | TMS_DIC_FIELD_NAMES, |
| | | loadTmsDic, |
| | | } from '#/views/x/tms/shared/dic'; |
| | | |
| | | defineOptions({ name: 'TmsEvalModeForm' }); |
| | | |
| | | const emit = defineEmits(['register', 'reload']); |
| | | const { createMessage } = useMessage(); |
| | | const baseStore = useBaseStore(); |
| | | |
| | | const state = reactive({ id: '' }); |
| | | const { id } = toRefs(state); |
| | |
| | | label: '需要试卷', |
| | | component: 'Select', |
| | | defaultValue: '0', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | label: '需要提问预设', |
| | | component: 'Select', |
| | | defaultValue: '0', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | label: '需要实操评分', |
| | | component: 'Select', |
| | | defaultValue: '0', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | label: '启用状态', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | }); |
| | | |
| | | const [registerModal, { closeModal, changeLoading, changeOkLoading }] = useModalInner(init); |
| | | |
| | | async function loadDicOptions() { |
| | | const [yesNo, enable] = await Promise.all([ |
| | | loadTmsDic(baseStore, TMS_DIC.yesNo), |
| | | loadTmsDic(baseStore, TMS_DIC.enableStatus), |
| | | ]); |
| | | updateSchema( |
| | | ['needPaper', 'needQuiz', 'needPractice'].map((field) => ({ |
| | | field, |
| | | componentProps: { placeholder: '请选择', options: yesNo, fieldNames: TMS_DIC_FIELD_NAMES }, |
| | | })), |
| | | ); |
| | | updateSchema({ |
| | | field: 'enabled', |
| | | componentProps: { placeholder: '请选择', options: enable, fieldNames: TMS_DIC_FIELD_NAMES }, |
| | | }); |
| | | } |
| | | |
| | | async function init(data: { id?: string }) { |
| | | changeLoading(true); |
| | |
| | | }, |
| | | }); |
| | | try { |
| | | await loadDicOptions(); |
| | | if (state.id) setFieldsValue(await getEvalModeInfo(state.id)); |
| | | } finally { |
| | | changeLoading(false); |
| | |
| | | |
| | | import type { EvalModeItem } from './types'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { useModal } from '@jnpf/ui/modal'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { deleteEvalMode, getEvalModeList, setEvalModeEnabled } from '#/api/x/tms/evalMode'; |
| | | import { useBaseStore } from '#/store'; |
| | | import { |
| | | TMS_DIC, |
| | | TMS_DIC_FIELD_NAMES, |
| | | labelOfDic, |
| | | loadTmsDic, |
| | | type TmsDicOpt, |
| | | } from '#/views/x/tms/shared/dic'; |
| | | |
| | | import Form from './Form.vue'; |
| | | import { ENABLE_OPTIONS, labelOfEnabled, labelOfYesNo } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsEvalMode' }); |
| | | |
| | | const { createMessage } = useMessage(); |
| | | const baseStore = useBaseStore(); |
| | | const [registerForm, { openModal: openFormModal }] = useModal(); |
| | | |
| | | const yesNoOpts = ref<TmsDicOpt[]>([]); |
| | | const enableOpts = ref<TmsDicOpt[]>([]); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '方式编码', dataIndex: 'modeCode', width: 120 }, |
| | |
| | | dataIndex: 'needPaper', |
| | | width: 100, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as EvalModeItem).needPaper), |
| | | customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as EvalModeItem).needPaper), |
| | | }, |
| | | { |
| | | title: '需要提问预设', |
| | | dataIndex: 'needQuiz', |
| | | width: 120, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as EvalModeItem).needQuiz), |
| | | customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as EvalModeItem).needQuiz), |
| | | }, |
| | | { |
| | | title: '需要实操评分', |
| | | dataIndex: 'needPractice', |
| | | width: 120, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as EvalModeItem).needPractice), |
| | | customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as EvalModeItem).needPractice), |
| | | }, |
| | | { |
| | | title: '状态', |
| | |
| | | { title: '备注', dataIndex: 'remark', minWidth: 200 }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload }] = useVxeTable({ |
| | | const [registerTable, { reload, getForm }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | immediate: false, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | |
| | | field: 'enabled', |
| | | label: '状态', |
| | | component: 'Select', |
| | | componentProps: { allowClear: true, placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: enableOpts.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | onMounted(async () => { |
| | | yesNoOpts.value = await loadTmsDic(baseStore, TMS_DIC.yesNo); |
| | | enableOpts.value = await loadTmsDic(baseStore, TMS_DIC.enableStatus); |
| | | getForm()?.updateSchema?.({ |
| | | field: 'enabled', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: enableOpts.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }); |
| | | reload(); |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | |
| | | } |
| | | |
| | | async function handleDelete(record: EvalModeItem) { |
| | | if (record.enabled !== '0') { |
| | | createMessage.warning('请先停用后再删除'); |
| | | return; |
| | | } |
| | | try { |
| | | await deleteEvalMode(record.id); |
| | | createMessage.success('删除成功'); |
| | |
| | | |
| | | function getTableActions(record: EvalModeItem): ActionItem[] { |
| | | const enableLabel = record.enabled === '1' ? '停用' : '启用'; |
| | | return [ |
| | | const actions: ActionItem[] = [ |
| | | { label: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { |
| | | label: enableLabel, |
| | |
| | | onOk: handleToggleEnabled.bind(null, record), |
| | | }, |
| | | }, |
| | | { |
| | | ]; |
| | | if (record.enabled === '0') { |
| | | actions.push({ |
| | | label: '删除', |
| | | color: 'error', |
| | | modelConfirm: { |
| | | content: `确定删除考核方式「${record.modeName}」吗?`, |
| | | onOk: handleDelete.bind(null, record), |
| | | }, |
| | | }, |
| | | ]; |
| | | }); |
| | | } |
| | | return actions; |
| | | } |
| | | </script> |
| | | |
| | |
| | | </template> |
| | | <template #enabled="{ record }"> |
| | | <a-tag :color="record.enabled === '1' ? 'success' : 'default'"> |
| | | {{ labelOfEnabled(record.enabled) }} |
| | | {{ labelOfDic(enableOpts, record.enabled) }} |
| | | </a-tag> |
| | | </template> |
| | | <template #action="{ record }"> |
| | |
| | | <script lang="ts" setup> |
| | | import type { GradeExamDetail, GradeExamItem } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { computed, onMounted, reactive, ref, watch } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { Modal } from 'ant-design-vue'; |
| | | |
| | | import { getGradeExamDetail, submitGrade } from '#/api/x/tms/examGrade'; |
| | | import { labelOfType } from '#/views/x/tms/question/constants'; |
| | | import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import { colorOfGradeStatus, labelOfGradeStatus } from './constants'; |
| | | import { colorOfGradeStatus, labelOfGradeStatus, loadExamGradeDics } from './constants'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsExamGradeMark' }); |
| | | |
| | |
| | | return Number(detail.value.objectiveScore || 0) + subjectiveTotal.value; |
| | | }); |
| | | |
| | | onMounted(() => { |
| | | loadDetail(); |
| | | onMounted(async () => { |
| | | await Promise.all([loadExamGradeDics(), loadQuestionDics()]); |
| | | await loadDetail(); |
| | | }); |
| | | |
| | | watch( |
| | | () => String(route.params.id || ''), |
| | | async (id, prev) => { |
| | | if (!id || id === prev) return; |
| | | Object.keys(scoreMap).forEach((key) => { |
| | | delete scoreMap[key]; |
| | | }); |
| | | await loadDetail(); |
| | | }, |
| | | ); |
| | | |
| | | async function loadDetail() { |
| | | const id = String(route.params.id || ''); |
| | |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | if (!detail.value || readonly.value) return; |
| | | if (!detail.value || readonly.value || saving.value) return; |
| | | const err = validateScores(); |
| | | if (err) { |
| | | createMessage.warning(err); |
| | | return; |
| | | } |
| | | Modal.confirm({ |
| | | title: '确认提交', |
| | | content: '提交后不可修改,确定提交吗?', |
| | | okText: '确认', |
| | | cancelText: '取消', |
| | | onOk: () => doSubmit(), |
| | | }); |
| | | } |
| | | |
| | | async function doSubmit() { |
| | | if (!detail.value || readonly.value || saving.value) return; |
| | | saving.value = true; |
| | | try { |
| | | const items = detail.value.items |
| | | .filter((x) => x.isSubjective === '1') |
| | | .map((x) => ({ id: x.id, gotScore: Number(scoreMap[x.id] || 0) })); |
| | | await submitGrade({ examId: detail.value.examId, items }); |
| | | createMessage.success(`阅卷完成,总分 ${previewTotal.value}`); |
| | | const result = await submitGrade({ examId: detail.value.examId, items }); |
| | | createMessage.success(`阅卷完成,总分 ${result?.totalScore ?? previewTotal.value}`); |
| | | goBack(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '提交失败'); |
| | |
| | | <div class="jnpf-content-wrapper-center tms-grade-center"> |
| | | <div class="jnpf-content-wrapper-content tms-grade-mark-wrap"> |
| | | <div class="mark-top"> |
| | | <div class="mark-header"> |
| | | <div class="mark-header tms-page-header"> |
| | | <div> |
| | | <div class="text-base font-medium"> |
| | | {{ detail?.paperName || '阅卷' }} · 阅卷 |
| | | </div> |
| | | <div v-if="detail" class="mt-1 text-gray-400 text-sm"> |
| | | <div class="tms-page-header__title">{{ detail?.paperName || '阅卷' }} · 阅卷</div> |
| | | <div v-if="detail" class="tms-page-header__sub"> |
| | | 考生:{{ detail.userName }} |
| | | <span v-if="detail.deptName">({{ detail.deptName }})</span> |
| | | <span class="ml-3">交卷:{{ detail.submitTime || '-' }}</span> |
| | |
| | | </span> |
| | | </div> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="goBack">返回</a-button> |
| | | <div class="tms-page-header__actions"> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | <a-button v-if="detail && !readonly" type="primary" :loading="saving" @click="handleSubmit"> |
| | | 提交阅卷 |
| | | </a-button> |
| | | </a-space> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="detail" class="score-bar"> |
| | |
| | | |
| | | import type { GradeExamListItem, GradeSessionListItem } from './types'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | import { onActivated, onMounted, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { getGradeExamList, getGradeSessionInfo } from '#/api/x/tms/examGrade'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import { colorOfGradeStatus, labelOfGradeStatus } from './constants'; |
| | | import { colorOfGradeStatus, labelOfGradeStatus, loadExamGradeDics } from './constants'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsExamGradeSession' }); |
| | | |
| | |
| | | return; |
| | | } |
| | | try { |
| | | session.value = await getGradeSessionInfo(sessionId); |
| | | reload(); |
| | | await loadExamGradeDics(); |
| | | await refreshSession(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载失败'); |
| | | router.replace('/tms/examGrade'); |
| | | } |
| | | }); |
| | | |
| | | onActivated(() => { |
| | | if (!sessionId) return; |
| | | void refreshSession(); |
| | | }); |
| | | |
| | | async function refreshSession() { |
| | | session.value = await getGradeSessionInfo(sessionId); |
| | | reload(); |
| | | } |
| | | |
| | | async function fetchList() { |
| | | return { data: await getGradeExamList(sessionId) }; |
| | | const list = await getGradeExamList(sessionId); |
| | | return { data: Array.isArray(list) ? list : [] }; |
| | | } |
| | | |
| | | function goBack() { |
| | |
| | | |
| | | function getTableActions(record: GradeExamListItem): ActionItem[] { |
| | | if (record.gradeStatus === 'pending') { |
| | | return [{ label: '阅卷', onClick: handleMark.bind(null, record) }]; |
| | | return [{ label: TMS_BTN.grade, onClick: handleMark.bind(null, record) }]; |
| | | } |
| | | return [{ label: '查看', onClick: handleMark.bind(null, record) }]; |
| | | return [{ label: TMS_BTN.detail, onClick: handleMark.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <div class="flex items-center gap-3"> |
| | | <a-button @click="goBack">返回</a-button> |
| | | <div class="tms-page-header" style="margin-bottom: 0"> |
| | | <div> |
| | | <div class="text-base font-medium"> |
| | | {{ session?.paperName || '答卷列表' }} |
| | | </div> |
| | | <div class="mt-1 text-gray-400 text-sm"> |
| | | {{ session?.taskNo }} · {{ session?.taskSubject }} |
| | | </div> |
| | | <div class="tms-page-header__title">{{ session?.paperName || '答卷列表' }}</div> |
| | | <div class="tms-page-header__sub">{{ session?.taskNo }} · {{ session?.taskSubject }}</div> |
| | | </div> |
| | | <div class="tms-page-header__actions"> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | |
| | | import type { GradeStatus } from './types'; |
| | | import { ref } from 'vue'; |
| | | |
| | | export const GRADE_STATUS_OPTIONS: { id: GradeStatus; fullName: string; color?: string }[] = [ |
| | | { id: 'auto', fullName: '无需阅卷', color: '#8c8c8c' }, |
| | | { id: 'pending', fullName: '待批改', color: '#fa8c16' }, |
| | | { id: 'graded', fullName: '已批改', color: '#52c41a' }, |
| | | ]; |
| | | import { useBaseStore } from '#/store'; |
| | | import { TMS_DIC, labelOfDic, loadTmsDic, type TmsDicOpt } from '#/views/x/tms/shared/dic'; |
| | | |
| | | const GRADE_STATUS_COLOR: Record<string, string> = { |
| | | auto: '#8c8c8c', |
| | | pending: '#fa8c16', |
| | | graded: '#52c41a', |
| | | }; |
| | | |
| | | export const GRADE_STATUS_OPTIONS = ref<TmsDicOpt[]>([]); |
| | | |
| | | export async function loadExamGradeDics() { |
| | | const baseStore = useBaseStore(); |
| | | GRADE_STATUS_OPTIONS.value = await loadTmsDic(baseStore, TMS_DIC.gradeStatus); |
| | | } |
| | | |
| | | export function labelOfGradeStatus(v?: string) { |
| | | return GRADE_STATUS_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-'; |
| | | return labelOfDic(GRADE_STATUS_OPTIONS.value, v); |
| | | } |
| | | |
| | | export function colorOfGradeStatus(v?: string) { |
| | | return GRADE_STATUS_OPTIONS.find((x) => x.id === v)?.color; |
| | | return (v && GRADE_STATUS_COLOR[v]) || undefined; |
| | | } |
| | |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { getGradeSessionList } from '#/api/x/tms/examGrade'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsExamGrade' }); |
| | | |
| | | const router = useRouter(); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '培训编号', dataIndex: 'taskNo', width: 140 }, |
| | | { title: '试卷编号', dataIndex: 'taskNo', width: 140 }, |
| | | { title: '培训主题', dataIndex: 'taskSubject', minWidth: 260 }, |
| | | { title: '试卷名称', dataIndex: 'paperName', minWidth: 200 }, |
| | | { |
| | |
| | | schemas: [ |
| | | { |
| | | field: 'taskNo', |
| | | label: '培训编号', |
| | | label: '试卷编号', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入培训编号', submitOnPressEnter: true }, |
| | | componentProps: { placeholder: '请输入试卷编号', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'keyword', |
| | |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getGradeSessionList(params) }; |
| | | const page = await getGradeSessionList(params); |
| | | return { |
| | | data: { |
| | | list: Array.isArray(page?.list) ? page.list : [], |
| | | pagination: page?.pagination || { total: 0 }, |
| | | }, |
| | | }; |
| | | } |
| | | |
| | | function handleEnter(record: GradeSessionListItem) { |
| | |
| | | } |
| | | |
| | | function getTableActions(record: GradeSessionListItem): ActionItem[] { |
| | | return [{ label: '进入', onClick: handleEnter.bind(null, record) }]; |
| | | return [{ label: TMS_BTN.grade, onClick: handleEnter.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <div> |
| | | <div class="text-base font-medium">考试阅卷</div> |
| | | <div class="mt-1 text-gray-400 text-sm">阅卷列表,选择试卷进行阅卷,或查看考试详情。</div> |
| | | <div class="tms-page-header__title">考试阅卷</div> |
| | | <div class="tms-page-header__sub">选择场次进入答卷列表,对待阅试卷评分。</div> |
| | | </div> |
| | | </template> |
| | | <template #examTime="{ record }"> |
| | |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { getExamScoreDetail } from '#/api/x/tms/examScore'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsExamScoreDetail' }); |
| | | |
| | |
| | | const loaded = ref(false); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '培训主题', dataIndex: 'taskSubject', minWidth: 220 }, |
| | | { title: '培训任务编号', dataIndex: 'taskNo', width: 140 }, |
| | | { title: '试卷名称', dataIndex: 'taskSubject', minWidth: 220 }, |
| | | { title: '试卷编号', dataIndex: 'taskNo', width: 180 }, |
| | | { |
| | | title: '用户ID', |
| | | title: '考生', |
| | | dataIndex: 'userName', |
| | | width: 120, |
| | | customRender: ({ record }) => { |
| | |
| | | align: 'center', |
| | | slots: { default: 'passFlag' }, |
| | | }, |
| | | { title: '来源IP地址', dataIndex: 'sourceIp', width: 140 }, |
| | | ]; |
| | | |
| | | const [registerTable, { getSelectRows, reload }] = useVxeTable({ |
| | |
| | | } |
| | | |
| | | function getTableActions(record: ExamScoreDetailRow): ActionItem[] { |
| | | return [{ label: '查看试卷', onClick: viewPaper.bind(null, record) }]; |
| | | return [{ label: TMS_BTN.viewPaper, onClick: viewPaper.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <a-space> |
| | | <a-button @click="goBack">返回</a-button> |
| | | <a-button type="primary" @click="handleViewSelected">查看试卷</a-button> |
| | | <span class="text-gray-400 text-sm">{{ titleText }}</span> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | <a-button type="primary" @click="handleViewSelected">{{ TMS_BTN.viewPaper }}</a-button> |
| | | <span class="tms-toolbar-hint">{{ titleText }}</span> |
| | | </a-space> |
| | | </template> |
| | | <template #passFlag="{ record }"> |
| | | <span :class="record.passFlag === '1' ? 'text-green-600' : 'text-red-500'"> |
| | | <span |
| | | v-if="record.passFlag === '1' || record.passFlag === '0'" |
| | | :class="record.passFlag === '1' ? 'text-green-600' : 'text-red-500'" |
| | | > |
| | | {{ record.passFlag === '1' ? '是' : '否' }} |
| | | </span> |
| | | <span v-else>-</span> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { getExamPaperView } from '#/api/x/tms/examScore'; |
| | | import { labelOfType } from '#/views/x/tms/question/constants'; |
| | | import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsExamScorePaper' }); |
| | | |
| | |
| | | const loading = ref(false); |
| | | const paper = ref<ExamPaperView | null>(null); |
| | | |
| | | onMounted(() => { |
| | | onMounted(async () => { |
| | | await loadQuestionDics(); |
| | | loadPaper(); |
| | | }); |
| | | |
| | |
| | | <div class="jnpf-content-wrapper-center tms-paper-center"> |
| | | <div class="jnpf-content-wrapper-content tms-paper-wrap"> |
| | | <div class="paper-top"> |
| | | <div class="paper-header"> |
| | | <div class="paper-header tms-page-header"> |
| | | <div> |
| | | <div class="text-base font-medium">考生试卷详情</div> |
| | | <div v-if="paper" class="mt-1 text-gray-400 text-sm"> |
| | | <div class="tms-page-header__title">考生试卷详情</div> |
| | | <div v-if="paper" class="tms-page-header__sub"> |
| | | {{ paper.userName }}({{ paper.userId }}) · {{ paper.paperName }} |
| | | </div> |
| | | </div> |
| | | <a-button @click="goBack">返回</a-button> |
| | | <div class="tms-page-header__actions"> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <ADescriptions v-if="paper" bordered :column="3" size="small" class="mb-3"> |
| | | <ADescriptionsItem label="培训主题" :span="2">{{ paper.taskSubject }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训编号">{{ paper.taskNo }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="试卷名称" :span="2">{{ paper.taskSubject }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="试卷编号">{{ paper.taskNo }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="考试开始">{{ paper.examStart || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="考试结束">{{ paper.examEnd || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="来源IP">{{ paper.sourceIp || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="成绩"> |
| | | <b class="text-primary">{{ paper.score }}</b> / {{ paper.totalScore }} |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem label="及格分">{{ paper.passScore }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="是否合格"> |
| | | <span :class="paper.passFlag === '1' ? 'text-green-600' : 'text-red-500'"> |
| | | <span |
| | | v-if="paper.passFlag === '1' || paper.passFlag === '0'" |
| | | :class="paper.passFlag === '1' ? 'text-green-600' : 'text-red-500'" |
| | | > |
| | | {{ paper.passFlag === '1' ? '是' : '否' }} |
| | | </span> |
| | | <span v-else>-</span> |
| | | </ADescriptionsItem> |
| | | </ADescriptions> |
| | | </div> |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { ExamScoreSummaryItem } from './types'; |
| | | import type { ExamScoreDetailRow, ExamScoreSummaryItem } from './types'; |
| | | |
| | | import { ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { getExamScoreList } from '#/api/x/tms/examScore'; |
| | | import { exportExamScores, getExamScoreList } from '#/api/x/tms/examScore'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsExamScore' }); |
| | | |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | const exporting = ref(false); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '培训主题', dataIndex: 'taskSubject', minWidth: 240 }, |
| | | { title: '培训编号', dataIndex: 'taskNo', width: 140 }, |
| | | { title: '试卷名称', dataIndex: 'taskSubject', minWidth: 240 }, |
| | | { title: '试卷编号', dataIndex: 'taskNo', width: 180 }, |
| | | { |
| | | title: '试卷名称', |
| | | dataIndex: 'paperName', |
| | |
| | | }, |
| | | { |
| | | field: 'taskNo', |
| | | label: '培训编号', |
| | | label: '试卷编号', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入培训编号', submitOnPressEnter: true }, |
| | | componentProps: { placeholder: '请输入试卷编号', submitOnPressEnter: true }, |
| | | }, |
| | | ], |
| | | }, |
| | |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getExamScoreList(params) }; |
| | | const page = await getExamScoreList(params); |
| | | return { |
| | | data: { |
| | | list: Array.isArray(page?.list) ? page.list : [], |
| | | pagination: page?.pagination || { total: 0 }, |
| | | }, |
| | | }; |
| | | } |
| | | |
| | | function handleDetail(record: ExamScoreSummaryItem) { |
| | | router.push(`/tms/examScore/detail/${record.id}`); |
| | | } |
| | | |
| | | function handleExport() { |
| | | const rows = getSelectRows?.() || []; |
| | | if (!rows.length) { |
| | | async function handleExport() { |
| | | const selected = (getSelectRows?.() || []) as ExamScoreSummaryItem[]; |
| | | if (!selected.length) { |
| | | createMessage.warning('请先勾选要导出的考试记录'); |
| | | return; |
| | | } |
| | | createMessage.success(`已选择 ${rows.length} 条(导出接口联调后生效)`); |
| | | if (exporting.value) return; |
| | | exporting.value = true; |
| | | try { |
| | | const rows = await exportExamScores(selected.map((item) => item.id)); |
| | | if (!rows?.length) { |
| | | createMessage.warning('所选试卷暂无已交卷成绩'); |
| | | return; |
| | | } |
| | | downloadScoreFile(rows, selected); |
| | | createMessage.success(`已导出 ${rows.length} 条考生成绩`); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '导出失败'); |
| | | } finally { |
| | | exporting.value = false; |
| | | } |
| | | } |
| | | |
| | | function passText(flag?: string) { |
| | | if (flag === '1') return '合格'; |
| | | if (flag === '0') return '不合格'; |
| | | return '-'; |
| | | } |
| | | |
| | | function xmlCell(value: unknown) { |
| | | const text = value == null ? '' : String(value); |
| | | return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
| | | } |
| | | |
| | | function scoreFileName(selected: ExamScoreSummaryItem[]) { |
| | | const names = [...new Set(selected.map((item) => (item.taskSubject || '').trim()).filter(Boolean))]; |
| | | const raw = names.length === 1 ? `考试成绩_${names[0]}` : '考试成绩'; |
| | | return `${raw.replace(/[\\/:*?"<>|\r\n]/g, '_')}.xls`; |
| | | } |
| | | |
| | | function downloadScoreFile(rows: ExamScoreDetailRow[], selected: ExamScoreSummaryItem[]) { |
| | | const header = ['试卷名称', '试卷编号', '考生', '考试开始时间', '考试结束时间', '成绩', '及格分', '是否合格']; |
| | | const body = rows.map((row) => [ |
| | | row.taskSubject, |
| | | row.taskNo, |
| | | row.userName || row.userId, |
| | | row.examStart || '', |
| | | row.examEnd || '', |
| | | row.score, |
| | | row.passScore, |
| | | passText(row.passFlag), |
| | | ]); |
| | | const widths = [180, 160, 100, 150, 150, 60, 60, 80]; |
| | | const cols = widths.map((width) => `<Column ss:Width="${width}"/>`).join(''); |
| | | const toRow = (cells: unknown[]) => |
| | | `<Row>${cells.map((cell) => `<Cell><Data ss:Type="String">${xmlCell(cell)}</Data></Cell>`).join('')}</Row>`; |
| | | const xml = `<?xml version="1.0" encoding="UTF-8"?> |
| | | <?mso-application progid="Excel.Sheet"?> |
| | | <Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"> |
| | | <Worksheet ss:Name="考试成绩"><Table>${cols}${toRow(header)}${body.map(toRow).join('')}</Table></Worksheet> |
| | | </Workbook>`; |
| | | const blob = new Blob([xml], { type: 'application/vnd.ms-excel;charset=utf-8' }); |
| | | const link = document.createElement('a'); |
| | | link.href = URL.createObjectURL(blob); |
| | | link.download = scoreFileName(selected); |
| | | link.click(); |
| | | URL.revokeObjectURL(link.href); |
| | | } |
| | | |
| | | function getTableActions(record: ExamScoreSummaryItem): ActionItem[] { |
| | | return [{ label: '考试详情', onClick: handleDetail.bind(null, record) }]; |
| | | return [{ label: TMS_BTN.detail, onClick: handleDetail.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <a-space> |
| | | <a-button type="primary" @click="handleExport">导出考试</a-button> |
| | | <a-button type="primary" :loading="exporting" @click="handleExport">{{ TMS_BTN.export }}</a-button> |
| | | <span class="tms-toolbar-hint">按场次汇总成绩,可导出或进入明细</span> |
| | | </a-space> |
| | | </template> |
| | | <template #paperName="{ record }"> |
| | |
| | | <script lang="ts" setup> |
| | | import type { OnlineExamDetail } from './types'; |
| | | import type { OnlineExamAttempt, OnlineExamDetail } from './types'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | |
| | | Descriptions as ADescriptions, |
| | | DescriptionsItem as ADescriptionsItem, |
| | | Spin, |
| | | Table as ATable, |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { getMyPaperDetail, startOnlineExam } from '#/api/x/tms/onlineExam'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import { colorOfMyPaperStatus, labelOfMyPaperStatus } from './constants'; |
| | | import { |
| | | colorOfMyPaperStatus, |
| | | colorOfPassFlag, |
| | | labelOfGradeStatus, |
| | | labelOfMyPaperStatus, |
| | | labelOfPassFlag, |
| | | loadOnlineExamDics, |
| | | } from './constants'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsOnlineExamDetail' }); |
| | | |
| | |
| | | const starting = ref(false); |
| | | const detail = ref<OnlineExamDetail | null>(null); |
| | | |
| | | onMounted(() => { |
| | | loadDetail(); |
| | | const attemptColumns = [ |
| | | { title: '考试类型', dataIndex: 'attemptLabel', key: 'attemptLabel', width: 130 }, |
| | | { title: '状态', dataIndex: 'status', key: 'status', width: 100 }, |
| | | { title: '开始时间', dataIndex: 'startTime', key: 'startTime', width: 170 }, |
| | | { title: '交卷时间', dataIndex: 'submitTime', key: 'submitTime', width: 170 }, |
| | | { title: '得分', dataIndex: 'gotScore', key: 'gotScore', width: 90, align: 'center' as const }, |
| | | { title: '是否合格', dataIndex: 'passFlag', key: 'passFlag', width: 100, align: 'center' as const }, |
| | | { title: '阅卷', dataIndex: 'gradeStatus', key: 'gradeStatus', width: 100 }, |
| | | { title: '操作', key: 'action', width: 100, align: 'center' as const }, |
| | | ]; |
| | | |
| | | const attempts = computed(() => detail.value?.attempts || []); |
| | | |
| | | onMounted(async () => { |
| | | await loadOnlineExamDics(); |
| | | await loadDetail(); |
| | | }); |
| | | |
| | | async function loadDetail() { |
| | |
| | | router.push('/tms/onlineExam'); |
| | | } |
| | | |
| | | function goReview(row?: OnlineExamAttempt) { |
| | | if (!detail.value) return; |
| | | const examId = row?.examId || detail.value.examId; |
| | | if (!examId) { |
| | | createMessage.warning('暂无已交卷答卷可回顾'); |
| | | return; |
| | | } |
| | | router.push({ |
| | | path: `/tms/onlineExam/review/${detail.value.id}`, |
| | | query: { examId }, |
| | | }); |
| | | } |
| | | |
| | | async function handleStart() { |
| | | if (!detail.value || starting.value) return; |
| | | starting.value = true; |
| | | try { |
| | | const paper = await startOnlineExam(detail.value.id); |
| | | if (paper?.autoSubmitted) { |
| | | createMessage.warning('考试时间已到,已自动交卷'); |
| | | await loadDetail(); |
| | | return; |
| | | } |
| | | sessionStorage.setItem('tms_online_exam_paper', JSON.stringify(paper)); |
| | | sessionStorage.setItem('tms_online_exam_myPaperId', detail.value.id); |
| | | router.push('/tms/onlineExam/exam'); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '开始考试失败'); |
| | | const msg = e?.message || '开始考试失败'; |
| | | if (String(msg).includes('自动交卷')) { |
| | | createMessage.warning(msg); |
| | | await loadDetail(); |
| | | } else { |
| | | createMessage.error(msg); |
| | | } |
| | | } finally { |
| | | starting.value = false; |
| | | } |
| | |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper tms-exam-detail-root"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content tms-exam-detail-page"> |
| | | <Spin :spinning="loading"> |
| | | <div class="tms-exam-detail-header"> |
| | | <div class="text-base font-medium">考试详情</div> |
| | | <a-space> |
| | | <a-button @click="goBack">返回列表</a-button> |
| | | <div class="tms-page-header"> |
| | | <div> |
| | | <div class="tms-page-header__title">考试详情</div> |
| | | <div v-if="detail?.status === 'submitted'" class="tms-page-header__sub"> |
| | | 最近得分 {{ detail.gotScore ?? '-' }} / {{ detail.totalScore }} |
| | | <span v-if="detail.submitTime" class="ml-3">交卷时间:{{ detail.submitTime }}</span> |
| | | </div> |
| | | </div> |
| | | <div class="tms-page-header__actions"> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | <a-button |
| | | v-if="detail && detail.status !== 'submitted'" |
| | | type="primary" |
| | | :loading="starting" |
| | | @click="handleStart" |
| | | > |
| | | {{ detail?.status === 'doing' ? '继续考试' : '开始考试' }} |
| | | {{ detail?.status === 'doing' ? TMS_BTN.continueExam : TMS_BTN.startExam }} |
| | | </a-button> |
| | | </a-space> |
| | | <a-button |
| | | v-else-if="detail?.canRetake" |
| | | type="primary" |
| | | :loading="starting" |
| | | @click="handleStart" |
| | | > |
| | | {{ TMS_BTN.retake }} |
| | | </a-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <ADescriptions v-if="detail" bordered :column="2" size="middle"> |
| | | <ADescriptionsItem label="试卷名称" :span="2">{{ detail.paperName }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="最近考试类型">{{ detail.attemptLabel || '首考' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="状态"> |
| | | <span :style="{ color: colorOfMyPaperStatus(detail.status) }"> |
| | | {{ labelOfMyPaperStatus(detail.status) }} |
| | | </span> |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem label="卷面总分">{{ detail.totalScore }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="补考次数"> |
| | | {{ |
| | | detail.retakeLimit == null |
| | | ? '-' |
| | | : `剩余 ${detail.remainingRetakes ?? 0} / 上限 ${detail.retakeLimit}` |
| | | }} |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem label="考试时间" :span="2">{{ detail.examTimeText || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="考试时长"> |
| | | {{ detail.durationMin != null ? `${detail.durationMin} 分钟` : '-' }} |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem label="合格分数">{{ detail.passScore ?? '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="开始时间">{{ detail.startTime || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="交卷时间">{{ detail.submitTime || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="得分"> |
| | | {{ detail.gotScore != null ? detail.gotScore : '-' }} |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem label="是否合格"> |
| | | <template v-if="detail.passFlag === '1'">合格</template> |
| | | <template v-else-if="detail.passFlag === '0'">不合格</template> |
| | | <template v-else>-</template> |
| | | </ADescriptionsItem> |
| | | </ADescriptions> |
| | | |
| | | <div class="history-block"> |
| | | <div class="history-title">历史答卷</div> |
| | | <ATable |
| | | size="middle" |
| | | :columns="attemptColumns" |
| | | :data-source="attempts" |
| | | :pagination="false" |
| | | row-key="examId" |
| | | bordered |
| | | > |
| | | <template #bodyCell="{ column, record }"> |
| | | <template v-if="column.key === 'status'"> |
| | | <span :style="{ color: colorOfMyPaperStatus(record.status) }"> |
| | | {{ labelOfMyPaperStatus(record.status) }} |
| | | </span> |
| | | </template> |
| | | <template v-else-if="column.key === 'gotScore'"> |
| | | {{ record.gotScore != null ? record.gotScore : '-' }} |
| | | </template> |
| | | <template v-else-if="column.key === 'passFlag'"> |
| | | <span :style="{ color: colorOfPassFlag(record.passFlag) }"> |
| | | {{ labelOfPassFlag(record.passFlag) }} |
| | | </span> |
| | | </template> |
| | | <template v-else-if="column.key === 'gradeStatus'"> |
| | | {{ labelOfGradeStatus(record.gradeStatus) }} |
| | | </template> |
| | | <template v-else-if="column.key === 'action'"> |
| | | <a |
| | | v-if="record.status === 'submitted'" |
| | | @click.prevent="goReview(record as OnlineExamAttempt)" |
| | | > |
| | | 回顾 |
| | | </a> |
| | | <span v-else class="text-gray-400">-</span> |
| | | </template> |
| | | </template> |
| | | </ATable> |
| | | <div v-if="!attempts.length" class="history-empty">暂无答卷记录</div> |
| | | </div> |
| | | |
| | | <a-alert |
| | | v-if="detail && detail.status !== 'submitted' && !attempts.some((a) => a.status === 'submitted')" |
| | | class="mt-4" |
| | | type="info" |
| | | show-icon |
| | | :message="detail.status === 'doing' ? '考试进行中,交卷后可在历史答卷中回顾。' : '尚未开考,暂无答题内容。'" |
| | | /> |
| | | </Spin> |
| | | </div> |
| | | </div> |
| | |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-exam-detail-page { |
| | | background: #fff; |
| | | padding: 20px 24px; |
| | | .tms-exam-detail-root { |
| | | height: 100%; |
| | | overflow: auto; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-exam-detail-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 20px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | .tms-exam-detail-page { |
| | | height: 100%; |
| | | min-height: 0; |
| | | overflow-x: hidden; |
| | | overflow-y: auto !important; |
| | | background: #fff; |
| | | padding: 20px 24px; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .history-block { |
| | | margin-top: 20px; |
| | | } |
| | | |
| | | .history-title { |
| | | font-weight: 600; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .history-empty { |
| | | margin-top: 12px; |
| | | color: #8c8c8c; |
| | | text-align: center; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { OnlineExamQuestionItem } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { Spin } from 'ant-design-vue'; |
| | | |
| | | import { getExamReview, getMyPaperDetail } from '#/api/x/tms/onlineExam'; |
| | | import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsOnlineExamReview' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const loading = ref(false); |
| | | const paperName = ref(''); |
| | | const attemptLabel = ref(''); |
| | | const questions = ref<OnlineExamQuestionItem[]>([]); |
| | | const currentIndex = ref(0); |
| | | const answers = reactive<Record<string, string | string[]>>({}); |
| | | |
| | | const current = computed(() => questions.value[currentIndex.value]); |
| | | const total = computed(() => questions.value.length); |
| | | |
| | | onMounted(async () => { |
| | | await loadQuestionDics(); |
| | | await loadReview(); |
| | | }); |
| | | |
| | | async function loadReview() { |
| | | const id = String(route.params.id || ''); |
| | | const examId = String(route.query.examId || ''); |
| | | if (!id && !examId) { |
| | | router.replace('/tms/onlineExam'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | const detail = examId ? await getExamReview(examId) : await getMyPaperDetail(id); |
| | | if (!detail || detail.status !== 'submitted') { |
| | | createMessage.warning('交卷后才能进行考试回顾'); |
| | | router.replace(id ? `/tms/onlineExam/detail/${id}` : '/tms/onlineExam'); |
| | | return; |
| | | } |
| | | paperName.value = detail.paperName; |
| | | attemptLabel.value = detail.attemptLabel || ''; |
| | | questions.value = detail.questions || []; |
| | | restoreAnswers(questions.value); |
| | | currentIndex.value = 0; |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载考试回顾失败'); |
| | | router.replace(id ? `/tms/onlineExam/detail/${id}` : '/tms/onlineExam'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function isObjective(q: OnlineExamQuestionItem) { |
| | | return !(q.subjective || q.questionType === 'essay'); |
| | | } |
| | | |
| | | function restoreAnswers(list: OnlineExamQuestionItem[]) { |
| | | Object.keys(answers).forEach((key) => { |
| | | delete answers[key]; |
| | | }); |
| | | for (const q of list) { |
| | | if (q.questionType === 'multi') { |
| | | answers[q.id] = q.userAnswer ? q.userAnswer.split(/[,,]/).map((x) => x.trim()).filter(Boolean) : []; |
| | | } else { |
| | | answers[q.id] = q.userAnswer || ''; |
| | | } |
| | | } |
| | | } |
| | | |
| | | function stripHtml(html?: string) { |
| | | if (!html) return ''; |
| | | return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); |
| | | } |
| | | |
| | | function reviewText(q?: OnlineExamQuestionItem) { |
| | | if (!q) return ''; |
| | | if (!isObjective(q)) { |
| | | if (q.gotScore != null) return `主观题得分:${q.gotScore} / ${q.score}`; |
| | | return '主观题,待阅卷'; |
| | | } |
| | | if (q.right) return '回答正确'; |
| | | return `回答错误 · 正确答案:${correctText(q)}`; |
| | | } |
| | | |
| | | function reviewClass(q?: OnlineExamQuestionItem) { |
| | | if (!q || !isObjective(q)) return 'text-gray-500'; |
| | | return q.right ? 'text-green-600' : 'text-red-500'; |
| | | } |
| | | function correctText(q?: OnlineExamQuestionItem) { |
| | | if (!q) return '-'; |
| | | const labels = (q.correctAnswer || '') |
| | | .split(/[,,]/) |
| | | .map((x) => x.trim()) |
| | | .filter(Boolean); |
| | | if (labels.length) return labels.join('、'); |
| | | const fromOptions = (q.options || []).filter((o) => o.isCorrect === '1').map((o) => o.optionLabel); |
| | | return fromOptions.join('、') || '-'; |
| | | } |
| | | |
| | | function goBack() { |
| | | const id = String(route.params.id || ''); |
| | | router.push(id ? `/tms/onlineExam/detail/${id}` : '/tms/onlineExam'); |
| | | } |
| | | |
| | | function goPrev() { |
| | | if (currentIndex.value > 0) currentIndex.value -= 1; |
| | | } |
| | | |
| | | function goNext() { |
| | | if (currentIndex.value < total.value - 1) currentIndex.value += 1; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-wrong-root"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content tms-wrong-page"> |
| | | <Spin :spinning="loading" class="tms-wrong-spin"> |
| | | <template v-if="!loading && !total"> |
| | | <div class="tms-page-header"> |
| | | <div class="tms-page-header__title">{{ paperName || '考试回顾' }}</div> |
| | | <div class="tms-page-header__actions"> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | </div> |
| | | </div> |
| | | <a-empty description="暂无答题内容" /> |
| | | </template> |
| | | |
| | | <template v-else-if="current"> |
| | | <div class="tms-page-header"> |
| | | <div> |
| | | <div class="tms-page-header__title"> |
| | | {{ paperName }} |
| | | <span v-if="attemptLabel" class="ml-2 text-sm font-normal text-gray-500">({{ attemptLabel }})</span> |
| | | </div> |
| | | <div class="tms-page-header__sub">考试回顾 · 第 {{ currentIndex + 1 }} / {{ total }} 题</div> |
| | | </div> |
| | | <div class="tms-page-header__actions"> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="tms-exam-body"> |
| | | <div class="mb-3 text-sm text-gray-500"> |
| | | {{ labelOfType(current.questionType) }} |
| | | <span class="ml-2">({{ current.score }} 分)</span> |
| | | </div> |
| | | <div class="stem mb-4">{{ stripHtml(current.stem) }}</div> |
| | | |
| | | <a-radio-group |
| | | v-if="current.questionType === 'single' || current.questionType === 'judge'" |
| | | :value="answers[current.id]" |
| | | class="!flex !flex-col gap-3" |
| | | disabled |
| | | > |
| | | <a-radio v-for="opt in current.options" :key="opt.optionLabel" :value="opt.optionLabel"> |
| | | {{ opt.optionLabel }}. {{ opt.optionContent }} |
| | | </a-radio> |
| | | </a-radio-group> |
| | | |
| | | <a-checkbox-group |
| | | v-else-if="current.questionType === 'multi'" |
| | | :value="answers[current.id]" |
| | | class="!flex !flex-col gap-3" |
| | | disabled |
| | | > |
| | | <a-checkbox v-for="opt in current.options" :key="opt.optionLabel" :value="opt.optionLabel"> |
| | | {{ opt.optionLabel }}. {{ opt.optionContent }} |
| | | </a-checkbox> |
| | | </a-checkbox-group> |
| | | |
| | | <a-input |
| | | v-else-if="current.questionType === 'blank'" |
| | | :value="answers[current.id]" |
| | | disabled |
| | | placeholder="未作答" |
| | | /> |
| | | |
| | | <a-textarea |
| | | v-else-if="current.questionType === 'essay'" |
| | | :value="answers[current.id]" |
| | | disabled |
| | | :rows="6" |
| | | placeholder="未作答" |
| | | /> |
| | | |
| | | <div class="mt-4 text-sm" :class="reviewClass(current)">{{ reviewText(current) }}</div> |
| | | </div> |
| | | |
| | | <div class="tms-exam-footer"> |
| | | <a-button :disabled="currentIndex <= 0" @click="goPrev">上一题</a-button> |
| | | <a-button :disabled="currentIndex >= total - 1" @click="goNext">下一题</a-button> |
| | | </div> |
| | | </template> |
| | | </Spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-wrong-root { |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-wrong-page { |
| | | height: 100%; |
| | | min-height: 0; |
| | | overflow: hidden; |
| | | background: #fff; |
| | | padding: 20px 24px; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .tms-wrong-spin { |
| | | height: 100%; |
| | | } |
| | | |
| | | .tms-wrong-spin :deep(.ant-spin-container) { |
| | | height: 100%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-exam-body { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow-y: auto !important; |
| | | } |
| | | |
| | | .stem { |
| | | font-size: 15px; |
| | | line-height: 1.7; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .tms-exam-footer { |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | gap: 12px; |
| | | justify-content: center; |
| | | padding-top: 16px; |
| | | border-top: 1px solid #f0f0f0; |
| | | } |
| | | </style> |
| | |
| | | import type { MyPaperStatus } from './types'; |
| | | import { ref } from 'vue'; |
| | | |
| | | export const MY_PAPER_STATUS_OPTIONS: { |
| | | id: MyPaperStatus; |
| | | fullName: string; |
| | | color?: string; |
| | | }[] = [ |
| | | { id: 'notStarted', fullName: '未开始', color: '#8c8c8c' }, |
| | | { id: 'doing', fullName: '考试中', color: '#1890ff' }, |
| | | { id: 'submitted', fullName: '已交卷', color: '#52c41a' }, |
| | | ]; |
| | | import { useBaseStore } from '#/store'; |
| | | import { TMS_DIC, labelOfDic, loadTmsDic, type TmsDicOpt } from '#/views/x/tms/shared/dic'; |
| | | |
| | | /** 我的试卷「未开始」为前端态,字典 tmsExamStatus 不含该项 */ |
| | | const NOT_STARTED: TmsDicOpt = { id: 'notStarted', enCode: 'notStarted', fullName: '未开始' }; |
| | | |
| | | const STATUS_COLOR: Record<string, string> = { |
| | | notStarted: '#8c8c8c', |
| | | doing: '#1890ff', |
| | | submitted: '#52c41a', |
| | | cancelled: '#ff4d4f', |
| | | }; |
| | | |
| | | export const MY_PAPER_STATUS_OPTIONS = ref<TmsDicOpt[]>([NOT_STARTED]); |
| | | export const PASS_FLAG_OPTIONS = ref<TmsDicOpt[]>([]); |
| | | |
| | | export async function loadOnlineExamDics() { |
| | | const baseStore = useBaseStore(); |
| | | const [examStatus, passFlag] = await Promise.all([ |
| | | loadTmsDic(baseStore, TMS_DIC.examStatus), |
| | | loadTmsDic(baseStore, TMS_DIC.passFlag), |
| | | ]); |
| | | const rest = examStatus.filter((x) => (x.enCode || x.id) !== 'notStarted'); |
| | | MY_PAPER_STATUS_OPTIONS.value = [NOT_STARTED, ...rest]; |
| | | PASS_FLAG_OPTIONS.value = passFlag; |
| | | } |
| | | |
| | | export function labelOfMyPaperStatus(v?: string) { |
| | | return MY_PAPER_STATUS_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-'; |
| | | return labelOfDic(MY_PAPER_STATUS_OPTIONS.value, v); |
| | | } |
| | | |
| | | export function colorOfMyPaperStatus(v?: string) { |
| | | return MY_PAPER_STATUS_OPTIONS.find((x) => x.id === v)?.color; |
| | | return (v && STATUS_COLOR[v]) || undefined; |
| | | } |
| | | |
| | | export function labelOfPassFlag(v?: string | null) { |
| | | if (v == null || v === '') return '-'; |
| | | const code = String(v); |
| | | if (code === '1') return '合格'; |
| | | if (code === '0') return '不合格'; |
| | | return labelOfDic(PASS_FLAG_OPTIONS.value, code); |
| | | } |
| | | |
| | | export function colorOfPassFlag(v?: string | null) { |
| | | if (v === '1') return '#52c41a'; |
| | | if (v === '0') return '#ff4d4f'; |
| | | return undefined; |
| | | } |
| | | |
| | | export function labelOfGradeStatus(v?: string | null) { |
| | | if (!v) return '-'; |
| | | if (v === 'auto') return '自动阅卷'; |
| | | if (v === 'pending') return '待阅卷'; |
| | | if (v === 'graded') return '已阅卷'; |
| | | return v; |
| | | } |
| | |
| | | <script lang="ts" setup> |
| | | import type { OnlineExamPaper, OnlineExamQuestionItem } from './types'; |
| | | import type { OnlineExamPaper, OnlineExamQuestionItem, OnlineExamSubmitResult } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { Modal } from 'ant-design-vue'; |
| | | import { AppstoreOutlined } from '@ant-design/icons-vue'; |
| | | |
| | | import { submitOnlineExam } from '#/api/x/tms/onlineExam'; |
| | | import { labelOfType } from '#/views/x/tms/question/constants'; |
| | | import { saveOnlineExam, submitOnlineExam } from '#/api/x/tms/onlineExam'; |
| | | import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | defineOptions({ name: 'TmsOnlineExamTake' }); |
| | | |
| | |
| | | const submitted = ref(false); |
| | | const scoreText = ref(''); |
| | | const submitting = ref(false); |
| | | const saving = ref(false); |
| | | const remainSeconds = ref<number | null>(null); |
| | | const autoSubmitting = ref(false); |
| | | const resultModalOpen = ref(false); |
| | | const submitResult = ref<OnlineExamSubmitResult | null>(null); |
| | | const resultFilter = ref<'all' | 'wrong'>('all'); |
| | | const sheetOpen = ref(false); |
| | | |
| | | let timer: ReturnType<typeof setInterval> | null = null; |
| | | |
| | | const current = computed(() => paper.value?.questions?.[currentIndex.value]); |
| | | const total = computed(() => paper.value?.questions?.length || 0); |
| | | const countdownText = computed(() => formatRemain(remainSeconds.value)); |
| | | const countdownUrgent = computed( |
| | | () => remainSeconds.value != null && remainSeconds.value > 0 && remainSeconds.value <= 5 * 60, |
| | | ); |
| | | |
| | | onMounted(() => { |
| | | const rightCount = computed( |
| | | () => paper.value?.questions?.filter((q) => isObjective(q) && q.right === true).length || 0, |
| | | ); |
| | | const wrongCount = computed( |
| | | () => paper.value?.questions?.filter((q) => isObjective(q) && q.right === false).length || 0, |
| | | ); |
| | | const objectiveCount = computed(() => rightCount.value + wrongCount.value); |
| | | const scoreRate = computed(() => { |
| | | if (!objectiveCount.value) return 0; |
| | | return Math.round((rightCount.value * 10000) / objectiveCount.value) / 100; |
| | | }); |
| | | const resultQuestions = computed(() => { |
| | | const list = paper.value?.questions || []; |
| | | if (resultFilter.value === 'wrong') { |
| | | return list.filter((q) => isObjective(q) && q.right === false); |
| | | } |
| | | return list; |
| | | }); |
| | | const answeredCount = computed( |
| | | () => (paper.value?.questions || []).filter((q) => isAnswered(q)).length, |
| | | ); |
| | | |
| | | onMounted(async () => { |
| | | await loadQuestionDics(); |
| | | const raw = sessionStorage.getItem('tms_online_exam_paper'); |
| | | if (!raw) { |
| | | createMessage.warning('请先从试卷列表选择考试'); |
| | |
| | | return; |
| | | } |
| | | try { |
| | | paper.value = JSON.parse(raw); |
| | | const parsed = JSON.parse(raw) as OnlineExamPaper; |
| | | paper.value = parsed; |
| | | restoreAnswers(parsed); |
| | | initCountdown(parsed); |
| | | } catch { |
| | | router.replace('/tms/onlineExam'); |
| | | } |
| | | }); |
| | | |
| | | onUnmounted(() => { |
| | | stopCountdown(); |
| | | resultModalOpen.value = false; |
| | | }); |
| | | |
| | | function isObjective(q: OnlineExamQuestionItem) { |
| | | return !(q.subjective || q.questionType === 'essay'); |
| | | } |
| | | |
| | | function isAnswered(q: OnlineExamQuestionItem) { |
| | | const val = answers[q.id]; |
| | | if (Array.isArray(val)) return val.length > 0; |
| | | return String(val ?? '').trim().length > 0; |
| | | } |
| | | |
| | | function restoreAnswers(data: OnlineExamPaper) { |
| | | Object.keys(answers).forEach((key) => { |
| | | delete answers[key]; |
| | | }); |
| | | for (const q of data.questions || []) { |
| | | if (q.questionType === 'multi') { |
| | | answers[q.id] = q.userAnswer ? q.userAnswer.split(',').filter(Boolean) : []; |
| | | } else { |
| | | answers[q.id] = q.userAnswer || ''; |
| | | } |
| | | } |
| | | } |
| | | |
| | | function parseStartMs(startTime?: string) { |
| | | if (!startTime) return NaN; |
| | | const normalized = startTime.includes('T') ? startTime : startTime.replace(/-/g, '/'); |
| | | return new Date(normalized).getTime(); |
| | | } |
| | | |
| | | function calcRemain(data: OnlineExamPaper) { |
| | | if (data.durationMin == null || data.durationMin <= 0) return null; |
| | | const startMs = parseStartMs(data.startTime); |
| | | if (!Number.isNaN(startMs)) { |
| | | const endMs = startMs + data.durationMin * 60 * 1000; |
| | | return Math.max(0, Math.floor((endMs - Date.now()) / 1000)); |
| | | } |
| | | if (data.remainSeconds != null) return Math.max(0, Number(data.remainSeconds)); |
| | | return null; |
| | | } |
| | | |
| | | function initCountdown(data: OnlineExamPaper) { |
| | | stopCountdown(); |
| | | const remain = calcRemain(data); |
| | | remainSeconds.value = remain; |
| | | if (remain == null) return; |
| | | if (remain <= 0) { |
| | | void doSubmit({ auto: true }); |
| | | return; |
| | | } |
| | | timer = setInterval(() => { |
| | | if (submitted.value || submitting.value || autoSubmitting.value) { |
| | | stopCountdown(); |
| | | return; |
| | | } |
| | | const next = calcRemain(paper.value!); |
| | | if (next == null) { |
| | | remainSeconds.value = null; |
| | | stopCountdown(); |
| | | return; |
| | | } |
| | | remainSeconds.value = next; |
| | | if (next <= 0) { |
| | | stopCountdown(); |
| | | void doSubmit({ auto: true }); |
| | | } |
| | | }, 1000); |
| | | } |
| | | |
| | | function stopCountdown() { |
| | | if (timer) { |
| | | clearInterval(timer); |
| | | timer = null; |
| | | } |
| | | } |
| | | |
| | | function formatRemain(sec: number | null) { |
| | | if (sec == null) return ''; |
| | | const s = Math.max(0, sec); |
| | | const h = Math.floor(s / 3600); |
| | | const m = Math.floor((s % 3600) / 60); |
| | | const r = s % 60; |
| | | const mm = String(m).padStart(2, '0'); |
| | | const ss = String(r).padStart(2, '0'); |
| | | if (h > 0) return `${String(h).padStart(2, '0')}:${mm}:${ss}`; |
| | | return `${mm}:${ss}`; |
| | | } |
| | | |
| | | function stripHtml(html?: string) { |
| | | if (!html) return ''; |
| | | return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); |
| | | } |
| | | |
| | | function goPrev() { |
| | | if (currentIndex.value > 0) currentIndex.value -= 1; |
| | | } |
| | | |
| | | function goNext() { |
| | | if (currentIndex.value < total.value - 1) currentIndex.value += 1; |
| | | } |
| | | |
| | | function goBack() { |
| | | router.push('/tms/onlineExam'); |
| | | } |
| | | |
| | | function isCorrect(q: OnlineExamQuestionItem): boolean { |
| | | const ans = answers[q.id]; |
| | | const correctLabels = q.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel); |
| | | if (q.questionType === 'multi') { |
| | | const selected = Array.isArray(ans) ? [...ans].sort() : []; |
| | | return selected.join(',') === [...correctLabels].sort().join(','); |
| | | async function persistAnswers() { |
| | | if (!paper.value || submitted.value || saving.value) return; |
| | | saving.value = true; |
| | | try { |
| | | await saveOnlineExam(paper.value.examId, { ...answers }); |
| | | } catch (e: any) { |
| | | const msg = e?.message || '暂存失败'; |
| | | if (String(msg).includes('自动交卷')) { |
| | | createMessage.warning(msg); |
| | | stopCountdown(); |
| | | router.replace('/tms/onlineExam'); |
| | | return; |
| | | } |
| | | createMessage.error(msg); |
| | | } finally { |
| | | saving.value = false; |
| | | } |
| | | return String(ans || '') === String(correctLabels[0] || ''); |
| | | } |
| | | |
| | | function calcScore(): number { |
| | | if (!paper.value) return 0; |
| | | let got = 0; |
| | | paper.value.questions.forEach((q) => { |
| | | if (isCorrect(q)) got += Number(q.score || 0); |
| | | }); |
| | | return got; |
| | | async function goPrev() { |
| | | if (currentIndex.value <= 0) return; |
| | | await persistAnswers(); |
| | | currentIndex.value -= 1; |
| | | } |
| | | |
| | | async function goNext() { |
| | | if (currentIndex.value >= total.value - 1) return; |
| | | await persistAnswers(); |
| | | currentIndex.value += 1; |
| | | } |
| | | |
| | | async function goToQuestion(index: number) { |
| | | if (index < 0 || index >= total.value || index === currentIndex.value) { |
| | | sheetOpen.value = false; |
| | | return; |
| | | } |
| | | if (!submitted.value) await persistAnswers(); |
| | | currentIndex.value = index; |
| | | sheetOpen.value = false; |
| | | } |
| | | |
| | | function openSheet() { |
| | | sheetOpen.value = true; |
| | | } |
| | | |
| | | async function goBack() { |
| | | if (!submitted.value) await persistAnswers(); |
| | | resultModalOpen.value = false; |
| | | router.push('/tms/onlineExam'); |
| | | } |
| | | |
| | | function handleSubmit() { |
| | |
| | | Modal.confirm({ |
| | | title: '确认交卷', |
| | | content: '交卷后不可再修改答案,确定交卷吗?', |
| | | onOk: doSubmit, |
| | | onOk: () => doSubmit(), |
| | | }); |
| | | } |
| | | |
| | | async function doSubmit() { |
| | | function applyGrade(result: OnlineExamSubmitResult) { |
| | | if (!paper.value) return; |
| | | const byId = new Map((result.items || []).map((item) => [item.id, item])); |
| | | for (const q of paper.value.questions) { |
| | | const item = byId.get(q.id); |
| | | if (!item) continue; |
| | | q.right = item.right; |
| | | q.subjective = item.subjective; |
| | | q.correctAnswer = item.correctAnswer || ''; |
| | | if (q.options?.length && item.correctAnswer && q.questionType !== 'blank' && q.questionType !== 'essay') { |
| | | const labels = new Set(item.correctAnswer.split(/[,,]/).map((x) => x.trim()).filter(Boolean)); |
| | | q.options.forEach((opt) => { |
| | | opt.isCorrect = labels.has(opt.optionLabel) ? '1' : '0'; |
| | | }); |
| | | } |
| | | } |
| | | } |
| | | |
| | | function closeResultModal() { |
| | | resultModalOpen.value = false; |
| | | } |
| | | |
| | | async function openResultModal() { |
| | | resultFilter.value = 'all'; |
| | | await nextTick(); |
| | | resultModalOpen.value = true; |
| | | } |
| | | |
| | | function correctLabelsOf(q: OnlineExamQuestionItem) { |
| | | if (q.questionType === 'blank' || q.questionType === 'essay') { |
| | | return q.correctAnswer ? [q.correctAnswer] : []; |
| | | } |
| | | const fromOpt = q.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel); |
| | | if (fromOpt.length) return fromOpt; |
| | | return (q.correctAnswer || '') |
| | | .split(/[,,]/) |
| | | .map((x) => x.trim()) |
| | | .filter(Boolean); |
| | | } |
| | | |
| | | function userAnswerText(q: OnlineExamQuestionItem) { |
| | | const ans = answers[q.id]; |
| | | if (q.questionType === 'multi') { |
| | | const selected = Array.isArray(ans) ? [...ans].sort() : []; |
| | | return selected.length ? selected.join('、') : '未作答'; |
| | | } |
| | | return String(ans || '') || '未作答'; |
| | | } |
| | | |
| | | function optionClass(q: OnlineExamQuestionItem, label: string) { |
| | | if (!submitted.value || !isObjective(q)) return ''; |
| | | const correctSet = new Set(correctLabelsOf(q)); |
| | | const ans = answers[q.id]; |
| | | const userSet = new Set( |
| | | q.questionType === 'multi' |
| | | ? Array.isArray(ans) |
| | | ? ans |
| | | : [] |
| | | : ans |
| | | ? [String(ans)] |
| | | : [], |
| | | ); |
| | | if (correctSet.has(label)) return 'opt-correct'; |
| | | if (userSet.has(label) && !correctSet.has(label)) return 'opt-wrong'; |
| | | return ''; |
| | | } |
| | | |
| | | function resultTag(q: OnlineExamQuestionItem) { |
| | | if (!isObjective(q)) return { color: 'default', text: '待阅卷' }; |
| | | return q.right ? { color: 'success', text: '回答正确' } : { color: 'error', text: '回答错误' }; |
| | | } |
| | | |
| | | function passText() { |
| | | if (submitResult.value?.pendingGrade) return '待阅卷'; |
| | | if (submitResult.value?.passFlag === '1') return '合格'; |
| | | if (submitResult.value?.passFlag === '0') return '不合格'; |
| | | return '-'; |
| | | } |
| | | |
| | | async function doSubmit(opts?: { auto?: boolean }) { |
| | | if (!paper.value || submitted.value || submitting.value) return; |
| | | if (opts?.auto) autoSubmitting.value = true; |
| | | submitting.value = true; |
| | | stopCountdown(); |
| | | try { |
| | | const got = calcScore(); |
| | | await submitOnlineExam(paper.value.examId, { score: got, answers: { ...answers } }); |
| | | const result = await submitOnlineExam(paper.value.examId, { ...answers }); |
| | | applyGrade(result); |
| | | submitted.value = true; |
| | | scoreText.value = `${got} / ${paper.value.totalScore}`; |
| | | createMessage.success(`交卷成功,得分 ${got} 分(满分 ${paper.value.totalScore})`); |
| | | remainSeconds.value = 0; |
| | | submitResult.value = result; |
| | | const got = result.pendingGrade ? result.objectiveScore : result.totalScore; |
| | | scoreText.value = result.pendingGrade |
| | | ? `客观题 ${got ?? 0} / ${paper.value.totalScore}` |
| | | : `${got ?? 0} / ${paper.value.totalScore}`; |
| | | if (opts?.auto) { |
| | | createMessage.warning('考试时间已到,已自动交卷'); |
| | | } |
| | | await openResultModal(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '交卷失败'); |
| | | const msg = e?.message || '交卷失败'; |
| | | if (String(msg).includes('已交卷') || String(msg).includes('自动交卷')) { |
| | | createMessage.warning(msg); |
| | | router.replace('/tms/onlineExam'); |
| | | return; |
| | | } |
| | | createMessage.error(msg); |
| | | if (opts?.auto && paper.value && !submitted.value) { |
| | | initCountdown(paper.value); |
| | | } |
| | | } finally { |
| | | submitting.value = false; |
| | | autoSubmitting.value = false; |
| | | } |
| | | } |
| | | |
| | | function reviewText(q?: OnlineExamQuestionItem) { |
| | | if (!q || !submitted.value) return ''; |
| | | if (!isObjective(q)) return '主观题,待阅卷'; |
| | | if (q.right) return '回答正确'; |
| | | return `回答错误 · 正确答案:${correctLabelsOf(q).join('、') || '-'}`; |
| | | } |
| | | </script> |
| | | |
| | |
| | | <div v-if="paper" class="jnpf-content-wrapper-content tms-online-exam-page"> |
| | | <div class="tms-exam-header"> |
| | | <div> |
| | | <div class="text-base font-medium">{{ paper.paperName }}</div> |
| | | <div class="text-base font-medium"> |
| | | {{ paper.paperName }} |
| | | <span v-if="paper.attemptLabel" class="ml-2 text-sm font-normal text-gray-500"> |
| | | ({{ paper.attemptLabel }}) |
| | | </span> |
| | | </div> |
| | | <div class="mt-1 text-gray-400 text-sm"> |
| | | 第 {{ currentIndex + 1 }} / {{ total }} 题 |
| | | <span v-if="paper.durationMin" class="ml-3">时长 {{ paper.durationMin }} 分钟</span> |
| | |
| | | </div> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="goBack">返回列表</a-button> |
| | | <div |
| | | v-if="countdownText && !submitted" |
| | | class="countdown" |
| | | :class="{ urgent: countdownUrgent, ended: remainSeconds === 0 }" |
| | | > |
| | | 剩余 {{ countdownText }} |
| | | </div> |
| | | <a-button v-if="submitted" type="link" @click="openResultModal">查看本次结果</a-button> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | <a-button type="primary" :loading="submitting" :disabled="submitted" @click="handleSubmit"> |
| | | 交卷 |
| | | {{ TMS_BTN.submitExam }} |
| | | </a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | | <div v-if="current" class="tms-exam-body"> |
| | | <div class="mb-3 text-sm text-gray-500"> |
| | | {{ labelOfType(current.questionType) }} |
| | | <span class="ml-2">({{ current.score }} 分)</span> |
| | | <div class="q-meta mb-3"> |
| | | <div class="text-sm text-gray-500"> |
| | | {{ labelOfType(current.questionType) }} |
| | | <span class="ml-2">({{ current.score }} 分)</span> |
| | | </div> |
| | | <a-tooltip title="答题卡"> |
| | | <button type="button" class="sheet-icon-btn" aria-label="答题卡" @click="openSheet"> |
| | | <AppstoreOutlined /> |
| | | </button> |
| | | </a-tooltip> |
| | | </div> |
| | | <div class="stem mb-4">{{ stripHtml(current.stem) }}</div> |
| | | |
| | |
| | | </a-checkbox> |
| | | </a-checkbox-group> |
| | | |
| | | <a-input |
| | | v-else-if="current.questionType === 'blank'" |
| | | v-model:value="answers[current.id]" |
| | | :disabled="submitted" |
| | | placeholder="请输入答案" |
| | | /> |
| | | |
| | | <a-textarea |
| | | v-else-if="current.questionType === 'essay'" |
| | | v-model:value="answers[current.id]" |
| | | :disabled="submitted" |
| | | :rows="6" |
| | | placeholder="请输入答案" |
| | | /> |
| | | |
| | | <div |
| | | v-if="submitted" |
| | | class="mt-4 text-sm" |
| | | :class="isCorrect(current) ? 'text-green-600' : 'text-red-500'" |
| | | :class=" |
| | | current.right |
| | | ? 'text-green-600' |
| | | : !isObjective(current) |
| | | ? 'text-gray-500' |
| | | : 'text-red-500' |
| | | " |
| | | > |
| | | {{ isCorrect(current) ? '回答正确' : '回答错误' }} |
| | | · 正确答案: |
| | | {{ current.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel).join('、') }} |
| | | {{ reviewText(current) }} |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="tms-exam-footer"> |
| | | <a-button :disabled="currentIndex <= 0" @click="goPrev">上一题</a-button> |
| | | <a-button :disabled="currentIndex >= total - 1" @click="goNext">下一题</a-button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="sheetOpen && paper" class="tms-sheet-overlay" @click.self="sheetOpen = false"> |
| | | <div class="tms-sheet-dialog" role="dialog" aria-modal="true"> |
| | | <div class="sheet-head"> |
| | | <div> |
| | | <div class="sheet-title">答题卡</div> |
| | | <div class="sheet-sub">已答 {{ answeredCount }} / {{ total }},点击题号可切换</div> |
| | | </div> |
| | | <button type="button" class="tms-result-close" aria-label="关闭" @click="sheetOpen = false">×</button> |
| | | </div> |
| | | <div class="sheet-legend"> |
| | | <span class="legend-chip current">当前</span> |
| | | <span class="legend-chip answered">已答</span> |
| | | <span class="legend-chip unanswered">未答</span> |
| | | </div> |
| | | <div class="sheet-grid"> |
| | | <button |
| | | v-for="(q, idx) in paper.questions" |
| | | :key="q.id" |
| | | type="button" |
| | | class="sheet-item" |
| | | :class="{ |
| | | current: idx === currentIndex, |
| | | answered: isAnswered(q), |
| | | unanswered: !isAnswered(q), |
| | | }" |
| | | @click="goToQuestion(idx)" |
| | | > |
| | | {{ idx + 1 }} |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div |
| | | v-if="resultModalOpen && submitResult && paper" |
| | | class="tms-result-overlay" |
| | | @click.self="closeResultModal" |
| | | > |
| | | <div class="tms-result-dialog" role="dialog" aria-modal="true"> |
| | | <button type="button" class="tms-result-close" aria-label="关闭" @click="closeResultModal">×</button> |
| | | |
| | | <div class="result-hero"> |
| | | <div class="hero-title">本次考试结果</div> |
| | | <div class="hero-bank"> |
| | | {{ paper.paperName }} |
| | | <span v-if="paper.attemptLabel">({{ paper.attemptLabel }})</span> |
| | | </div> |
| | | <div class="hero-stats"> |
| | | <div class="stat-item"> |
| | | <div class="stat-value">{{ rightCount }}</div> |
| | | <div class="stat-label">答对</div> |
| | | </div> |
| | | <div class="stat-divider" /> |
| | | <div class="stat-item"> |
| | | <div class="stat-value">{{ wrongCount }}</div> |
| | | <div class="stat-label">答错</div> |
| | | </div> |
| | | <div class="stat-divider" /> |
| | | <div class="stat-item"> |
| | | <div class="stat-value">{{ scoreRate }}%</div> |
| | | <div class="stat-label">正确率</div> |
| | | </div> |
| | | <div class="stat-divider" /> |
| | | <div class="stat-item"> |
| | | <div class="stat-value"> |
| | | {{ submitResult.pendingGrade ? submitResult.objectiveScore : submitResult.totalScore }}/{{ |
| | | paper.totalScore |
| | | }} |
| | | </div> |
| | | <div class="stat-label">{{ submitResult.pendingGrade ? '客观分' : '得分' }}</div> |
| | | </div> |
| | | </div> |
| | | <div class="hero-extra"> |
| | | 结果:{{ passText() }} |
| | | <span v-if="submitResult.pendingGrade" class="ml-2">(含主观题,待阅卷后出最终成绩)</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="result-toolbar"> |
| | | <a-radio-group v-model:value="resultFilter" button-style="solid" size="small"> |
| | | <a-radio-button value="all">全部题目({{ total }})</a-radio-button> |
| | | <a-radio-button value="wrong">仅错题({{ wrongCount }})</a-radio-button> |
| | | </a-radio-group> |
| | | <div class="legend"> |
| | | <span class="legend-item correct">正确答案</span> |
| | | <span class="legend-item wrong">你的错误选项</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="result-list"> |
| | | <a-empty v-if="!resultQuestions.length" description="暂无题目" /> |
| | | <div |
| | | v-for="(q, idx) in resultQuestions" |
| | | :key="q.id" |
| | | class="question-card" |
| | | :class="!isObjective(q) ? '' : q.right ? 'is-right' : 'is-wrong'" |
| | | > |
| | | <div class="q-head"> |
| | | <div class="q-head-left"> |
| | | <span class="q-index">第 {{ idx + 1 }} 题</span> |
| | | <span class="q-type">{{ labelOfType(q.questionType) }} · {{ q.score }} 分</span> |
| | | </div> |
| | | <a-tag :color="resultTag(q).color">{{ resultTag(q).text }}</a-tag> |
| | | </div> |
| | | <div class="q-stem">{{ stripHtml(q.stem) }}</div> |
| | | <div v-if="q.options?.length" class="q-options"> |
| | | <div |
| | | v-for="opt in q.options" |
| | | :key="opt.optionLabel" |
| | | class="q-option" |
| | | :class="optionClass(q, opt.optionLabel)" |
| | | > |
| | | <span class="opt-label">{{ opt.optionLabel }}</span> |
| | | <span class="opt-content">{{ opt.optionContent }}</span> |
| | | </div> |
| | | </div> |
| | | <div class="q-answer-bar"> |
| | | <div> |
| | | <span class="ans-label">你的答案</span> |
| | | <span :class="!isObjective(q) ? '' : q.right ? 'ans-ok' : 'ans-bad'"> |
| | | {{ userAnswerText(q) }} |
| | | </span> |
| | | </div> |
| | | <div v-if="isObjective(q)"> |
| | | <span class="ans-label">正确答案</span> |
| | | <span class="ans-ok">{{ correctLabelsOf(q).join('、') || '-' }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="result-footer"> |
| | | <a-button @click="closeResultModal">{{ TMS_BTN.close }}</a-button> |
| | | <a-button type="primary" @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .countdown { |
| | | min-width: 120px; |
| | | padding: 4px 12px; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | font-variant-numeric: tabular-nums; |
| | | color: #1890ff; |
| | | background: #e6f7ff; |
| | | border: 1px solid #91d5ff; |
| | | border-radius: 4px; |
| | | text-align: center; |
| | | } |
| | | |
| | | .countdown.urgent { |
| | | color: #cf1322; |
| | | background: #fff1f0; |
| | | border-color: #ffa39e; |
| | | } |
| | | |
| | | .countdown.ended { |
| | | color: #8c8c8c; |
| | | background: #fafafa; |
| | | border-color: #d9d9d9; |
| | | } |
| | | |
| | | .tms-exam-body { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .q-meta { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .sheet-icon-btn { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | width: 32px; |
| | | height: 32px; |
| | | border: 1px solid #d9d9d9; |
| | | border-radius: 6px; |
| | | background: #fff; |
| | | color: #1677ff; |
| | | font-size: 16px; |
| | | cursor: pointer; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .sheet-icon-btn:hover { |
| | | border-color: #1677ff; |
| | | background: #e6f4ff; |
| | | } |
| | | |
| | | .stem { |
| | |
| | | padding-top: 16px; |
| | | border-top: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .tms-sheet-overlay { |
| | | position: fixed; |
| | | inset: 0; |
| | | z-index: 1900; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding: 24px; |
| | | background: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .tms-sheet-dialog { |
| | | width: min(520px, 100%); |
| | | max-height: min(70vh, 640px); |
| | | display: flex; |
| | | flex-direction: column; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .sheet-head { |
| | | position: relative; |
| | | flex-shrink: 0; |
| | | padding: 18px 48px 12px 20px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .sheet-title { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .sheet-sub { |
| | | margin-top: 4px; |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .sheet-legend { |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | gap: 16px; |
| | | padding: 12px 20px 0; |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.55); |
| | | } |
| | | |
| | | .legend-chip::before { |
| | | content: ''; |
| | | display: inline-block; |
| | | width: 12px; |
| | | height: 12px; |
| | | margin-right: 6px; |
| | | border-radius: 3px; |
| | | vertical-align: -2px; |
| | | } |
| | | |
| | | .legend-chip.current::before { |
| | | background: #fff; |
| | | border: 2px solid #1677ff; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .legend-chip.answered::before { |
| | | background: #1677ff; |
| | | } |
| | | |
| | | .legend-chip.unanswered::before { |
| | | background: #f5f5f5; |
| | | border: 1px solid #d9d9d9; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .sheet-grid { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow: auto; |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); |
| | | gap: 10px; |
| | | padding: 16px 20px 20px; |
| | | } |
| | | |
| | | .sheet-item { |
| | | height: 40px; |
| | | border-radius: 8px; |
| | | border: 1px solid #d9d9d9; |
| | | background: #fafafa; |
| | | color: rgba(0, 0, 0, 0.65); |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | cursor: pointer; |
| | | transition: all 0.15s ease; |
| | | } |
| | | |
| | | .sheet-item.answered { |
| | | background: #1677ff; |
| | | border-color: #1677ff; |
| | | color: #fff; |
| | | } |
| | | |
| | | .sheet-item.unanswered { |
| | | background: #f5f5f5; |
| | | border-color: #d9d9d9; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .sheet-item.current { |
| | | box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.35); |
| | | } |
| | | |
| | | .sheet-item.current.unanswered { |
| | | border-color: #1677ff; |
| | | color: #1677ff; |
| | | background: #e6f4ff; |
| | | } |
| | | |
| | | .sheet-item:hover { |
| | | filter: brightness(0.97); |
| | | } |
| | | |
| | | .tms-result-overlay { |
| | | position: fixed; |
| | | inset: 0; |
| | | z-index: 2000; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding: 24px; |
| | | background: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .tms-result-dialog { |
| | | position: relative; |
| | | width: min(720px, 100%); |
| | | max-height: min(80vh, 780px); |
| | | display: flex; |
| | | flex-direction: column; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .tms-result-close { |
| | | position: absolute; |
| | | top: 10px; |
| | | right: 12px; |
| | | z-index: 2; |
| | | width: 32px; |
| | | height: 32px; |
| | | border: none; |
| | | border-radius: 6px; |
| | | background: transparent; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | cursor: pointer; |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 22px; |
| | | line-height: 1; |
| | | } |
| | | |
| | | .tms-result-close:hover { |
| | | background: rgba(0, 0, 0, 0.06); |
| | | color: rgba(0, 0, 0, 0.75); |
| | | } |
| | | |
| | | .result-hero { |
| | | flex-shrink: 0; |
| | | padding: 24px 28px 20px; |
| | | background: linear-gradient(135deg, #f0f7ff 0%, #f8fbff 55%, #ffffff 100%); |
| | | border-bottom: 1px solid #eef2f7; |
| | | } |
| | | |
| | | .hero-title { |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | padding-right: 36px; |
| | | } |
| | | |
| | | .hero-bank { |
| | | margin-top: 4px; |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .hero-stats { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | margin-top: 18px; |
| | | padding: 14px 8px; |
| | | background: #fff; |
| | | border: 1px solid #e8eef5; |
| | | border-radius: 10px; |
| | | } |
| | | |
| | | .hero-extra { |
| | | margin-top: 12px; |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.65); |
| | | } |
| | | |
| | | .stat-item { |
| | | flex: 1; |
| | | text-align: center; |
| | | } |
| | | |
| | | .stat-value { |
| | | font-size: 22px; |
| | | font-weight: 700; |
| | | line-height: 1.2; |
| | | color: #1677ff; |
| | | } |
| | | |
| | | .stat-label { |
| | | margin-top: 4px; |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .stat-divider { |
| | | width: 1px; |
| | | height: 28px; |
| | | background: #eef2f7; |
| | | } |
| | | |
| | | .result-toolbar { |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | gap: 12px; |
| | | flex-wrap: wrap; |
| | | padding: 12px 28px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .legend { |
| | | display: flex; |
| | | gap: 12px; |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .legend-item::before { |
| | | content: ''; |
| | | display: inline-block; |
| | | width: 10px; |
| | | height: 10px; |
| | | margin-right: 6px; |
| | | border-radius: 2px; |
| | | vertical-align: -1px; |
| | | } |
| | | |
| | | .legend-item.correct::before { |
| | | background: #b7eb8f; |
| | | } |
| | | |
| | | .legend-item.wrong::before { |
| | | background: #ffa39e; |
| | | } |
| | | |
| | | .result-list { |
| | | flex: 1; |
| | | min-height: 160px; |
| | | overflow: auto; |
| | | padding: 8px 28px 4px; |
| | | } |
| | | |
| | | .question-card { |
| | | margin-bottom: 12px; |
| | | padding: 14px 16px; |
| | | border: 1px solid #f0f0f0; |
| | | border-radius: 10px; |
| | | background: #fff; |
| | | } |
| | | |
| | | .question-card.is-wrong { |
| | | border-color: #ffccc7; |
| | | background: #fffafa; |
| | | } |
| | | |
| | | .question-card.is-right { |
| | | border-color: #d9f7be; |
| | | background: #fcfffb; |
| | | } |
| | | |
| | | .q-head { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .q-head-left { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .q-index { |
| | | font-weight: 600; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .q-type { |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .q-stem { |
| | | margin-bottom: 10px; |
| | | font-size: 14px; |
| | | line-height: 1.7; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .q-options { |
| | | display: grid; |
| | | gap: 6px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .q-option { |
| | | display: flex; |
| | | gap: 8px; |
| | | padding: 8px 10px; |
| | | border-radius: 6px; |
| | | background: #fafafa; |
| | | border: 1px solid transparent; |
| | | color: rgba(0, 0, 0, 0.75); |
| | | } |
| | | |
| | | .q-option .opt-label { |
| | | min-width: 18px; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .q-option.opt-correct { |
| | | background: #f6ffed; |
| | | color: #389e0d; |
| | | border-color: #b7eb8f; |
| | | } |
| | | |
| | | .q-option.opt-wrong { |
| | | background: #fff2f0; |
| | | color: #cf1322; |
| | | border-color: #ffccc7; |
| | | } |
| | | |
| | | .q-answer-bar { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 16px 28px; |
| | | padding-top: 8px; |
| | | border-top: 1px dashed #f0f0f0; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .ans-label { |
| | | margin-right: 8px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .ans-ok { |
| | | color: #389e0d; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .ans-bad { |
| | | color: #cf1322; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .result-footer { |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | justify-content: center; |
| | | gap: 16px; |
| | | padding: 14px 28px 18px; |
| | | border-top: 1px solid #f0f0f0; |
| | | background: #fafafa; |
| | | } |
| | | </style> |
| | |
| | | |
| | | import type { MyPaperListItem } from './types'; |
| | | |
| | | import { ref } from 'vue'; |
| | | import { onMounted, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { getMyPaperList, startOnlineExam } from '#/api/x/tms/onlineExam'; |
| | | import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import { MY_PAPER_STATUS_OPTIONS, colorOfMyPaperStatus, labelOfMyPaperStatus } from './constants'; |
| | | import { |
| | | MY_PAPER_STATUS_OPTIONS, |
| | | colorOfMyPaperStatus, |
| | | colorOfPassFlag, |
| | | labelOfMyPaperStatus, |
| | | labelOfPassFlag, |
| | | loadOnlineExamDics, |
| | | } from './constants'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsOnlineExam' }); |
| | | |
| | |
| | | const starting = ref(false); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '试卷名称', dataIndex: 'paperName', minWidth: 260 }, |
| | | { title: '试卷名称', dataIndex: 'paperName', minWidth: 220 }, |
| | | { |
| | | title: '考试类型', |
| | | dataIndex: 'attemptLabel', |
| | | width: 130, |
| | | align: 'center', |
| | | customRender: ({ record }) => { |
| | | const row = record as MyPaperListItem; |
| | | return row.attemptLabel |
| | | || (row.attemptNo && row.attemptNo > 1 ? `补考(第${row.attemptNo - 1}次)` : '首考'); |
| | | }, |
| | | }, |
| | | { |
| | | title: '状态', |
| | | dataIndex: 'status', |
| | |
| | | { |
| | | title: '考试时间', |
| | | dataIndex: 'examTimeText', |
| | | minWidth: 280, |
| | | minWidth: 260, |
| | | customRender: ({ record }) => (record as MyPaperListItem).examTimeText || '-', |
| | | }, |
| | | { |
| | |
| | | width: 100, |
| | | align: 'center', |
| | | }, |
| | | { |
| | | title: '是否合格', |
| | | dataIndex: 'passFlag', |
| | | width: 100, |
| | | align: 'center', |
| | | slots: { default: 'passFlag' }, |
| | | }, |
| | | { |
| | | title: '剩余补考', |
| | | dataIndex: 'remainingRetakes', |
| | | width: 100, |
| | | align: 'center', |
| | | customRender: ({ record }) => { |
| | | const row = record as MyPaperListItem; |
| | | if (row.retakeLimit == null) return '-'; |
| | | return `${row.remainingRetakes ?? 0} / ${row.retakeLimit}`; |
| | | }, |
| | | }, |
| | | ]; |
| | | |
| | | const [registerTable] = useVxeTable({ |
| | | const [registerTable, { reload, getForm }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | immediate: false, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择状态', |
| | | options: MY_PAPER_STATUS_OPTIONS, |
| | | options: MY_PAPER_STATUS_OPTIONS.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 160, |
| | | width: 200, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | onMounted(async () => { |
| | | await loadOnlineExamDics(); |
| | | getForm()?.updateSchema?.({ |
| | | field: 'status', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择状态', |
| | | options: MY_PAPER_STATUS_OPTIONS.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }); |
| | | reload(); |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getMyPaperList(params) }; |
| | | const page = await getMyPaperList(params); |
| | | return { |
| | | data: { |
| | | list: Array.isArray(page?.list) ? page.list : [], |
| | | pagination: page?.pagination || { total: 0 }, |
| | | }, |
| | | }; |
| | | } |
| | | |
| | | function handleDetail(record: MyPaperListItem) { |
| | |
| | | starting.value = true; |
| | | try { |
| | | const paper = await startOnlineExam(record.id); |
| | | if (paper?.autoSubmitted) { |
| | | createMessage.warning('考试时间已到,已自动交卷'); |
| | | reload(); |
| | | return; |
| | | } |
| | | sessionStorage.setItem('tms_online_exam_paper', JSON.stringify(paper)); |
| | | sessionStorage.setItem('tms_online_exam_myPaperId', record.id); |
| | | router.push('/tms/onlineExam/exam'); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '开始考试失败'); |
| | | const msg = e?.message || '开始考试失败'; |
| | | if (String(msg).includes('自动交卷')) { |
| | | createMessage.warning(msg); |
| | | reload(); |
| | | } else { |
| | | createMessage.error(msg); |
| | | } |
| | | } finally { |
| | | starting.value = false; |
| | | } |
| | |
| | | function getTableActions(record: MyPaperListItem): ActionItem[] { |
| | | const actions: ActionItem[] = []; |
| | | if (record.status === 'notStarted') { |
| | | actions.push({ label: '开始考试', onClick: handleStart.bind(null, record) }); |
| | | actions.push({ label: TMS_BTN.startExam, onClick: handleStart.bind(null, record) }); |
| | | } else if (record.status === 'doing') { |
| | | actions.push({ label: '继续考试', onClick: handleStart.bind(null, record) }); |
| | | actions.push({ label: TMS_BTN.continueExam, onClick: handleStart.bind(null, record) }); |
| | | } else if (record.status === 'submitted' && record.canRetake) { |
| | | actions.push({ label: TMS_BTN.retake, onClick: handleStart.bind(null, record) }); |
| | | } |
| | | actions.push({ label: '查看详情', onClick: handleDetail.bind(null, record) }); |
| | | actions.push({ label: TMS_BTN.detail, onClick: handleDetail.bind(null, record) }); |
| | | return actions; |
| | | } |
| | | </script> |
| | |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <div> |
| | | <div class="text-base font-medium">我的试卷</div> |
| | | <div class="mt-1 text-gray-400 text-sm">我的试卷,选择试卷参加考试,或者查看考试详情。</div> |
| | | <div class="tms-page-header__title">我的试卷</div> |
| | | <div class="tms-page-header__sub">参加考试或查看成绩;可考时操作列直接开考。</div> |
| | | </div> |
| | | </template> |
| | | <template #status="{ record }"> |
| | |
| | | {{ labelOfMyPaperStatus(record.status) }} |
| | | </span> |
| | | </template> |
| | | <template #passFlag="{ record }"> |
| | | <span :style="{ color: colorOfPassFlag(record.passFlag) }"> |
| | | {{ labelOfPassFlag(record.passFlag) }} |
| | | </span> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | |
| | | OnlineExamQuestionItem, |
| | | } from './types'; |
| | | |
| | | import { mockGetQuestion, mockQueryQuestions } from '#/views/x/tms/question/mock'; |
| | | |
| | | function delay<T>(data: T, ms = 220): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | /** 在线考试 mock 自带样题(不再依赖试题管理 mock) */ |
| | | const DEMO_QUESTIONS: OnlineExamQuestionItem[] = [ |
| | | { |
| | | id: 'demo_q1', |
| | | questionNo: '260001', |
| | | questionType: 'judge', |
| | | stem: '特种作业人员必须取得有效资格证书后方可上岗作业。', |
| | | score: 20, |
| | | options: [ |
| | | { optionLabel: 'T', optionContent: '正确', isCorrect: '1' }, |
| | | { optionLabel: 'F', optionContent: '错误', isCorrect: '0' }, |
| | | ], |
| | | }, |
| | | { |
| | | id: 'demo_q2', |
| | | questionNo: '260002', |
| | | questionType: 'multi', |
| | | stem: '下列哪些属于特种设备?', |
| | | score: 20, |
| | | options: [ |
| | | { optionLabel: 'A', optionContent: '电梯', isCorrect: '1' }, |
| | | { optionLabel: 'B', optionContent: '压力容器', isCorrect: '1' }, |
| | | { optionLabel: 'C', optionContent: '普通办公桌', isCorrect: '0' }, |
| | | { optionLabel: 'D', optionContent: '锅炉', isCorrect: '1' }, |
| | | ], |
| | | }, |
| | | { |
| | | id: 'demo_q3', |
| | | questionNo: '260003', |
| | | questionType: 'single', |
| | | stem: 'GMP 的全称是?', |
| | | score: 20, |
| | | options: [ |
| | | { optionLabel: 'A', optionContent: '药品生产质量管理规范', isCorrect: '1' }, |
| | | { optionLabel: 'B', optionContent: '药品经营质量管理规范', isCorrect: '0' }, |
| | | { optionLabel: 'C', optionContent: '实验室管理规范', isCorrect: '0' }, |
| | | { optionLabel: 'D', optionContent: '文件管理规范', isCorrect: '0' }, |
| | | ], |
| | | }, |
| | | ]; |
| | | |
| | | const store: MyPaperListItem[] = [ |
| | | { |
| | |
| | | }); |
| | | } |
| | | |
| | | async function buildQuestions(): Promise<OnlineExamQuestionItem[]> { |
| | | const all = await mockQueryQuestions({ pageSize: 50, currentPage: 1 }); |
| | | const list = (all.list || []).filter((q) => ['single', 'multi', 'judge'].includes(q.questionType)); |
| | | const picked = list.slice(0, 5); |
| | | const detailed: OnlineExamQuestionItem[] = []; |
| | | for (const q of picked) { |
| | | const full = await mockGetQuestion(q.id!); |
| | | detailed.push({ |
| | | id: full.id!, |
| | | questionNo: full.questionNo, |
| | | questionType: full.questionType as 'single' | 'multi' | 'judge', |
| | | stem: full.stem, |
| | | score: 20, |
| | | options: (full.options || []).map((o) => ({ |
| | | optionLabel: o.optionLabel, |
| | | optionContent: o.optionContent, |
| | | isCorrect: o.isCorrect, |
| | | })), |
| | | }); |
| | | } |
| | | // mock 题不够时补空题避免无法开考 |
| | | if (!detailed.length) { |
| | | detailed.push({ |
| | | id: 'demo_q1', |
| | | questionType: 'judge', |
| | | stem: '特种作业人员必须取得有效资格证书后方可上岗作业。', |
| | | score: 100, |
| | | options: [ |
| | | { optionLabel: 'T', optionContent: '正确', isCorrect: '1' }, |
| | | { optionLabel: 'F', optionContent: '错误', isCorrect: '0' }, |
| | | ], |
| | | }); |
| | | } |
| | | return detailed; |
| | | } |
| | | |
| | | export async function mockStartExam(myPaperId: string): Promise<OnlineExamPaper> { |
| | | const row = store.find((x) => x.id === myPaperId); |
| | | if (!row) return Promise.reject(new Error('试卷不存在')); |
| | | if (row.status === 'submitted') return Promise.reject(new Error('该试卷已交卷,无法再次考试')); |
| | | |
| | | const questions = await buildQuestions(); |
| | | const examId = row.examId || `exam_${Date.now()}`; |
| | | row.status = 'doing'; |
| | | row.examId = examId; |
| | |
| | | totalScore: row.totalScore, |
| | | passScore: row.passScore, |
| | | durationMin: row.durationMin, |
| | | questions, |
| | | questions: DEMO_QUESTIONS.map((q) => ({ ...q, options: q.options.map((o) => ({ ...o })) })), |
| | | }); |
| | | } |
| | | |
| | |
| | | durationMin?: number; |
| | | /** 已交卷时的得分 */ |
| | | gotScore?: number; |
| | | /** 1合格 / 0不合格 / 空=未出结果 */ |
| | | passFlag?: '0' | '1' | ''; |
| | | examId?: string; |
| | | /** 当前/最近一次考试序号,1=首考 */ |
| | | attemptNo?: number; |
| | | /** 首考 / 补考(第1次)…(补考序号不含首考) */ |
| | | attemptLabel?: string; |
| | | submittedCount?: number; |
| | | retakeLimit?: number; |
| | | remainingRetakes?: number; |
| | | canRetake?: boolean; |
| | | } |
| | | |
| | | export interface MyPaperPageQuery { |
| | |
| | | totalScore: number; |
| | | passScore?: number; |
| | | durationMin?: number; |
| | | /** 开考时间,倒计时按此续算 */ |
| | | startTime?: string; |
| | | /** 服务端剩余秒数 */ |
| | | remainSeconds?: number | null; |
| | | /** 开考接口发现已超时并完成自动交卷 */ |
| | | autoSubmitted?: boolean; |
| | | attemptNo?: number; |
| | | attemptLabel?: string; |
| | | questions: OnlineExamQuestionItem[]; |
| | | } |
| | | |
| | | export type OnlineQuestionType = 'single' | 'multi' | 'judge' | 'blank' | 'essay'; |
| | | |
| | | export interface OnlineExamQuestionItem { |
| | | id: string; |
| | | questionId?: string; |
| | | questionNo?: string; |
| | | questionType: 'single' | 'multi' | 'judge'; |
| | | questionType: OnlineQuestionType; |
| | | stem: string; |
| | | score: number; |
| | | options: { optionLabel: string; optionContent: string; isCorrect: '0' | '1' }[]; |
| | | gotScore?: number | null; |
| | | userAnswer?: string; |
| | | options: { optionLabel: string; optionContent: string; isCorrect?: '0' | '1' }[]; |
| | | /** 交卷后回填 */ |
| | | right?: boolean | null; |
| | | correctAnswer?: string; |
| | | subjective?: boolean; |
| | | } |
| | | |
| | | export interface OnlineExamSubmitResult { |
| | | objectiveScore?: number; |
| | | totalScore?: number; |
| | | passFlag?: '0' | '1' | ''; |
| | | gradeStatus?: string; |
| | | pendingGrade?: boolean; |
| | | items?: { |
| | | id: string; |
| | | correctAnswer?: string; |
| | | gotScore?: number | null; |
| | | right?: boolean | null; |
| | | subjective?: boolean; |
| | | }[]; |
| | | } |
| | | |
| | | export interface OnlineExamAttempt { |
| | | examId: string; |
| | | attemptNo?: number; |
| | | attemptLabel?: string; |
| | | status?: MyPaperStatus; |
| | | gradeStatus?: string; |
| | | startTime?: string; |
| | | submitTime?: string; |
| | | gotScore?: number | null; |
| | | passFlag?: '0' | '1' | ''; |
| | | } |
| | | |
| | | export interface OnlineExamDetail { |
| | |
| | | startTime?: string; |
| | | submitTime?: string; |
| | | passFlag?: '0' | '1'; |
| | | gradeStatus?: string; |
| | | examId?: string; |
| | | attemptNo?: number; |
| | | attemptLabel?: string; |
| | | submittedCount?: number; |
| | | retakeLimit?: number; |
| | | remainingRetakes?: number; |
| | | canRetake?: boolean; |
| | | /** 历史答卷 */ |
| | | attempts?: OnlineExamAttempt[]; |
| | | /** 已交卷回顾 */ |
| | | questions?: OnlineExamQuestionItem[]; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { QuestionEntity, QuestionOption } from './types'; |
| | | |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { |
| | | Descriptions as ADescriptions, |
| | | DescriptionsItem as ADescriptionsItem, |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { getQuestionInfo } from '#/api/x/tms/question'; |
| | | |
| | | import { |
| | | labelOfDifficulty, |
| | | labelOfSource, |
| | | labelOfStatus, |
| | | labelOfType, |
| | | loadQuestionDics, |
| | | } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsQuestionDetail' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const loading = ref(false); |
| | | const detail = ref<QuestionEntity | null>(null); |
| | | |
| | | const answerText = computed(() => formatAnswer(detail.value)); |
| | | |
| | | onMounted(async () => { |
| | | await loadQuestionDics(); |
| | | await loadData(); |
| | | }); |
| | | |
| | | async function loadData() { |
| | | const id = String(route.params.id || ''); |
| | | if (!id) { |
| | | router.replace('/tms/question'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | detail.value = await getQuestionInfo(id); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载试题失败'); |
| | | router.replace('/tms/question'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function goBack() { |
| | | router.push('/tms/question'); |
| | | } |
| | | |
| | | function goEdit() { |
| | | if (!detail.value?.id) return; |
| | | router.push(`/tms/question/edit/${detail.value.id}`); |
| | | } |
| | | |
| | | function formatAnswer(row?: QuestionEntity | null) { |
| | | if (!row) return '-'; |
| | | const opts = row.options || []; |
| | | switch (row.questionType) { |
| | | case 'single': |
| | | case 'multi': { |
| | | const correct = opts.filter((o) => o.isCorrect === '1'); |
| | | if (!correct.length) return '-'; |
| | | return correct.map((o) => `${o.optionLabel}. ${o.optionContent || ''}`).join(';'); |
| | | } |
| | | case 'judge': { |
| | | const correct = opts.find((o) => o.isCorrect === '1'); |
| | | return correct?.optionContent || correct?.optionLabel || '-'; |
| | | } |
| | | case 'blank': |
| | | return opts.map((o, i) => `填空${i + 1}:${o.optionContent || '-'}`).join(';') || '-'; |
| | | case 'essay': |
| | | return opts[0]?.optionContent || '-'; |
| | | default: |
| | | return '-'; |
| | | } |
| | | } |
| | | |
| | | function optionClass(opt: QuestionOption) { |
| | | return opt.isCorrect === '1' ? 'opt-correct' : ''; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-question-detail-page"> |
| | | <div class="jnpf-content-wrapper-center tms-question-detail-center"> |
| | | <div class="jnpf-content-wrapper-content tms-question-detail-wrap"> |
| | | <a-spin :spinning="loading" class="detail-spin"> |
| | | <div v-if="detail" class="detail-inner"> |
| | | <div class="detail-header"> |
| | | <div> |
| | | <div class="text-base font-medium">试题详情</div> |
| | | <div class="mt-1 text-gray-400 text-sm">编号 {{ detail.questionNo || '-' }}</div> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="goBack">返回</a-button> |
| | | <a-button type="primary" @click="goEdit">编辑</a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | | <ADescriptions bordered :column="2" size="middle" class="detail-desc"> |
| | | <ADescriptionsItem label="试题编号">{{ detail.questionNo || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="试题类型">{{ labelOfType(detail.questionType) }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="所属题库">{{ detail.bankName || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="试题难度">{{ labelOfDifficulty(detail.difficulty) }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="试题来源">{{ labelOfSource(detail.sourceType) }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="试题状态">{{ labelOfStatus(detail.bizStatus) }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="管理员">{{ detail.adminUserName || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="创建时间">{{ detail.creatorTime || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="题干内容" :span="2"> |
| | | <div class="stem-html" v-html="detail.stem || '-'" /> |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem |
| | | v-if="detail.questionType === 'single' || detail.questionType === 'multi' || detail.questionType === 'judge'" |
| | | label="选项" |
| | | :span="2" |
| | | > |
| | | <div class="option-list"> |
| | | <div |
| | | v-for="(opt, idx) in detail.options || []" |
| | | :key="opt.id || idx" |
| | | class="option-item" |
| | | :class="optionClass(opt)" |
| | | > |
| | | <span class="opt-label">{{ opt.optionLabel }}.</span> |
| | | <span>{{ opt.optionContent }}</span> |
| | | <span v-if="opt.isCorrect === '1'" class="opt-tag">正确答案</span> |
| | | </div> |
| | | <div v-if="!(detail.options && detail.options.length)" class="text-gray-400">-</div> |
| | | </div> |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem label="参考答案" :span="2"> |
| | | <div class="pre-line">{{ answerText }}</div> |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem label="试题解析" :span="2"> |
| | | <div class="pre-line">{{ detail.analysis || '-' }}</div> |
| | | </ADescriptionsItem> |
| | | </ADescriptions> |
| | | </div> |
| | | </a-spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-question-detail-page { |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-question-detail-center { |
| | | height: 100% !important; |
| | | min-height: 0 !important; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .tms-question-detail-wrap { |
| | | display: flex !important; |
| | | flex-direction: column; |
| | | flex: 1 1 0 !important; |
| | | min-height: 0 !important; |
| | | height: 100%; |
| | | overflow: auto !important; |
| | | background: #fff; |
| | | padding: 0; |
| | | } |
| | | |
| | | .detail-spin { |
| | | display: block; |
| | | min-height: 100%; |
| | | width: 100%; |
| | | } |
| | | |
| | | .detail-spin :deep(.ant-spin-container) { |
| | | min-height: 100%; |
| | | width: 100%; |
| | | } |
| | | |
| | | .detail-inner { |
| | | padding: 16px 20px 24px; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .detail-header { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | margin-bottom: 16px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .detail-desc { |
| | | max-width: 1100px; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .stem-html :deep(p) { |
| | | margin-bottom: 0.5em; |
| | | } |
| | | |
| | | .stem-html :deep(img) { |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .pre-line { |
| | | white-space: pre-wrap; |
| | | word-break: break-word; |
| | | } |
| | | |
| | | .option-list { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .option-item { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | gap: 8px; |
| | | line-height: 1.6; |
| | | } |
| | | |
| | | .opt-label { |
| | | flex-shrink: 0; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .opt-correct { |
| | | color: #52c41a; |
| | | } |
| | | |
| | | .opt-tag { |
| | | flex-shrink: 0; |
| | | margin-left: 4px; |
| | | padding: 0 6px; |
| | | font-size: 12px; |
| | | color: #52c41a; |
| | | background: #f6ffed; |
| | | border: 1px solid #b7eb8f; |
| | | border-radius: 2px; |
| | | } |
| | | </style> |
| | |
| | | QUESTION_TYPE_OPTIONS, |
| | | SOURCE_OPTIONS, |
| | | STATUS_OPTIONS, |
| | | loadQuestionDics, |
| | | } from './constants'; |
| | | import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic'; |
| | | import { |
| | | createDefaultOptions, |
| | | hydrateFormFromEntity, |
| | |
| | | blankMixMode: false, |
| | | }); |
| | | |
| | | const statusTip = computed(() => STATUS_OPTIONS.find((x) => x.id === dataForm.bizStatus)?.tip); |
| | | const statusTipColor = computed(() => STATUS_OPTIONS.find((x) => x.id === dataForm.bizStatus)?.tipColor); |
| | | const statusTip = computed( |
| | | () => STATUS_OPTIONS.value.find((x) => (x.enCode || x.id) === dataForm.bizStatus)?.tip, |
| | | ); |
| | | const statusTipColor = computed( |
| | | () => STATUS_OPTIONS.value.find((x) => (x.enCode || x.id) === dataForm.bizStatus)?.tipColor, |
| | | ); |
| | | |
| | | const rules = { |
| | | questionType: [{ required: true, message: '请选择题型', trigger: 'change' }], |
| | |
| | | ); |
| | | |
| | | onMounted(async () => { |
| | | await loadQuestionDics(); |
| | | banks.value = (await getQuestionBanks()) || []; |
| | | if (isEdit.value) { |
| | | await loadDetail(String(route.params.id)); |
| | |
| | | <jnpf-select |
| | | v-model:value="dataForm.questionType" |
| | | :options="QUESTION_TYPE_OPTIONS" |
| | | :field-names="TMS_DIC_FIELD_NAMES" |
| | | :allow-clear="false" |
| | | placeholder="请选择题型" |
| | | /> |
| | |
| | | <jnpf-select |
| | | v-model:value="dataForm.difficulty" |
| | | :options="DIFFICULTY_OPTIONS" |
| | | :field-names="TMS_DIC_FIELD_NAMES" |
| | | :allow-clear="false" |
| | | /> |
| | | </a-form-item> |
| | |
| | | <jnpf-select |
| | | v-model:value="dataForm.bizStatus" |
| | | :options="STATUS_OPTIONS" |
| | | :field-names="TMS_DIC_FIELD_NAMES" |
| | | :allow-clear="false" |
| | | class="!w-[200px]" |
| | | /> |
| | |
| | | <jnpf-select |
| | | v-model:value="dataForm.sourceType" |
| | | :options="SOURCE_OPTIONS" |
| | | :field-names="TMS_DIC_FIELD_NAMES" |
| | | :allow-clear="false" |
| | | /> |
| | | </a-form-item> |
| | |
| | | import type { Difficulty, QuestionStatus, QuestionType, SourceType } from './types'; |
| | | import { ref } from 'vue'; |
| | | |
| | | export const QUESTION_TYPE_OPTIONS: { id: QuestionType; fullName: string }[] = [ |
| | | { id: 'single', fullName: '单选题' }, |
| | | { id: 'multi', fullName: '多选题' }, |
| | | { id: 'judge', fullName: '判断题' }, |
| | | { id: 'blank', fullName: '填空题' }, |
| | | { id: 'essay', fullName: '问答题' }, |
| | | ]; |
| | | import { useBaseStore } from '#/store'; |
| | | import { TMS_DIC, labelOfDic, loadTmsDic, type TmsDicOpt } from '#/views/x/tms/shared/dic'; |
| | | |
| | | export const DIFFICULTY_OPTIONS: { id: Difficulty; fullName: string }[] = [ |
| | | { id: 'easy', fullName: '简单' }, |
| | | { id: 'normal', fullName: '一般' }, |
| | | { id: 'hard', fullName: '困难' }, |
| | | ]; |
| | | type StatusOpt = TmsDicOpt & { tip?: string; tipColor?: string }; |
| | | |
| | | export const SOURCE_OPTIONS: { id: SourceType; fullName: string }[] = [ |
| | | { id: 'self', fullName: '自主命题' }, |
| | | { id: 'import', fullName: '导入' }, |
| | | { id: 'external', fullName: '外购' }, |
| | | ]; |
| | | function withStatusTip(list: TmsDicOpt[]): StatusOpt[] { |
| | | return list.map((x) => { |
| | | const code = x.enCode || x.id; |
| | | if (code === 'open') return { ...x, tip: '学生可以模拟', tipColor: 'green' }; |
| | | if (code === 'closed') return { ...x, tip: '学生不能模拟', tipColor: 'red' }; |
| | | return x; |
| | | }); |
| | | } |
| | | |
| | | export const STATUS_OPTIONS: { id: QuestionStatus; fullName: string; tip?: string; tipColor?: string }[] = [ |
| | | { id: 'open', fullName: '开放', tip: '学生可以模拟', tipColor: 'green' }, |
| | | { id: 'closed', fullName: '不开放', tip: '学生不能模拟', tipColor: 'red' }, |
| | | { id: 'invalid', fullName: '废弃' }, |
| | | ]; |
| | | export const QUESTION_TYPE_OPTIONS = ref<TmsDicOpt[]>([]); |
| | | export const DIFFICULTY_OPTIONS = ref<TmsDicOpt[]>([]); |
| | | export const SOURCE_OPTIONS = ref<TmsDicOpt[]>([]); |
| | | export const STATUS_OPTIONS = ref<StatusOpt[]>([]); |
| | | |
| | | export async function loadQuestionDics() { |
| | | const baseStore = useBaseStore(); |
| | | const [questionType, difficulty, source, status] = await Promise.all([ |
| | | loadTmsDic(baseStore, TMS_DIC.questionType), |
| | | loadTmsDic(baseStore, TMS_DIC.difficulty), |
| | | loadTmsDic(baseStore, TMS_DIC.questionSource), |
| | | loadTmsDic(baseStore, TMS_DIC.openClosedInvalid), |
| | | ]); |
| | | QUESTION_TYPE_OPTIONS.value = questionType; |
| | | DIFFICULTY_OPTIONS.value = difficulty; |
| | | SOURCE_OPTIONS.value = source; |
| | | STATUS_OPTIONS.value = withStatusTip(status); |
| | | } |
| | | |
| | | export const OPTION_LABELS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); |
| | | |
| | | export function labelOfType(type?: string) { |
| | | return QUESTION_TYPE_OPTIONS.find((x) => x.id === type)?.fullName ?? type ?? '-'; |
| | | return labelOfDic(QUESTION_TYPE_OPTIONS.value, type); |
| | | } |
| | | |
| | | export function labelOfStatus(status?: string) { |
| | | return STATUS_OPTIONS.find((x) => x.id === status)?.fullName ?? status ?? '-'; |
| | | return labelOfDic(STATUS_OPTIONS.value, status); |
| | | } |
| | | |
| | | export function labelOfDifficulty(v?: string) { |
| | | return DIFFICULTY_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-'; |
| | | return labelOfDic(DIFFICULTY_OPTIONS.value, v); |
| | | } |
| | | |
| | | export function labelOfSource(v?: string) { |
| | | return SOURCE_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-'; |
| | | return labelOfDic(SOURCE_OPTIONS.value, v); |
| | | } |
| | |
| | | |
| | | import { Modal } from 'ant-design-vue'; |
| | | |
| | | import { deleteQuestion, getQuestionBanks, getQuestionList } from '#/api/x/tms/question'; |
| | | import { getQuestionBanks, getQuestionList, invalidateQuestion } from '#/api/x/tms/question'; |
| | | |
| | | import { |
| | | QUESTION_TYPE_OPTIONS, |
| | | STATUS_OPTIONS, |
| | | labelOfStatus, |
| | | labelOfType, |
| | | loadQuestionDics, |
| | | } from './constants'; |
| | | import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic'; |
| | | |
| | | defineOptions({ name: 'TmsQuestionList' }); |
| | | |
| | |
| | | }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload, getForm }] = useVxeTable({ |
| | | const [registerTable, { reload, getForm, getSelectRows }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | immediate: false, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | showAdvancedButton: true, |
| | | autoAdvancedLine: 1, |
| | | alwaysShowLines: 1, |
| | | autoAdvancedLine: 4, |
| | | schemas: [ |
| | | { |
| | | field: 'bankId', |
| | |
| | | allowClear: true, |
| | | showSearch: true, |
| | | placeholder: '请选择题型', |
| | | options: QUESTION_TYPE_OPTIONS, |
| | | options: QUESTION_TYPE_OPTIONS.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | allowClear: true, |
| | | showSearch: true, |
| | | placeholder: '请选择状态', |
| | | options: STATUS_OPTIONS.filter((x) => x.id !== 'invalid'), |
| | | options: STATUS_OPTIONS.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | }, |
| | | ], |
| | | }, |
| | | rowSelection: { type: 'checkbox' }, |
| | | actionColumn: { |
| | | width: 100, |
| | | width: 180, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | |
| | | }); |
| | | |
| | | onMounted(async () => { |
| | | banks.value = (await getQuestionBanks()) || []; |
| | | getForm()?.updateSchema?.({ |
| | | field: 'bankId', |
| | | componentProps: { |
| | | allowClear: true, |
| | | showSearch: true, |
| | | placeholder: '请选择题库', |
| | | options: banks.value, |
| | | await loadQuestionDics(); |
| | | try { |
| | | const list = await getQuestionBanks(); |
| | | banks.value = Array.isArray(list) ? list : []; |
| | | } catch { |
| | | banks.value = []; |
| | | } |
| | | getForm()?.updateSchema?.([ |
| | | { |
| | | field: 'bankId', |
| | | componentProps: { |
| | | allowClear: true, |
| | | showSearch: true, |
| | | placeholder: '请选择题库', |
| | | options: banks.value, |
| | | }, |
| | | }, |
| | | }); |
| | | { |
| | | field: 'questionType', |
| | | componentProps: { |
| | | allowClear: true, |
| | | showSearch: true, |
| | | placeholder: '请选择题型', |
| | | options: QUESTION_TYPE_OPTIONS.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'bizStatus', |
| | | componentProps: { |
| | | allowClear: true, |
| | | showSearch: true, |
| | | placeholder: '请选择状态', |
| | | options: STATUS_OPTIONS.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | ]); |
| | | reload(); |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getQuestionList(params) }; |
| | | const page = await getQuestionList(params); |
| | | return { |
| | | data: { |
| | | list: Array.isArray(page?.list) ? page.list : [], |
| | | pagination: page?.pagination || { total: 0 }, |
| | | }, |
| | | }; |
| | | } |
| | | |
| | | function stripHtml(html?: string) { |
| | |
| | | router.push('/tms/question/create'); |
| | | } |
| | | |
| | | function handleEdit(record: QuestionEntity) { |
| | | router.push(`/tms/question/edit/${record.id}`); |
| | | } |
| | | |
| | | function handleDelete(record: QuestionEntity) { |
| | | function handleManage() { |
| | | const rows = (getSelectRows?.() || []) as QuestionEntity[]; |
| | | if (!rows.length) { |
| | | createMessage.warning('请先勾选要管理的试题'); |
| | | return; |
| | | } |
| | | if (rows.length === 1) { |
| | | handleEdit(rows[0]!); |
| | | return; |
| | | } |
| | | Modal.confirm({ |
| | | title: '确认删除', |
| | | content: `确定删除试题「${stripHtml(record.stem) || record.questionNo}」吗?`, |
| | | title: '批量废弃', |
| | | content: `已勾选 ${rows.length} 道试题,确定全部废弃吗?`, |
| | | onOk: async () => { |
| | | await deleteQuestion(record.id!); |
| | | createMessage.success('删除成功'); |
| | | for (const row of rows) { |
| | | if (row.id) await invalidateQuestion(row.id); |
| | | } |
| | | createMessage.success('废弃成功'); |
| | | reload(); |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | function handleDetail(record: QuestionEntity) { |
| | | router.push(`/tms/question/detail/${record.id}`); |
| | | } |
| | | |
| | | function handleEdit(record: QuestionEntity) { |
| | | router.push(`/tms/question/edit/${record.id}`); |
| | | } |
| | | |
| | | async function handleInvalidate(record: QuestionEntity) { |
| | | await invalidateQuestion(record.id!); |
| | | createMessage.success('废弃成功'); |
| | | reload(); |
| | | } |
| | | |
| | | function getTableActions(record: QuestionEntity): ActionItem[] { |
| | | return [ |
| | | { icon: 'icon-ym icon-ym-btn-edit', tooltip: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { label: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { |
| | | icon: 'icon-ym icon-ym-btn-clearn', |
| | | tooltip: '删除', |
| | | label: '废弃', |
| | | color: 'error', |
| | | onClick: handleDelete.bind(null, record), |
| | | modelConfirm: { |
| | | content: `确定废弃试题「${stripHtml(record.stem) || record.questionNo}」吗?废弃后将从列表移除。`, |
| | | onOk: handleInvalidate.bind(null, record), |
| | | }, |
| | | }, |
| | | { label: '详情', onClick: handleDetail.bind(null, record) }, |
| | | ]; |
| | | } |
| | | </script> |
| | |
| | | <template #tableTitle> |
| | | <a-space> |
| | | <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleCreate">创建试题</a-button> |
| | | <a-button disabled>管理试题</a-button> |
| | | <a-button @click="handleManage">管理试题</a-button> |
| | | </a-space> |
| | | </template> |
| | | <template #bizStatus="{ record }"> |
| | |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { getTmsRecordDetail } from '#/api/x/tms/record'; |
| | | import { getAuthMediaUrl } from '#/utils/jnpf'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import { |
| | | labelOfCategory, |
| | | labelOfEvalMode, |
| | | labelOfTrainMode, |
| | | loadRecordDics, |
| | | } from './constants'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsRecordDetail' }); |
| | | |
| | |
| | | }, |
| | | ]; |
| | | |
| | | onMounted(() => { |
| | | loadData(); |
| | | onMounted(async () => { |
| | | await loadRecordDics(); |
| | | await loadData(); |
| | | }); |
| | | |
| | | async function loadData() { |
| | |
| | | |
| | | function openAttachment(url?: string) { |
| | | if (!url || url === '#') { |
| | | createMessage.info('附件预览联调后生效'); |
| | | createMessage.info('暂无可预览附件'); |
| | | return; |
| | | } |
| | | window.open(url, '_blank'); |
| | | const apiIndex = url.indexOf('/api/'); |
| | | const path = /^https?:\/\//i.test(url) && apiIndex >= 0 ? url.slice(apiIndex) : url; |
| | | window.open(getAuthMediaUrl(path, false), '_blank'); |
| | | } |
| | | </script> |
| | | |
| | |
| | | <div class="jnpf-content-wrapper-content tms-record-detail-wrap"> |
| | | <a-spin :spinning="loading" class="detail-spin"> |
| | | <div v-if="detail" class="detail-inner"> |
| | | <div class="detail-header"> |
| | | <div class="text-base font-medium">培训记录</div> |
| | | <a-button type="primary" @click="goBack">返回</a-button> |
| | | <div class="tms-page-header"> |
| | | <div class="tms-page-header__title">培训记录</div> |
| | | <div class="tms-page-header__actions"> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <ADescriptions bordered :column="2" size="middle" class="detail-desc"> |
| | |
| | | <ADescriptionsItem label="培训开始时间">{{ detail.startTime }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训结束时间">{{ detail.endTime || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训地点" :span="2">{{ detail.placeName || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训分类">{{ detail.category }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训方式">{{ detail.trainMode || detail.trainType || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训分类">{{ labelOfCategory(detail.category) }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训方式">{{ labelOfTrainMode(detail.trainMode || detail.trainType) }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训师">{{ detail.trainerName }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训考核方式">{{ detail.evalMode }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训考核方式">{{ labelOfEvalMode(detail.evalMode) }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训对象" :span="2">{{ detail.trainees || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="附件" :span="2"> |
| | | <div v-if="detail.attachments?.length" class="attach-list"> |
| | |
| | | .detail-inner { |
| | | padding: 16px 20px 24px; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .detail-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 16px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .detail-desc { |
| | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | |
| | | import { getTmsRecordDetail, updateTmsRecord } from '#/api/x/tms/record'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | defineOptions({ name: 'TmsRecordEdit' }); |
| | | |
| | |
| | | <div class="mt-1 text-gray-400 text-sm">编号:{{ detail.recordNo }}</div> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="goBack">取消</a-button> |
| | | <a-button type="primary" :loading="submitting" @click="handleSubmit">保存</a-button> |
| | | <a-button @click="goBack">{{ TMS_BTN.cancel }}</a-button> |
| | | <a-button type="primary" :loading="submitting" @click="handleSubmit">{{ TMS_BTN.save }}</a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | |
| | | <script lang="ts" setup> |
| | | import type { TmsRecordDetail } from './types'; |
| | | |
| | | import { onMounted, reactive, ref } from 'vue'; |
| | | import { onMounted, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { Table as ATable } from 'ant-design-vue'; |
| | | import dayjs from 'dayjs'; |
| | | |
| | | import { getTmsRecordDetail } from '#/api/x/tms/record'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsRecordSign' }); |
| | | |
| | |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const loading = ref(false); |
| | | const submitting = ref(false); |
| | | const detail = ref<TmsRecordDetail | null>(null); |
| | | |
| | | const form = reactive({ |
| | | userName: '', |
| | | deptName: '', |
| | | signMode: '在线签到', |
| | | }); |
| | | |
| | | const signColumns = [ |
| | | { title: '序号', key: 'index', width: 70, align: 'center', customRender: ({ index }: any) => index + 1 }, |
| | |
| | | } |
| | | } |
| | | |
| | | function handleSubmit() { |
| | | if (!form.userName.trim()) { |
| | | createMessage.warning('请输入签到人姓名'); |
| | | return; |
| | | } |
| | | if (!detail.value) return; |
| | | submitting.value = true; |
| | | try { |
| | | detail.value.signList = [ |
| | | { |
| | | id: `s-${Date.now()}`, |
| | | userName: form.userName.trim(), |
| | | deptName: form.deptName.trim() || undefined, |
| | | signTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), |
| | | signMode: form.signMode, |
| | | }, |
| | | ...(detail.value.signList || []), |
| | | ]; |
| | | form.userName = ''; |
| | | form.deptName = ''; |
| | | createMessage.success('签到成功(本地 mock,联调后写入服务端)'); |
| | | } finally { |
| | | submitting.value = false; |
| | | } |
| | | } |
| | | |
| | | function goBack() { |
| | | router.push('/tms/record'); |
| | | } |
| | |
| | | <div class="jnpf-content-wrapper-content tms-record-wrap"> |
| | | <a-spin :spinning="loading" class="record-spin"> |
| | | <div v-if="detail" class="record-inner"> |
| | | <div class="record-header"> |
| | | <div class="tms-page-header"> |
| | | <div> |
| | | <div class="text-base font-medium">培训签到</div> |
| | | <div class="mt-1 text-gray-400 text-sm"> |
| | | {{ detail.recordNo }} · {{ detail.subject }} |
| | | </div> |
| | | <div class="tms-page-header__title">培训签到</div> |
| | | <div class="tms-page-header__sub">{{ detail.recordNo }} · {{ detail.subject }}</div> |
| | | </div> |
| | | <a-button @click="goBack">返回</a-button> |
| | | <div class="tms-page-header__actions"> |
| | | <a-button @click="goBack">{{ TMS_BTN.back }}</a-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <a-form layout="inline" class="sign-form mb-4"> |
| | | <a-form-item label="姓名"> |
| | | <a-input v-model:value="form.userName" placeholder="签到人姓名" class="!w-[140px]" allow-clear /> |
| | | </a-form-item> |
| | | <a-form-item label="部门"> |
| | | <a-input v-model:value="form.deptName" placeholder="部门(可选)" class="!w-[160px]" allow-clear /> |
| | | </a-form-item> |
| | | <a-form-item label="方式"> |
| | | <a-select |
| | | v-model:value="form.signMode" |
| | | class="!w-[130px]" |
| | | :options="[ |
| | | { label: '在线签到', value: '在线签到' }, |
| | | { label: 'APP扫码', value: 'APP扫码' }, |
| | | { label: '微信扫码', value: '微信扫码' }, |
| | | ]" |
| | | /> |
| | | </a-form-item> |
| | | <a-form-item> |
| | | <a-button type="primary" :loading="submitting" @click="handleSubmit">确认签到</a-button> |
| | | </a-form-item> |
| | | </a-form> |
| | | <a-alert |
| | | class="tms-page-tip" |
| | | type="info" |
| | | show-icon |
| | | message="管理端补签/扫码签到即将接入" |
| | | description="当前请学员在「个人培训任务」中完成签到。下方为已同步的签到名单(只读)。" |
| | | /> |
| | | |
| | | <div class="section-title">已签到人员</div> |
| | | <ATable |
| | |
| | | padding: 16px 20px 24px; |
| | | } |
| | | |
| | | .record-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .section-title { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | margin: 8px 0 12px; |
| | | padding-left: 10px; |
| | | border-left: 3px solid #1677ff; |
| | | } |
| | | |
| | | .sign-form { |
| | | padding: 12px; |
| | | background: #fafafa; |
| | | border-radius: 6px; |
| | | } |
| | | </style> |
| | |
| | | import type { ArchiveStatus, RecordListMode } from './types'; |
| | | |
| | | export const ARCHIVE_STATUS_OPTIONS: { id: ArchiveStatus; fullName: string }[] = [ |
| | | { id: 'pending', fullName: '待归档' }, |
| | | { id: 'ready', fullName: '可归档' }, |
| | | { id: 'archived', fullName: '已归档' }, |
| | | { id: 'invalid', fullName: '无效' }, |
| | | ]; |
| | | import { ref } from 'vue'; |
| | | |
| | | import { useBaseStore } from '#/store'; |
| | | import { |
| | | TMS_DIC, |
| | | labelOfDic, |
| | | loadTmsDic, |
| | | type TmsDicOpt, |
| | | } from '#/views/x/tms/shared/dic'; |
| | | |
| | | export const ARCHIVE_STATUS_OPTIONS = ref<TmsDicOpt[]>([]); |
| | | export const TASK_CATEGORY_OPTIONS = ref<TmsDicOpt[]>([]); |
| | | export const TRAIN_MODE_OPTIONS = ref<TmsDicOpt[]>([]); |
| | | export const EVAL_MODE_OPTIONS = ref<TmsDicOpt[]>([]); |
| | | |
| | | export async function loadRecordDics() { |
| | | const baseStore = useBaseStore(); |
| | | const [archive, category, trainMode, evalMode] = await Promise.all([ |
| | | loadTmsDic(baseStore, TMS_DIC.archiveStatus), |
| | | loadTmsDic(baseStore, TMS_DIC.taskCategory), |
| | | loadTmsDic(baseStore, TMS_DIC.trainMode), |
| | | loadTmsDic(baseStore, TMS_DIC.evalMode), |
| | | ]); |
| | | ARCHIVE_STATUS_OPTIONS.value = archive; |
| | | TASK_CATEGORY_OPTIONS.value = category; |
| | | TRAIN_MODE_OPTIONS.value = trainMode; |
| | | EVAL_MODE_OPTIONS.value = evalMode; |
| | | } |
| | | |
| | | export const LIST_MODE_LABEL: Record<RecordListMode, string> = { |
| | | main: '培训记录', |
| | |
| | | archived: '已归档列表', |
| | | }; |
| | | |
| | | /** 各列表页路由 */ |
| | | export const RECORD_LIST_PATH: Record<RecordListMode, string> = { |
| | | main: '/tms/record', |
| | | ready: '/tms/record/ready', |
| | | archived: '/tms/record/archived', |
| | | }; |
| | | |
| | | export function labelOfArchiveStatus(status?: ArchiveStatus) { |
| | | return ARCHIVE_STATUS_OPTIONS.find((x) => x.id === status)?.fullName || status || '-'; |
| | | export function labelOfArchiveStatus(status?: ArchiveStatus | string) { |
| | | return labelOfDic(ARCHIVE_STATUS_OPTIONS.value, status); |
| | | } |
| | | |
| | | export function colorOfArchiveStatus(status?: ArchiveStatus) { |
| | | export function labelOfCategory(v?: string) { |
| | | return labelOfDic(TASK_CATEGORY_OPTIONS.value, v); |
| | | } |
| | | |
| | | export function labelOfTrainMode(v?: string) { |
| | | return labelOfDic(TRAIN_MODE_OPTIONS.value, v); |
| | | } |
| | | |
| | | export function labelOfEvalMode(v?: string) { |
| | | return labelOfDic(EVAL_MODE_OPTIONS.value, v); |
| | | } |
| | | |
| | | export function colorOfArchiveStatus(status?: ArchiveStatus | string) { |
| | | if (status === 'ready') return '#1677ff'; |
| | | if (status === 'archived') return '#52c41a'; |
| | | if (status === 'invalid') return '#ff4d4f'; |
| | |
| | | |
| | | import type { RecordListMode, TmsRecordListItem } from './types'; |
| | | |
| | | import { computed, watch } from 'vue'; |
| | | import { computed, onMounted, watch } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | |
| | | import { Modal } from 'ant-design-vue'; |
| | | |
| | | import { archiveTmsRecords, deleteTmsRecords, getTmsRecordList } from '#/api/x/tms/record'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import { LIST_MODE_LABEL, RECORD_LIST_PATH } from './constants'; |
| | | import { LIST_MODE_LABEL, RECORD_LIST_PATH, labelOfCategory, labelOfEvalMode, labelOfTrainMode, loadRecordDics } from './constants'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsRecord' }); |
| | | |
| | |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '编号', dataIndex: 'recordNo', width: 140 }, |
| | | { title: '培训分类', dataIndex: 'category', width: 120 }, |
| | | { |
| | | title: '培训分类', |
| | | dataIndex: 'category', |
| | | width: 120, |
| | | customRender: ({ record }) => labelOfCategory((record as TmsRecordListItem).category), |
| | | }, |
| | | { |
| | | title: '培训主题', |
| | | dataIndex: 'subject', |
| | |
| | | slots: { default: 'subject' }, |
| | | }, |
| | | { title: '培训师', dataIndex: 'trainerName', width: 100 }, |
| | | { title: '培训类型', dataIndex: 'trainType', width: 110 }, |
| | | { |
| | | title: '培训类型', |
| | | dataIndex: 'trainType', |
| | | width: 110, |
| | | customRender: ({ record }) => { |
| | | const row = record as TmsRecordListItem; |
| | | return labelOfTrainMode(row.trainMode || row.trainType); |
| | | }, |
| | | }, |
| | | { title: '培训开始时间', dataIndex: 'startTime', width: 160 }, |
| | | { title: '考核方式', dataIndex: 'evalMode', width: 110 }, |
| | | { |
| | | title: '考核方式', |
| | | dataIndex: 'evalMode', |
| | | width: 110, |
| | | customRender: ({ record }) => labelOfEvalMode((record as TmsRecordListItem).evalMode), |
| | | }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload, getSelectRows }] = useVxeTable({ |
| | |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | showAdvancedButton: true, |
| | | alwaysShowLines: 1, |
| | | autoAdvancedLine: 1, |
| | | schemas: [ |
| | | { |
| | |
| | | () => reload(), |
| | | ); |
| | | |
| | | onMounted(() => { |
| | | loadRecordDics(); |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | const page = await getTmsRecordList({ |
| | | ...params, |
| | | listMode: listMode.value, |
| | | }); |
| | | return { |
| | | data: await getTmsRecordList({ |
| | | ...params, |
| | | listMode: listMode.value, |
| | | }), |
| | | data: { |
| | | list: Array.isArray(page?.list) ? page.list : [], |
| | | pagination: page?.pagination || { total: 0 }, |
| | | }, |
| | | }; |
| | | } |
| | | |
| | |
| | | router.push(RECORD_LIST_PATH[mode]); |
| | | } |
| | | |
| | | function handleQrcode() { |
| | | const row = requireOneRow(); |
| | | if (!row) return; |
| | | createMessage.success(`已生成「${row.recordNo}」签到二维码(接口联调后生效)`); |
| | | } |
| | | |
| | | function handleAdd() { |
| | | createMessage.info('新增培训记录(联调任务发布后由系统自动生成,手工新增待后端接口)'); |
| | | } |
| | | |
| | | function handleEdit() { |
| | | const row = requireOneRow(); |
| | | if (!row) return; |
| | | if (row.archiveStatus === 'archived' || isArchivedView.value) { |
| | | createMessage.warning('已归档记录不可修改'); |
| | | return; |
| | | } |
| | | router.push(`/tms/record/edit/${row.id}`); |
| | | } |
| | | |
| | |
| | | }); |
| | | } |
| | | |
| | | function handleSign() { |
| | | const row = requireOneRow(); |
| | | if (!row) return; |
| | | router.push(`/tms/record/sign/${row.id}`); |
| | | function handleAdminSignUnavailable() { |
| | | createMessage.info('管理端补签/扫码签到即将接入,请学员在「个人培训任务」中完成签到'); |
| | | } |
| | | |
| | | function handleDelete() { |
| | |
| | | } |
| | | |
| | | function getTableActions(record: TmsRecordListItem): ActionItem[] { |
| | | const actions: ActionItem[] = [{ label: '查看', onClick: handleView.bind(null, record) }]; |
| | | if (isMainView.value) { |
| | | actions.push({ |
| | | label: '签到', |
| | | onClick: () => router.push(`/tms/record/sign/${record.id}`), |
| | | }); |
| | | } |
| | | return actions; |
| | | return [{ label: TMS_BTN.detail, onClick: handleView.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | |
| | | <template #tableTitle> |
| | | <!-- 1. 培训记录 --> |
| | | <a-space v-if="isMainView" wrap> |
| | | <a-button pre-icon="icon-ym icon-ym-generator-qrcode" @click="handleQrcode">生成二维码</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-btn-edit" @click="handleEdit">修改</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-btn-preview" @click="handleView()">查看</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-extend-form" @click="handleSign">签到</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-btn-edit" @click="handleEdit">{{ TMS_BTN.edit }}</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-btn-preview" @click="handleView()">{{ TMS_BTN.detail }}</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-extend-form" @click="handleAdminSignUnavailable"> |
| | | {{ TMS_BTN.sign }} |
| | | </a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-file-text" @click="goList('ready')">可归档列表</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-extend-folder" @click="goList('archived')">已归档列表</a-button> |
| | | <span class="text-gray-400 text-sm">当前:{{ listModeTitle }}</span> |
| | | <span class="tms-toolbar-hint">当前:{{ listModeTitle }} · 记录由任务发布自动生成</span> |
| | | </a-space> |
| | | |
| | | <!-- 2. 可归档列表 --> |
| | | <a-space v-else-if="isReadyView" wrap> |
| | | <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleAdd">新增</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-btn-edit" @click="handleEdit">修改</a-button> |
| | | <a-button danger pre-icon="icon-ym icon-ym-btn-clearn" @click="handleDelete">删除</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-extend-folder" @click="handleArchive">归档</a-button> |
| | | <a-button @click="goList('main')">返回</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-btn-edit" @click="handleEdit">{{ TMS_BTN.edit }}</a-button> |
| | | <a-button danger pre-icon="icon-ym icon-ym-btn-clearn" @click="handleDelete"> |
| | | {{ TMS_BTN.delete }} |
| | | </a-button> |
| | | <a-button type="primary" pre-icon="icon-ym icon-ym-extend-folder" @click="handleArchive"> |
| | | {{ TMS_BTN.archive }} |
| | | </a-button> |
| | | <a-button @click="goList('main')">{{ TMS_BTN.back }}</a-button> |
| | | <a-button pre-icon="icon-ym icon-ym-file-text" @click="goList('archived')">已归档列表</a-button> |
| | | <span class="text-gray-400 text-sm">当前:{{ listModeTitle }}</span> |
| | | <span class="tms-toolbar-hint">当前:{{ listModeTitle }}</span> |
| | | </a-space> |
| | | |
| | | <!-- 3. 已归档列表 --> |
| | | <a-space v-else wrap> |
| | | <a-button @click="handleView()">查看</a-button> |
| | | <a-button @click="handleEdit">修改</a-button> |
| | | <a-button @click="goList('main')">返回</a-button> |
| | | <a-button type="primary" @click="goList('ready')">可归档列表</a-button> |
| | | <span class="text-gray-400 text-sm">当前:{{ listModeTitle }}</span> |
| | | <a-button @click="handleView()">{{ TMS_BTN.detail }}</a-button> |
| | | <a-button @click="goList('main')">{{ TMS_BTN.back }}</a-button> |
| | | <a-button type="primary" ghost @click="goList('ready')">可归档列表</a-button> |
| | | <span class="tms-toolbar-hint">当前:{{ listModeTitle }} · 已归档不可修改</span> |
| | | </a-space> |
| | | </template> |
| | | <template #subject="{ record }"> |
| | |
| | | attachments?: TmsRecordAttachment[]; |
| | | /** 参加人员名单 */ |
| | | participants?: TmsRecordParticipant[]; |
| | | signList: TmsRecordSignItem[]; |
| | | signList?: TmsRecordSignItem[]; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { SelfTestPaper, SelfTestQuestionItem } from './types'; |
| | | |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { useTabbarStore } from '@vben/stores'; |
| | | |
| | | import { getSelfTestInfo } from '#/api/x/tms/selfTest'; |
| | | import { labelOfType } from '#/views/x/tms/question/constants'; |
| | | |
| | | defineOptions({ name: 'TmsSelfTestDetail' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | const tabbarStore = useTabbarStore(); |
| | | |
| | | const loading = ref(false); |
| | | const detail = ref<SelfTestPaper | null>(null); |
| | | /** all | wrong */ |
| | | const filterMode = ref<'all' | 'wrong'>('all'); |
| | | |
| | | const wrongCount = computed( |
| | | () => detail.value?.questions?.filter((q) => q.isRight !== '1').length || 0, |
| | | ); |
| | | |
| | | const displayQuestions = computed(() => { |
| | | const list = detail.value?.questions || []; |
| | | if (filterMode.value === 'wrong') { |
| | | return list.filter((q) => q.isRight !== '1'); |
| | | } |
| | | return list; |
| | | }); |
| | | |
| | | function stripHtml(html?: string) { |
| | | if (!html) return ''; |
| | | return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); |
| | | } |
| | | |
| | | function formatAnswer(ans?: string) { |
| | | if (!ans) return '未作答'; |
| | | return ans.split(',').filter(Boolean).join('、'); |
| | | } |
| | | |
| | | function optionClass(q: SelfTestQuestionItem, label: string) { |
| | | const correctSet = new Set((q.correctAnswer || '').split(',').filter(Boolean)); |
| | | const userSet = new Set((q.userAnswer || '').split(',').filter(Boolean)); |
| | | const isCorrectOpt = correctSet.has(label); |
| | | const isUserOpt = userSet.has(label); |
| | | if (isCorrectOpt) return 'opt-correct'; |
| | | if (isUserOpt && !isCorrectOpt) return 'opt-wrong'; |
| | | return ''; |
| | | } |
| | | |
| | | async function loadDetail() { |
| | | const id = String(route.params.id || ''); |
| | | if (!id) { |
| | | router.replace('/tms/selfTest/records'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | detail.value = await getSelfTestInfo(id); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载检测详情失败'); |
| | | router.replace('/tms/selfTest/records'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | onMounted(() => { |
| | | tabbarStore.renderRouteView = true; |
| | | loadDetail(); |
| | | }); |
| | | |
| | | function goBack() { |
| | | tabbarStore.renderRouteView = true; |
| | | router.push('/tms/selfTest/records'); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content tms-self-test-detail" v-loading="loading"> |
| | | <div class="page-header" v-if="detail"> |
| | | <div> |
| | | <div class="text-base font-medium">检测详情 · {{ detail.bankName }}</div> |
| | | <div class="mt-1 text-gray-400 text-sm"> |
| | | 得分:{{ detail.correctCount ?? 0 }} / {{ detail.totalCount ?? detail.questions?.length ?? 0 }} |
| | | <span v-if="detail.scoreRate != null" class="ml-2">正确率 {{ detail.scoreRate }}%</span> |
| | | <span v-if="detail.submitTime" class="ml-3">交卷时间:{{ detail.submitTime }}</span> |
| | | </div> |
| | | </div> |
| | | <a-space> |
| | | <a-radio-group v-model:value="filterMode" button-style="solid" size="small"> |
| | | <a-radio-button value="all">全部题目</a-radio-button> |
| | | <a-radio-button value="wrong">仅错题({{ wrongCount }})</a-radio-button> |
| | | </a-radio-group> |
| | | <a-button @click="goBack">返回记录</a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | | <a-empty v-if="!loading && detail && !displayQuestions.length" description="暂无题目" /> |
| | | |
| | | <div v-for="(q, idx) in displayQuestions" :key="`${q.id}_${idx}`" class="question-card"> |
| | | <div class="q-title"> |
| | | <span class="q-index">{{ q.sortNo || idx + 1 }}.</span> |
| | | <span class="q-type">{{ labelOfType(q.questionType) }}</span> |
| | | <a-tag :color="q.isRight === '1' ? 'success' : 'error'" class="!ml-2"> |
| | | {{ q.isRight === '1' ? '正确' : '错误' }} |
| | | </a-tag> |
| | | </div> |
| | | <div class="q-stem">{{ stripHtml(q.stem) }}</div> |
| | | <div class="q-options"> |
| | | <div |
| | | v-for="opt in q.options" |
| | | :key="opt.optionLabel" |
| | | class="q-option" |
| | | :class="optionClass(q, opt.optionLabel)" |
| | | > |
| | | {{ opt.optionLabel }}. {{ opt.optionContent }} |
| | | </div> |
| | | </div> |
| | | <div class="q-answer"> |
| | | <span>你的答案:{{ formatAnswer(q.userAnswer) }}</span> |
| | | <span class="ml-4">正确答案:{{ formatAnswer(q.correctAnswer) }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-self-test-detail { |
| | | background: #fff; |
| | | padding: 20px 24px; |
| | | height: 100%; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .page-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: flex-start; |
| | | gap: 16px; |
| | | margin-bottom: 20px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .question-card { |
| | | padding: 16px 0; |
| | | border-bottom: 1px solid #f5f5f5; |
| | | } |
| | | |
| | | .q-title { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .q-index { |
| | | font-weight: 600; |
| | | margin-right: 6px; |
| | | } |
| | | |
| | | .q-type { |
| | | color: rgba(0, 0, 0, 0.45); |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .q-stem { |
| | | font-size: 15px; |
| | | line-height: 1.7; |
| | | margin-bottom: 12px; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .q-options { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .q-option { |
| | | padding: 6px 10px; |
| | | border-radius: 4px; |
| | | background: #fafafa; |
| | | color: rgba(0, 0, 0, 0.75); |
| | | } |
| | | |
| | | .q-option.opt-correct { |
| | | background: #f6ffed; |
| | | color: #389e0d; |
| | | border: 1px solid #b7eb8f; |
| | | } |
| | | |
| | | .q-option.opt-wrong { |
| | | background: #fff2f0; |
| | | color: #cf1322; |
| | | border: 1px solid #ffccc7; |
| | | } |
| | | |
| | | .q-answer { |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.55); |
| | | } |
| | | </style> |
| | |
| | | <script lang="ts" setup> |
| | | import type { SelfTestPaper, SelfTestQuestionItem } from './types'; |
| | | import type { SelfTestPaper, SelfTestQuestionItem, SelfTestSubmitResult } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | import { |
| | | computed, |
| | | nextTick, |
| | | onDeactivated, |
| | | onMounted, |
| | | onUnmounted, |
| | | reactive, |
| | | ref, |
| | | watch, |
| | | } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { useTabbarStore } from '@vben/stores'; |
| | | import { AppstoreOutlined } from '@ant-design/icons-vue'; |
| | | |
| | | import { labelOfType } from '#/views/x/tms/question/constants'; |
| | | import { saveSelfTest, submitSelfTest } from '#/api/x/tms/selfTest'; |
| | | import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants'; |
| | | |
| | | defineOptions({ name: 'TmsSelfTestExam' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | const tabbarStore = useTabbarStore(); |
| | | |
| | | const paper = ref<SelfTestPaper | null>(null); |
| | | const currentIndex = ref(0); |
| | | /** questionId -> answer: single/judge 存 optionLabel;multi 存 label[] */ |
| | | const answers = reactive<Record<string, string | string[]>>({}); |
| | | const submitted = ref(false); |
| | | const submitting = ref(false); |
| | | const saving = ref(false); |
| | | const answersReady = ref(false); |
| | | const scoreText = ref(''); |
| | | const resultModalOpen = ref(false); |
| | | const submitResult = ref<SelfTestSubmitResult | null>(null); |
| | | const resultFilter = ref<'all' | 'wrong'>('all'); |
| | | const sheetOpen = ref(false); |
| | | let saveTimer: ReturnType<typeof setTimeout> | null = null; |
| | | |
| | | const current = computed(() => paper.value?.questions?.[currentIndex.value]); |
| | | const total = computed(() => paper.value?.questions?.length || 0); |
| | | |
| | | onMounted(() => { |
| | | const wrongCount = computed( |
| | | () => paper.value?.questions?.filter((q) => !isCorrect(q)).length || 0, |
| | | ); |
| | | |
| | | const rightCount = computed(() => submitResult.value?.correctCount ?? 0); |
| | | |
| | | const resultQuestions = computed(() => { |
| | | const list = paper.value?.questions || []; |
| | | if (resultFilter.value === 'wrong') { |
| | | return list.filter((q) => !isCorrect(q)); |
| | | } |
| | | return list; |
| | | }); |
| | | const answeredCount = computed( |
| | | () => (paper.value?.questions || []).filter((q) => isAnswered(q)).length, |
| | | ); |
| | | |
| | | onMounted(async () => { |
| | | // 若此前 refreshTab 中断导致全局内容区不渲染,这里强制恢复 |
| | | tabbarStore.renderRouteView = true; |
| | | await loadQuestionDics(); |
| | | |
| | | const raw = sessionStorage.getItem('tms_self_test_paper'); |
| | | if (!raw) { |
| | | createMessage.warning('请先设置抽题条件'); |
| | | router.replace('/tms/selfTest'); |
| | | goBack(); |
| | | return; |
| | | } |
| | | try { |
| | | paper.value = JSON.parse(raw); |
| | | // 继续考试:恢复已保存的作答 |
| | | const ansRaw = sessionStorage.getItem('tms_self_test_answers'); |
| | | if (ansRaw) { |
| | | try { |
| | | const saved = JSON.parse(ansRaw) as Record<string, string | string[]>; |
| | | Object.keys(saved || {}).forEach((qid) => { |
| | | answers[qid] = saved[qid]; |
| | | }); |
| | | } catch { |
| | | // ignore |
| | | } |
| | | sessionStorage.removeItem('tms_self_test_answers'); |
| | | } |
| | | } catch { |
| | | router.replace('/tms/selfTest'); |
| | | goBack(); |
| | | return; |
| | | } |
| | | answersReady.value = true; |
| | | }); |
| | | |
| | | onDeactivated(() => { |
| | | resultModalOpen.value = false; |
| | | sheetOpen.value = false; |
| | | }); |
| | | |
| | | onUnmounted(() => { |
| | | if (saveTimer) { |
| | | clearTimeout(saveTimer); |
| | | saveTimer = null; |
| | | } |
| | | resultModalOpen.value = false; |
| | | sheetOpen.value = false; |
| | | // 再次确保离开本页后内容区可渲染 |
| | | tabbarStore.renderRouteView = true; |
| | | }); |
| | | |
| | | /** 作答变更后自动暂存,方便中途离开再继续 */ |
| | | watch( |
| | | answers, |
| | | () => { |
| | | if (!answersReady.value || submitted.value || !paper.value) return; |
| | | if (saveTimer) clearTimeout(saveTimer); |
| | | saveTimer = setTimeout(() => { |
| | | void persistAnswers(false); |
| | | }, 1500); |
| | | }, |
| | | { deep: true }, |
| | | ); |
| | | |
| | | function stripHtml(html?: string) { |
| | | if (!html) return ''; |
| | | return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); |
| | | } |
| | | |
| | | function isAnswered(q: SelfTestQuestionItem) { |
| | | const val = answers[q.id]; |
| | | if (Array.isArray(val)) return val.length > 0; |
| | | return String(val ?? '').trim().length > 0; |
| | | } |
| | | |
| | | function goPrev() { |
| | |
| | | if (currentIndex.value < total.value - 1) currentIndex.value += 1; |
| | | } |
| | | |
| | | function goBack() { |
| | | router.push('/tms/selfTest'); |
| | | function goToQuestion(index: number) { |
| | | if (index < 0 || index >= total.value) { |
| | | sheetOpen.value = false; |
| | | return; |
| | | } |
| | | currentIndex.value = index; |
| | | sheetOpen.value = false; |
| | | } |
| | | |
| | | function openSheet() { |
| | | sheetOpen.value = true; |
| | | } |
| | | |
| | | function getSettingsPath() { |
| | | return String(route.meta.currentActiveMenu || '/tms/selfTest'); |
| | | } |
| | | |
| | | /** |
| | | * 与在线考试一致:按 path 跳转,不调用 closeCurrentTab。 |
| | | * closeCurrentTab 内部按 route.name 跳转,动态菜单场景容易跳到空页并影响全局内容区。 |
| | | */ |
| | | async function goBack() { |
| | | if (!submitted.value && paper.value) { |
| | | if (saveTimer) { |
| | | clearTimeout(saveTimer); |
| | | saveTimer = null; |
| | | } |
| | | await persistAnswers(false); |
| | | } |
| | | resultModalOpen.value = false; |
| | | tabbarStore.renderRouteView = true; |
| | | router.push(getSettingsPath()); |
| | | } |
| | | |
| | | async function persistAnswers(showTip: boolean) { |
| | | if (!paper.value || submitted.value || saving.value) return; |
| | | const payload = { ...answers }; |
| | | if (!Object.keys(payload).length) { |
| | | if (showTip) createMessage.info('暂无作答可保存'); |
| | | return; |
| | | } |
| | | saving.value = true; |
| | | try { |
| | | await saveSelfTest(paper.value.paperId, { answers: payload }); |
| | | if (showTip) createMessage.success('已暂存,可稍后继续考试'); |
| | | } catch (e: any) { |
| | | if (showTip) createMessage.error(e?.message || '暂存失败'); |
| | | } finally { |
| | | saving.value = false; |
| | | } |
| | | } |
| | | |
| | | async function handleSave() { |
| | | if (saveTimer) { |
| | | clearTimeout(saveTimer); |
| | | saveTimer = null; |
| | | } |
| | | await persistAnswers(true); |
| | | } |
| | | |
| | | function closeResultModal() { |
| | | resultModalOpen.value = false; |
| | | } |
| | | |
| | | async function openResultModal() { |
| | | if (!submitResult.value && paper.value) { |
| | | const qs = paper.value.questions || []; |
| | | let correct = 0; |
| | | qs.forEach((q) => { |
| | | if (isCorrect(q)) correct += 1; |
| | | }); |
| | | submitResult.value = { |
| | | paperId: paper.value.paperId, |
| | | totalCount: qs.length, |
| | | correctCount: correct, |
| | | scoreRate: qs.length ? Math.round((correct * 10000) / qs.length) / 100 : 0, |
| | | }; |
| | | } |
| | | resultFilter.value = 'all'; |
| | | await nextTick(); |
| | | resultModalOpen.value = true; |
| | | } |
| | | |
| | | function goRecords() { |
| | | resultModalOpen.value = false; |
| | | tabbarStore.renderRouteView = true; |
| | | router.push('/tms/selfTest/records'); |
| | | } |
| | | |
| | | function correctLabelsOf(q: SelfTestQuestionItem) { |
| | | return q.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel); |
| | | } |
| | | |
| | | function userAnswerText(q: SelfTestQuestionItem) { |
| | | const ans = answers[q.id]; |
| | | if (q.questionType === 'multi') { |
| | | const selected = Array.isArray(ans) ? [...ans].sort() : []; |
| | | return selected.length ? selected.join('、') : '未作答'; |
| | | } |
| | | return String(ans || '') || '未作答'; |
| | | } |
| | | |
| | | function isCorrect(q: SelfTestQuestionItem): boolean { |
| | | const ans = answers[q.id]; |
| | | const correctLabels = q.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel); |
| | | const correctLabels = correctLabelsOf(q); |
| | | if (q.questionType === 'multi') { |
| | | const selected = Array.isArray(ans) ? [...ans].sort() : []; |
| | | return selected.join(',') === [...correctLabels].sort().join(','); |
| | |
| | | return String(ans || '') === String(correctLabels[0] || ''); |
| | | } |
| | | |
| | | function handleSubmit() { |
| | | if (!paper.value) return; |
| | | const qs = paper.value.questions; |
| | | let right = 0; |
| | | qs.forEach((q) => { |
| | | if (isCorrect(q)) right += 1; |
| | | }); |
| | | submitted.value = true; |
| | | scoreText.value = `${right} / ${qs.length}`; |
| | | createMessage.success(`检测完成,正确 ${right} 题,共 ${qs.length} 题`); |
| | | function optionClass(q: SelfTestQuestionItem, label: string) { |
| | | const correctSet = new Set(correctLabelsOf(q)); |
| | | const ans = answers[q.id]; |
| | | const userSet = new Set( |
| | | q.questionType === 'multi' |
| | | ? Array.isArray(ans) |
| | | ? ans |
| | | : [] |
| | | : ans |
| | | ? [String(ans)] |
| | | : [], |
| | | ); |
| | | if (correctSet.has(label)) return 'opt-correct'; |
| | | if (userSet.has(label) && !correctSet.has(label)) return 'opt-wrong'; |
| | | return ''; |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | if (!paper.value || submitting.value) return; |
| | | if (saveTimer) { |
| | | clearTimeout(saveTimer); |
| | | saveTimer = null; |
| | | } |
| | | submitting.value = true; |
| | | try { |
| | | const result = await submitSelfTest(paper.value.paperId, { answers: { ...answers } }); |
| | | submitted.value = true; |
| | | submitResult.value = result; |
| | | scoreText.value = `${result.correctCount} / ${result.totalCount}`; |
| | | await openResultModal(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '交卷失败'); |
| | | } finally { |
| | | submitting.value = false; |
| | | } |
| | | } |
| | | </script> |
| | | |
| | |
| | | </div> |
| | | </div> |
| | | <a-space> |
| | | <a-button v-if="submitted" type="link" @click="openResultModal">查看本次结果</a-button> |
| | | <a-button v-if="submitted" type="link" @click="goRecords">检测记录</a-button> |
| | | <a-button @click="goBack">返回设置</a-button> |
| | | <a-button type="primary" :disabled="submitted" @click="handleSubmit">交卷</a-button> |
| | | <a-button |
| | | v-if="!submitted" |
| | | :loading="saving" |
| | | :disabled="submitting" |
| | | @click="handleSave" |
| | | > |
| | | 暂存 |
| | | </a-button> |
| | | <a-button type="primary" :disabled="submitted" :loading="submitting" @click="handleSubmit"> |
| | | 交卷 |
| | | </a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | | <div v-if="current" class="tms-exam-body"> |
| | | <div class="mb-3 text-sm text-gray-500"> |
| | | {{ labelOfType(current.questionType) }} |
| | | <div class="q-meta mb-3"> |
| | | <div class="text-sm text-gray-500"> |
| | | {{ labelOfType(current.questionType) }} |
| | | </div> |
| | | <a-tooltip title="答题卡"> |
| | | <button type="button" class="sheet-icon-btn" aria-label="答题卡" @click="openSheet"> |
| | | <AppstoreOutlined /> |
| | | </button> |
| | | </a-tooltip> |
| | | </div> |
| | | <div class="stem mb-4">{{ stripHtml(current.stem) }}</div> |
| | | |
| | | <!-- 单选 / 判断 --> |
| | | <a-radio-group |
| | | v-if="current.questionType === 'single' || current.questionType === 'judge'" |
| | | v-model:value="answers[current.id]" |
| | |
| | | </a-radio> |
| | | </a-radio-group> |
| | | |
| | | <!-- 多选 --> |
| | | <a-checkbox-group |
| | | v-else-if="current.questionType === 'multi'" |
| | | v-model:value="answers[current.id]" |
| | |
| | | |
| | | <div v-if="submitted" class="mt-4 text-sm" :class="isCorrect(current) ? 'text-green-600' : 'text-red-500'"> |
| | | {{ isCorrect(current) ? '回答正确' : '回答错误' }} |
| | | · 正确答案: |
| | | {{ current.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel).join('、') }} |
| | | · 正确答案:{{ correctLabelsOf(current).join('、') }} |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="tms-exam-footer"> |
| | | <a-button :disabled="currentIndex <= 0" @click="goPrev">上一题</a-button> |
| | | <a-button :disabled="currentIndex >= total - 1" @click="goNext">下一题</a-button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="sheetOpen && paper" class="tms-sheet-overlay" @click.self="sheetOpen = false"> |
| | | <div class="tms-sheet-dialog" role="dialog" aria-modal="true"> |
| | | <div class="sheet-head"> |
| | | <div> |
| | | <div class="sheet-title">答题卡</div> |
| | | <div class="sheet-sub">已答 {{ answeredCount }} / {{ total }},点击题号可切换</div> |
| | | </div> |
| | | <button type="button" class="tms-result-close" aria-label="关闭" @click="sheetOpen = false">×</button> |
| | | </div> |
| | | <div class="sheet-legend"> |
| | | <span class="legend-chip current">当前</span> |
| | | <span class="legend-chip answered">已答</span> |
| | | <span class="legend-chip unanswered">未答</span> |
| | | </div> |
| | | <div class="sheet-grid"> |
| | | <button |
| | | v-for="(q, idx) in paper.questions" |
| | | :key="q.id" |
| | | type="button" |
| | | class="sheet-item" |
| | | :class="{ |
| | | current: idx === currentIndex, |
| | | answered: isAnswered(q), |
| | | unanswered: !isAnswered(q), |
| | | }" |
| | | @click="goToQuestion(idx)" |
| | | > |
| | | {{ idx + 1 }} |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 不用 Teleport 到 body,避免 KeepAlive 场景下遮罩残留导致全局无法点击/白屏 --> |
| | | <div |
| | | v-if="resultModalOpen && submitResult && paper" |
| | | class="tms-result-overlay" |
| | | @click.self="closeResultModal" |
| | | > |
| | | <div class="tms-result-dialog" role="dialog" aria-modal="true"> |
| | | <button type="button" class="tms-result-close" aria-label="关闭" @click="closeResultModal">×</button> |
| | | |
| | | <div class="result-hero"> |
| | | <div class="hero-title">本次检测结果</div> |
| | | <div class="hero-bank">{{ paper.bankName }}</div> |
| | | <div class="hero-stats"> |
| | | <div class="stat-item"> |
| | | <div class="stat-value">{{ rightCount }}</div> |
| | | <div class="stat-label">答对</div> |
| | | </div> |
| | | <div class="stat-divider" /> |
| | | <div class="stat-item"> |
| | | <div class="stat-value">{{ wrongCount }}</div> |
| | | <div class="stat-label">答错</div> |
| | | </div> |
| | | <div class="stat-divider" /> |
| | | <div class="stat-item"> |
| | | <div class="stat-value">{{ submitResult.scoreRate }}%</div> |
| | | <div class="stat-label">正确率</div> |
| | | </div> |
| | | <div class="stat-divider" /> |
| | | <div class="stat-item"> |
| | | <div class="stat-value">{{ submitResult.correctCount }}/{{ submitResult.totalCount }}</div> |
| | | <div class="stat-label">得分</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="result-toolbar"> |
| | | <a-radio-group v-model:value="resultFilter" button-style="solid" size="small"> |
| | | <a-radio-button value="all">全部题目({{ total }})</a-radio-button> |
| | | <a-radio-button value="wrong">仅错题({{ wrongCount }})</a-radio-button> |
| | | </a-radio-group> |
| | | <div class="legend"> |
| | | <span class="legend-item correct">正确答案</span> |
| | | <span class="legend-item wrong">你的错误选项</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="result-list"> |
| | | <a-empty v-if="!resultQuestions.length" description="暂无题目" /> |
| | | <div |
| | | v-for="(q, idx) in resultQuestions" |
| | | :key="q.id" |
| | | class="question-card" |
| | | :class="isCorrect(q) ? 'is-right' : 'is-wrong'" |
| | | > |
| | | <div class="q-head"> |
| | | <div class="q-head-left"> |
| | | <span class="q-index">第 {{ idx + 1 }} 题</span> |
| | | <span class="q-type">{{ labelOfType(q.questionType) }}</span> |
| | | </div> |
| | | <a-tag :color="isCorrect(q) ? 'success' : 'error'"> |
| | | {{ isCorrect(q) ? '回答正确' : '回答错误' }} |
| | | </a-tag> |
| | | </div> |
| | | <div class="q-stem">{{ stripHtml(q.stem) }}</div> |
| | | <div class="q-options"> |
| | | <div |
| | | v-for="opt in q.options" |
| | | :key="opt.optionLabel" |
| | | class="q-option" |
| | | :class="optionClass(q, opt.optionLabel)" |
| | | > |
| | | <span class="opt-label">{{ opt.optionLabel }}</span> |
| | | <span class="opt-content">{{ opt.optionContent }}</span> |
| | | </div> |
| | | </div> |
| | | <div class="q-answer-bar"> |
| | | <div> |
| | | <span class="ans-label">你的答案</span> |
| | | <span :class="isCorrect(q) ? 'ans-ok' : 'ans-bad'">{{ userAnswerText(q) }}</span> |
| | | </div> |
| | | <div> |
| | | <span class="ans-label">正确答案</span> |
| | | <span class="ans-ok">{{ correctLabelsOf(q).join('、') || '-' }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="result-footer"> |
| | | <a-button @click="closeResultModal">关闭</a-button> |
| | | <a-button type="primary" @click="goRecords">我的检测记录</a-button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | |
| | | overflow: auto; |
| | | } |
| | | |
| | | .q-meta { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .sheet-icon-btn { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | width: 32px; |
| | | height: 32px; |
| | | border: 1px solid #d9d9d9; |
| | | border-radius: 6px; |
| | | background: #fff; |
| | | color: #1677ff; |
| | | font-size: 16px; |
| | | cursor: pointer; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .sheet-icon-btn:hover { |
| | | border-color: #1677ff; |
| | | background: #e6f4ff; |
| | | } |
| | | |
| | | .stem { |
| | | font-size: 15px; |
| | | line-height: 1.7; |
| | |
| | | padding-top: 16px; |
| | | border-top: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .tms-sheet-overlay { |
| | | position: fixed; |
| | | inset: 0; |
| | | z-index: 1900; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding: 24px; |
| | | background: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .tms-sheet-dialog { |
| | | width: min(520px, 100%); |
| | | max-height: min(70vh, 640px); |
| | | display: flex; |
| | | flex-direction: column; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .sheet-head { |
| | | position: relative; |
| | | flex-shrink: 0; |
| | | padding: 18px 48px 12px 20px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .sheet-title { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .sheet-sub { |
| | | margin-top: 4px; |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .sheet-legend { |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | gap: 16px; |
| | | padding: 12px 20px 0; |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.55); |
| | | } |
| | | |
| | | .legend-chip::before { |
| | | content: ''; |
| | | display: inline-block; |
| | | width: 12px; |
| | | height: 12px; |
| | | margin-right: 6px; |
| | | border-radius: 3px; |
| | | vertical-align: -2px; |
| | | } |
| | | |
| | | .legend-chip.current::before { |
| | | background: #fff; |
| | | border: 2px solid #1677ff; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .legend-chip.answered::before { |
| | | background: #1677ff; |
| | | } |
| | | |
| | | .legend-chip.unanswered::before { |
| | | background: #f5f5f5; |
| | | border: 1px solid #d9d9d9; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .sheet-grid { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow: auto; |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); |
| | | gap: 10px; |
| | | padding: 16px 20px 20px; |
| | | } |
| | | |
| | | .sheet-item { |
| | | height: 40px; |
| | | border-radius: 8px; |
| | | border: 1px solid #d9d9d9; |
| | | background: #fafafa; |
| | | color: rgba(0, 0, 0, 0.65); |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | cursor: pointer; |
| | | transition: all 0.15s ease; |
| | | } |
| | | |
| | | .sheet-item.answered { |
| | | background: #1677ff; |
| | | border-color: #1677ff; |
| | | color: #fff; |
| | | } |
| | | |
| | | .sheet-item.unanswered { |
| | | background: #f5f5f5; |
| | | border-color: #d9d9d9; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .sheet-item.current { |
| | | box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.35); |
| | | } |
| | | |
| | | .sheet-item.current.unanswered { |
| | | border-color: #1677ff; |
| | | color: #1677ff; |
| | | background: #e6f4ff; |
| | | } |
| | | |
| | | .sheet-item:hover { |
| | | filter: brightness(0.97); |
| | | } |
| | | |
| | | .tms-result-overlay { |
| | | position: fixed; |
| | | inset: 0; |
| | | z-index: 2000; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding: 24px; |
| | | background: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .tms-result-dialog { |
| | | position: relative; |
| | | width: min(720px, 100%); |
| | | max-height: min(80vh, 780px); |
| | | display: flex; |
| | | flex-direction: column; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .tms-result-close { |
| | | position: absolute; |
| | | top: 10px; |
| | | right: 12px; |
| | | z-index: 2; |
| | | width: 32px; |
| | | height: 32px; |
| | | border: none; |
| | | border-radius: 6px; |
| | | background: transparent; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | cursor: pointer; |
| | | display: inline-flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 22px; |
| | | line-height: 1; |
| | | } |
| | | |
| | | .tms-result-close:hover { |
| | | background: rgba(0, 0, 0, 0.06); |
| | | color: rgba(0, 0, 0, 0.75); |
| | | } |
| | | |
| | | .result-hero { |
| | | flex-shrink: 0; |
| | | padding: 24px 28px 20px; |
| | | background: linear-gradient(135deg, #f0f7ff 0%, #f8fbff 55%, #ffffff 100%); |
| | | border-bottom: 1px solid #eef2f7; |
| | | } |
| | | |
| | | .hero-title { |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | padding-right: 36px; |
| | | } |
| | | |
| | | .hero-bank { |
| | | margin-top: 4px; |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .hero-stats { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | margin-top: 18px; |
| | | padding: 14px 8px; |
| | | background: #fff; |
| | | border: 1px solid #e8eef5; |
| | | border-radius: 10px; |
| | | } |
| | | |
| | | .stat-item { |
| | | flex: 1; |
| | | text-align: center; |
| | | } |
| | | |
| | | .stat-value { |
| | | font-size: 22px; |
| | | font-weight: 700; |
| | | line-height: 1.2; |
| | | color: #1677ff; |
| | | } |
| | | |
| | | .stat-label { |
| | | margin-top: 4px; |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .stat-divider { |
| | | width: 1px; |
| | | height: 28px; |
| | | background: #eef2f7; |
| | | } |
| | | |
| | | .result-toolbar { |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | gap: 12px; |
| | | flex-wrap: wrap; |
| | | padding: 12px 28px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .legend { |
| | | display: flex; |
| | | gap: 12px; |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .legend-item::before { |
| | | content: ''; |
| | | display: inline-block; |
| | | width: 10px; |
| | | height: 10px; |
| | | margin-right: 6px; |
| | | border-radius: 2px; |
| | | vertical-align: -1px; |
| | | } |
| | | |
| | | .legend-item.correct::before { |
| | | background: #b7eb8f; |
| | | } |
| | | |
| | | .legend-item.wrong::before { |
| | | background: #ffa39e; |
| | | } |
| | | |
| | | .result-list { |
| | | flex: 1; |
| | | min-height: 160px; |
| | | overflow: auto; |
| | | padding: 8px 28px 4px; |
| | | } |
| | | |
| | | .question-card { |
| | | margin-bottom: 12px; |
| | | padding: 14px 16px; |
| | | border: 1px solid #f0f0f0; |
| | | border-radius: 10px; |
| | | background: #fff; |
| | | } |
| | | |
| | | .question-card.is-wrong { |
| | | border-color: #ffccc7; |
| | | background: #fffafa; |
| | | } |
| | | |
| | | .question-card.is-right { |
| | | border-color: #d9f7be; |
| | | background: #fcfffb; |
| | | } |
| | | |
| | | .q-head { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .q-head-left { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .q-index { |
| | | font-weight: 600; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .q-type { |
| | | font-size: 12px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .q-stem { |
| | | margin-bottom: 10px; |
| | | font-size: 14px; |
| | | line-height: 1.7; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .q-options { |
| | | display: grid; |
| | | gap: 6px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .q-option { |
| | | display: flex; |
| | | gap: 8px; |
| | | padding: 8px 10px; |
| | | border-radius: 6px; |
| | | background: #fafafa; |
| | | border: 1px solid transparent; |
| | | color: rgba(0, 0, 0, 0.75); |
| | | } |
| | | |
| | | .q-option .opt-label { |
| | | min-width: 18px; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .q-option.opt-correct { |
| | | background: #f6ffed; |
| | | color: #389e0d; |
| | | border-color: #b7eb8f; |
| | | } |
| | | |
| | | .q-option.opt-wrong { |
| | | background: #fff2f0; |
| | | color: #cf1322; |
| | | border-color: #ffccc7; |
| | | } |
| | | |
| | | .q-answer-bar { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 16px 28px; |
| | | padding-top: 8px; |
| | | border-top: 1px dashed #f0f0f0; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .ans-label { |
| | | margin-right: 8px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .ans-ok { |
| | | color: #389e0d; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .ans-bad { |
| | | color: #cf1322; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .result-footer { |
| | | flex-shrink: 0; |
| | | display: flex; |
| | | justify-content: center; |
| | | gap: 16px; |
| | | padding: 14px 28px 18px; |
| | | border-top: 1px solid #f0f0f0; |
| | | background: #fafafa; |
| | | } |
| | | </style> |
| | |
| | | import type { QuestionBankOption } from '#/views/x/tms/question/types'; |
| | | import type { SelfTestFormModel } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { computed, onActivated, onMounted, reactive, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { useTabbarStore } from '@vben/stores'; |
| | | |
| | | import { getSelfTestBanks, startSelfTest } from '#/api/x/tms/selfTest'; |
| | | |
| | |
| | | |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | const tabbarStore = useTabbarStore(); |
| | | |
| | | const banks = ref<QuestionBankOption[]>([]); |
| | | const loading = ref(false); |
| | | const formRef = ref(); |
| | | const pageReady = ref(false); |
| | | |
| | | function defaultSetting() { |
| | | return { count: 0, difficulty: 'normal' as SelfTestDifficulty }; |
| | |
| | | () => Number(dataForm.single.count || 0) + Number(dataForm.multi.count || 0) + Number(dataForm.judge.count || 0), |
| | | ); |
| | | |
| | | onMounted(async () => { |
| | | banks.value = (await getSelfTestBanks()) || []; |
| | | if (banks.value.length === 1) { |
| | | dataForm.bankId = banks.value[0].id; |
| | | async function loadBanks() { |
| | | try { |
| | | banks.value = (await getSelfTestBanks()) || []; |
| | | if (!dataForm.bankId && banks.value.length === 1) { |
| | | dataForm.bankId = banks.value[0].id; |
| | | } |
| | | } catch (e: any) { |
| | | banks.value = []; |
| | | createMessage.error(e?.message || '加载题库失败'); |
| | | } finally { |
| | | pageReady.value = true; |
| | | } |
| | | } |
| | | |
| | | onMounted(() => { |
| | | tabbarStore.renderRouteView = true; |
| | | loadBanks(); |
| | | }); |
| | | onActivated(() => { |
| | | tabbarStore.renderRouteView = true; |
| | | if (!pageReady.value || !banks.value.length) { |
| | | loadBanks(); |
| | | } |
| | | }); |
| | | |
| | |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function goRecords() { |
| | | router.push('/tms/selfTest/records'); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content tms-self-test-page"> |
| | | <div class="tms-self-test-header"> |
| | | <div class="text-base font-medium">自我检测</div> |
| | | <div class="mt-1 text-gray-400 text-sm">设置查询条件,从题库中筛选题目进行自我检测。</div> |
| | | <div> |
| | | <div class="text-base font-medium">自我检测</div> |
| | | <div class="mt-1 text-gray-400 text-sm">设置查询条件,从题库中筛选题目进行自我检测。</div> |
| | | </div> |
| | | <a-button @click="goRecords">我的检测记录</a-button> |
| | | </div> |
| | | |
| | | <a-form |
| | |
| | | } |
| | | |
| | | .tms-self-test-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: flex-start; |
| | | margin-bottom: 24px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { QuestionBankOption } from '#/views/x/tms/question/types'; |
| | | import type { SelfTestPaper, SelfTestRecordItem } from './types'; |
| | | |
| | | import { onMounted, reactive, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { useTabbarStore } from '@vben/stores'; |
| | | import dayjs from 'dayjs'; |
| | | |
| | | import { getSelfTestBanks, getSelfTestInfo, getSelfTestList } from '#/api/x/tms/selfTest'; |
| | | import { useBaseStore } from '#/store'; |
| | | import { |
| | | TMS_DIC, |
| | | TMS_DIC_FIELD_NAMES, |
| | | labelOfDic, |
| | | loadTmsDic, |
| | | type TmsDicOpt, |
| | | } from '#/views/x/tms/shared/dic'; |
| | | |
| | | defineOptions({ name: 'TmsSelfTestRecords' }); |
| | | |
| | | const baseStore = useBaseStore(); |
| | | const statusOpts = ref<TmsDicOpt[]>([]); |
| | | |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | const tabbarStore = useTabbarStore(); |
| | | |
| | | const banks = ref<QuestionBankOption[]>([]); |
| | | const records = ref<SelfTestRecordItem[]>([]); |
| | | const loading = ref(false); |
| | | const continueLoadingId = ref(''); |
| | | const pagination = ref({ currentPage: 1, pageSize: 10, total: 0 }); |
| | | |
| | | const queryForm = reactive<{ |
| | | bankId?: string; |
| | | testStatus?: string; |
| | | /** jnpf-date-range 值为时间戳数组 */ |
| | | timeRange?: number[]; |
| | | }>({ |
| | | bankId: undefined, |
| | | testStatus: undefined, |
| | | timeRange: undefined, |
| | | }); |
| | | |
| | | async function loadBanks() { |
| | | try { |
| | | banks.value = (await getSelfTestBanks()) || []; |
| | | } catch { |
| | | banks.value = []; |
| | | } |
| | | } |
| | | |
| | | async function loadRecords() { |
| | | loading.value = true; |
| | | try { |
| | | const range = queryForm.timeRange; |
| | | const begin = range?.[0] != null ? dayjs(range[0]).format('YYYY-MM-DD') : undefined; |
| | | const end = range?.[1] != null ? dayjs(range[1]).format('YYYY-MM-DD') : undefined; |
| | | const res = await getSelfTestList({ |
| | | currentPage: pagination.value.currentPage, |
| | | pageSize: pagination.value.pageSize, |
| | | bankId: queryForm.bankId || undefined, |
| | | testStatus: queryForm.testStatus || undefined, |
| | | startTimeBegin: begin ? `${begin} 00:00:00` : undefined, |
| | | startTimeEnd: end ? `${end} 23:59:59` : undefined, |
| | | }); |
| | | records.value = res?.list || []; |
| | | const p = res?.pagination || {}; |
| | | pagination.value.total = Number(p.total || p.totalCount || records.value.length); |
| | | } catch (e: any) { |
| | | records.value = []; |
| | | createMessage.error(e?.message || '加载检测记录失败'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function handleSearch() { |
| | | pagination.value.currentPage = 1; |
| | | loadRecords(); |
| | | } |
| | | |
| | | function handleReset() { |
| | | queryForm.bankId = undefined; |
| | | queryForm.testStatus = undefined; |
| | | queryForm.timeRange = undefined; |
| | | pagination.value.currentPage = 1; |
| | | loadRecords(); |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | tabbarStore.renderRouteView = true; |
| | | const examStatus = await loadTmsDic(baseStore, TMS_DIC.examStatus); |
| | | statusOpts.value = examStatus.filter( |
| | | (x) => x.enCode === 'submitted' || x.enCode === 'doing', |
| | | ); |
| | | await loadBanks(); |
| | | loadRecords(); |
| | | }); |
| | | |
| | | function statusLabel(status?: string) { |
| | | return labelOfDic(statusOpts.value, status); |
| | | } |
| | | |
| | | function goBack() { |
| | | tabbarStore.renderRouteView = true; |
| | | router.push('/tms/selfTest'); |
| | | } |
| | | |
| | | function handleView(record: SelfTestRecordItem) { |
| | | if (record.testStatus !== 'submitted') { |
| | | createMessage.warning('该检测尚未交卷,暂无答题结果可查看'); |
| | | return; |
| | | } |
| | | router.push(`/tms/selfTest/detail/${record.id}`); |
| | | } |
| | | |
| | | /** 继续未交卷的检测 */ |
| | | async function handleContinue(record: SelfTestRecordItem) { |
| | | if (record.testStatus !== 'doing') return; |
| | | continueLoadingId.value = record.id; |
| | | try { |
| | | const detail = await getSelfTestInfo(record.id); |
| | | if (detail.testStatus === 'submitted') { |
| | | createMessage.warning('该检测已交卷,请直接查看详情'); |
| | | loadRecords(); |
| | | return; |
| | | } |
| | | if (!detail.questions?.length) { |
| | | createMessage.warning('未找到可继续的试题'); |
| | | return; |
| | | } |
| | | const paper: SelfTestPaper = { |
| | | paperId: detail.paperId, |
| | | bankId: detail.bankId, |
| | | bankName: detail.bankName, |
| | | testStatus: detail.testStatus, |
| | | questions: detail.questions, |
| | | }; |
| | | // 把已作答内容一并带入答题页 |
| | | const savedAnswers: Record<string, string | string[]> = {}; |
| | | detail.questions.forEach((q) => { |
| | | if (!q.userAnswer) return; |
| | | if (q.questionType === 'multi') { |
| | | savedAnswers[q.id] = q.userAnswer.split(',').filter(Boolean); |
| | | } else { |
| | | savedAnswers[q.id] = q.userAnswer; |
| | | } |
| | | }); |
| | | sessionStorage.setItem('tms_self_test_paper', JSON.stringify(paper)); |
| | | sessionStorage.setItem('tms_self_test_answers', JSON.stringify(savedAnswers)); |
| | | router.push('/tms/selfTest/exam'); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '继续考试失败'); |
| | | } finally { |
| | | continueLoadingId.value = ''; |
| | | } |
| | | } |
| | | |
| | | function onPageChange(page: number, pageSize: number) { |
| | | pagination.value.currentPage = page; |
| | | pagination.value.pageSize = pageSize; |
| | | loadRecords(); |
| | | } |
| | | |
| | | /** 弹出层挂到 body,避免被 jnpf-content-wrapper overflow:hidden 裁切 */ |
| | | function popupContainer() { |
| | | return document.body; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-self-test-records-page"> |
| | | <div class="jnpf-content-wrapper-center tms-self-test-records-center"> |
| | | <div class="jnpf-content-wrapper-content tms-self-test-records"> |
| | | <div class="records-top"> |
| | | <div class="page-header"> |
| | | <div> |
| | | <div class="text-base font-medium">我的检测记录</div> |
| | | <div class="mt-1 text-gray-400 text-sm">查看历史自我检测成绩与错题。</div> |
| | | </div> |
| | | <a-button @click="goBack">返回自我检测</a-button> |
| | | </div> |
| | | |
| | | <div class="search-bar"> |
| | | <a-form layout="inline" class="search-fields" :model="queryForm" @finish="handleSearch"> |
| | | <a-form-item label="题库"> |
| | | <jnpf-select |
| | | v-model:value="queryForm.bankId" |
| | | :options="banks" |
| | | allow-clear |
| | | show-search |
| | | placeholder="请选择题库" |
| | | class="!w-[200px]" |
| | | /> |
| | | </a-form-item> |
| | | <a-form-item label="状态"> |
| | | <jnpf-select |
| | | v-model:value="queryForm.testStatus" |
| | | :options="statusOpts" |
| | | :field-names="TMS_DIC_FIELD_NAMES" |
| | | allow-clear |
| | | placeholder="请选择状态" |
| | | class="!w-[140px]" |
| | | /> |
| | | </a-form-item> |
| | | <a-form-item label="时间范围"> |
| | | <jnpf-date-range |
| | | v-model:value="queryForm.timeRange" |
| | | allow-clear |
| | | format="YYYY-MM-DD" |
| | | class="!w-[260px]" |
| | | :placeholder="['开始日期', '结束日期']" |
| | | :get-popup-container="popupContainer" |
| | | /> |
| | | </a-form-item> |
| | | </a-form> |
| | | <a-space class="search-actions"> |
| | | <a-button type="primary" :loading="loading" @click="handleSearch">查询</a-button> |
| | | <a-button @click="handleReset">重置</a-button> |
| | | </a-space> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="records-body"> |
| | | <a-table |
| | | :data-source="records" |
| | | :loading="loading" |
| | | row-key="id" |
| | | size="middle" |
| | | :pagination="{ |
| | | current: pagination.currentPage, |
| | | pageSize: pagination.pageSize, |
| | | total: pagination.total, |
| | | showSizeChanger: true, |
| | | showTotal: (t: number) => `共 ${t} 条`, |
| | | onChange: onPageChange, |
| | | }" |
| | | :columns="[ |
| | | { title: '题库', dataIndex: 'bankName', key: 'bankName', ellipsis: true }, |
| | | { title: '题量', dataIndex: 'totalCount', key: 'totalCount', width: 80 }, |
| | | { title: '正确', dataIndex: 'correctCount', key: 'correctCount', width: 80 }, |
| | | { title: '正确率', key: 'scoreRate', width: 100 }, |
| | | { title: '状态', key: 'testStatus', width: 100 }, |
| | | { title: '开始时间', dataIndex: 'startTime', key: 'startTime', width: 170 }, |
| | | { title: '交卷时间', dataIndex: 'submitTime', key: 'submitTime', width: 170 }, |
| | | { title: '操作', key: 'action', width: 120, fixed: 'right' }, |
| | | ]" |
| | | > |
| | | <template #bodyCell="{ column, record }"> |
| | | <template v-if="column.key === 'scoreRate'"> |
| | | {{ record.scoreRate != null ? `${record.scoreRate}%` : '-' }} |
| | | </template> |
| | | <template v-else-if="column.key === 'testStatus'"> |
| | | {{ statusLabel(record.testStatus) }} |
| | | </template> |
| | | <template v-else-if="column.key === 'correctCount'"> |
| | | {{ record.correctCount ?? '-' }} |
| | | </template> |
| | | <template v-else-if="column.key === 'action'"> |
| | | <a-button |
| | | v-if="record.testStatus === 'doing'" |
| | | type="link" |
| | | size="small" |
| | | :loading="continueLoadingId === record.id" |
| | | @click="handleContinue(record)" |
| | | > |
| | | 继续考试 |
| | | </a-button> |
| | | <a-button |
| | | v-else |
| | | type="link" |
| | | size="small" |
| | | @click="handleView(record)" |
| | | > |
| | | 查看 |
| | | </a-button> |
| | | </template> |
| | | </template> |
| | | </a-table> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* 全局 jnpf-content-wrapper* 为 overflow:hidden 且无 min-height:0,滚动必须落在内层 body */ |
| | | .tms-self-test-records-page { |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-self-test-records-center { |
| | | min-height: 0 !important; |
| | | } |
| | | |
| | | .tms-self-test-records { |
| | | display: flex !important; |
| | | flex-direction: column; |
| | | flex: 1 1 0 !important; |
| | | min-height: 0 !important; |
| | | height: auto !important; |
| | | overflow: hidden !important; |
| | | background: #fff; |
| | | padding: 0; |
| | | } |
| | | |
| | | .records-top { |
| | | flex-shrink: 0; |
| | | padding: 20px 24px 0; |
| | | } |
| | | |
| | | .page-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: flex-start; |
| | | margin-bottom: 16px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .search-bar { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: flex-start; |
| | | gap: 16px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .search-fields { |
| | | flex: 1; |
| | | row-gap: 12px; |
| | | } |
| | | |
| | | .search-actions { |
| | | flex-shrink: 0; |
| | | padding-top: 4px; |
| | | } |
| | | |
| | | .records-body { |
| | | flex: 1 1 0; |
| | | min-height: 0; |
| | | overflow-y: auto !important; |
| | | overflow-x: hidden; |
| | | padding: 0 24px 24px; |
| | | -webkit-overflow-scrolling: touch; |
| | | } |
| | | |
| | | .records-body :deep(.ant-table-wrapper) { |
| | | overflow: visible !important; |
| | | } |
| | | </style> |
| | |
| | | judge: SelfTestTypeSetting; |
| | | } |
| | | |
| | | /** 抽题结果(考试页) */ |
| | | /** 抽题结果 / 检测详情 */ |
| | | export interface SelfTestPaper { |
| | | paperId: string; |
| | | bankId: string; |
| | | bankName: string; |
| | | testStatus?: string; |
| | | totalCount?: number; |
| | | correctCount?: number; |
| | | scoreRate?: number; |
| | | startTime?: string; |
| | | submitTime?: string; |
| | | questions: SelfTestQuestionItem[]; |
| | | } |
| | | |
| | |
| | | difficulty?: Difficulty; |
| | | stem: string; |
| | | options: { optionLabel: string; optionContent: string; isCorrect: '0' | '1' }[]; |
| | | /** 回顾字段 */ |
| | | correctAnswer?: string; |
| | | userAnswer?: string; |
| | | isRight?: '0' | '1'; |
| | | sortNo?: number; |
| | | } |
| | | |
| | | export interface SelfTestSubmitPayload { |
| | | answers: Record<string, string | string[]>; |
| | | } |
| | | |
| | | export interface SelfTestSubmitResult { |
| | | paperId: string; |
| | | totalCount: number; |
| | | correctCount: number; |
| | | scoreRate: number; |
| | | } |
| | | |
| | | export interface SelfTestRecordItem { |
| | | id: string; |
| | | bankId: string; |
| | | bankName: string; |
| | | totalCount: number; |
| | | correctCount?: number; |
| | | scoreRate?: number; |
| | | testStatus: string; |
| | | startTime?: string; |
| | | submitTime?: string; |
| | | } |
| New file |
| | |
| | | import type { useBaseStore } from '#/store'; |
| | | |
| | | /** 与 docs/tms/字段注释与字典规范.md 对齐 */ |
| | | export const TMS_DIC = { |
| | | yesNo: 'tmsYesNo', |
| | | enableStatus: 'tmsEnableStatus', |
| | | signRule: 'tmsSignRule', |
| | | questionType: 'tmsQuestionType', |
| | | difficulty: 'tmsDifficulty', |
| | | questionSource: 'tmsQuestionSource', |
| | | openClosedInvalid: 'tmsOpenClosedInvalid', |
| | | paperSortMode: 'tmsPaperSortMode', |
| | | courseCategory: 'tmsCourseCategory', |
| | | trainMode: 'tmsTrainMode', |
| | | evalMode: 'tmsEvalMode', |
| | | archiveStatus: 'tmsArchiveStatus', |
| | | gradeStatus: 'tmsGradeStatus', |
| | | examStatus: 'tmsExamStatus', |
| | | passFlag: 'tmsPassFlag', |
| | | taskPublishStatus: 'tmsTaskPublishStatus', |
| | | taskCategory: 'tmsTaskCategory', |
| | | taskKind: 'tmsTaskKind', |
| | | taskSourceType: 'tmsTaskSourceType', |
| | | personTaskStatus: 'tmsPersonTaskStatus', |
| | | } as const; |
| | | |
| | | export type TmsDicCode = (typeof TMS_DIC)[keyof typeof TMS_DIC]; |
| | | |
| | | export type TmsDicOpt = { |
| | | id: string; |
| | | enCode?: string; |
| | | fullName: string; |
| | | [key: string]: any; |
| | | }; |
| | | |
| | | /** Select 存字典 enCode(与库字段编码一致) */ |
| | | export const TMS_DIC_FIELD_NAMES = { value: 'enCode', label: 'fullName' } as const; |
| | | |
| | | /** 字典不可用时的兜底,仅供 loadTmsDic 内部使用 */ |
| | | const FALLBACK_BY_CODE: Record<string, TmsDicOpt[]> = { |
| | | [TMS_DIC.yesNo]: [ |
| | | { id: '1', enCode: '1', fullName: '是' }, |
| | | { id: '0', enCode: '0', fullName: '否' }, |
| | | ], |
| | | [TMS_DIC.enableStatus]: [ |
| | | { id: '1', enCode: '1', fullName: '启用' }, |
| | | { id: '0', enCode: '0', fullName: '停用' }, |
| | | ], |
| | | [TMS_DIC.signRule]: [ |
| | | { id: 'scan', enCode: 'scan', fullName: '仅扫码' }, |
| | | { id: 'both', enCode: 'both', fullName: '扫码+在线' }, |
| | | ], |
| | | [TMS_DIC.questionType]: [ |
| | | { id: 'single', enCode: 'single', fullName: '单选' }, |
| | | { id: 'multi', enCode: 'multi', fullName: '多选' }, |
| | | { id: 'judge', enCode: 'judge', fullName: '判断' }, |
| | | { id: 'blank', enCode: 'blank', fullName: '填空' }, |
| | | { id: 'essay', enCode: 'essay', fullName: '问答' }, |
| | | ], |
| | | [TMS_DIC.difficulty]: [ |
| | | { id: 'easy', enCode: 'easy', fullName: '简单' }, |
| | | { id: 'normal', enCode: 'normal', fullName: '一般' }, |
| | | { id: 'hard', enCode: 'hard', fullName: '困难' }, |
| | | ], |
| | | [TMS_DIC.questionSource]: [ |
| | | { id: 'self', enCode: 'self', fullName: '自命题' }, |
| | | { id: 'import', enCode: 'import', fullName: '导入' }, |
| | | { id: 'external', enCode: 'external', fullName: '外购' }, |
| | | ], |
| | | [TMS_DIC.openClosedInvalid]: [ |
| | | { id: 'open', enCode: 'open', fullName: '开放' }, |
| | | { id: 'closed', enCode: 'closed', fullName: '不开放' }, |
| | | { id: 'invalid', enCode: 'invalid', fullName: '废弃' }, |
| | | ], |
| | | [TMS_DIC.paperSortMode]: [ |
| | | { id: 'paper', enCode: 'paper', fullName: '试卷顺序' }, |
| | | { id: 'random', enCode: 'random', fullName: '随机' }, |
| | | ], |
| | | [TMS_DIC.courseCategory]: [ |
| | | { id: 'gmp', enCode: 'gmp', fullName: 'GMP' }, |
| | | { id: 'sop', enCode: 'sop', fullName: 'SOP' }, |
| | | { id: 'safety', enCode: 'safety', fullName: '安全' }, |
| | | { id: 'skill', enCode: 'skill', fullName: '技能' }, |
| | | { id: 'other', enCode: 'other', fullName: '其他' }, |
| | | ], |
| | | [TMS_DIC.trainMode]: [ |
| | | { id: 'onsite', enCode: 'onsite', fullName: '集中授课' }, |
| | | { id: 'practice', enCode: 'practice', fullName: '操作授课' }, |
| | | { id: 'online', enCode: 'online', fullName: '在线学习' }, |
| | | ], |
| | | [TMS_DIC.evalMode]: [ |
| | | { id: 'quiz', enCode: 'quiz', fullName: '提问' }, |
| | | { id: 'practice', enCode: 'practice', fullName: '现场操作' }, |
| | | { id: 'exam', enCode: 'exam', fullName: '在线考试' }, |
| | | { id: 'none', enCode: 'none', fullName: '无需考核' }, |
| | | ], |
| | | [TMS_DIC.archiveStatus]: [ |
| | | { id: 'pending', enCode: 'pending', fullName: '待归档' }, |
| | | { id: 'ready', enCode: 'ready', fullName: '可归档' }, |
| | | { id: 'archived', enCode: 'archived', fullName: '已归档' }, |
| | | { id: 'invalid', enCode: 'invalid', fullName: '无效' }, |
| | | ], |
| | | [TMS_DIC.gradeStatus]: [ |
| | | { id: 'auto', enCode: 'auto', fullName: '无需阅卷' }, |
| | | { id: 'pending', enCode: 'pending', fullName: '待批改' }, |
| | | { id: 'graded', enCode: 'graded', fullName: '已批改' }, |
| | | ], |
| | | [TMS_DIC.examStatus]: [ |
| | | { id: 'doing', enCode: 'doing', fullName: '考试中' }, |
| | | { id: 'submitted', enCode: 'submitted', fullName: '已交卷' }, |
| | | { id: 'cancelled', enCode: 'cancelled', fullName: '已取消' }, |
| | | ], |
| | | [TMS_DIC.passFlag]: [ |
| | | { id: '1', enCode: '1', fullName: '合格' }, |
| | | { id: '0', enCode: '0', fullName: '不合格' }, |
| | | ], |
| | | [TMS_DIC.taskPublishStatus]: [ |
| | | { id: 'draft', enCode: 'draft', fullName: '未发布' }, |
| | | { id: 'published', enCode: 'published', fullName: '已发布' }, |
| | | { id: 'cancelled', enCode: 'cancelled', fullName: '已取消' }, |
| | | { id: 'done', enCode: 'done', fullName: '已完成' }, |
| | | ], |
| | | [TMS_DIC.taskCategory]: [ |
| | | { id: 'temp', enCode: 'temp', fullName: '临时培训' }, |
| | | { id: 'post_plan', enCode: 'post_plan', fullName: '岗位计划' }, |
| | | { id: 'annual_plan', enCode: 'annual_plan', fullName: '年度计划' }, |
| | | { id: 'file_effect', enCode: 'file_effect', fullName: '文件生效' }, |
| | | { id: 'out_train', enCode: 'out_train', fullName: '外派培训' }, |
| | | { id: 'retrain', enCode: 'retrain', fullName: '再培训' }, |
| | | ], |
| | | [TMS_DIC.taskKind]: [ |
| | | { id: 'new', enCode: 'new', fullName: '新增' }, |
| | | { id: 'continue', enCode: 'continue', fullName: '继续' }, |
| | | ], |
| | | [TMS_DIC.taskSourceType]: [ |
| | | { id: 'annual', enCode: 'annual', fullName: '年计划' }, |
| | | { id: 'post', enCode: 'post', fullName: '岗计划' }, |
| | | { id: 'temp', enCode: 'temp', fullName: '临时' }, |
| | | { id: 'file', enCode: 'file', fullName: '文件培训' }, |
| | | ], |
| | | [TMS_DIC.personTaskStatus]: [ |
| | | { id: 'todo', enCode: 'todo', fullName: '未完成' }, |
| | | { id: 'signed', enCode: 'signed', fullName: '已签到' }, |
| | | { id: 'done', enCode: 'done', fullName: '已完成' }, |
| | | { id: 'expired', enCode: 'expired', fullName: '已过期' }, |
| | | { id: 'cancelled', enCode: 'cancelled', fullName: '已取消' }, |
| | | ], |
| | | }; |
| | | |
| | | export function labelOfDic(options: TmsDicOpt[], v?: string | null) { |
| | | if (v == null || v === '') return '-'; |
| | | const hit = options.find((x) => x.enCode === v || x.id === v); |
| | | return hit?.fullName || v; |
| | | } |
| | | |
| | | type BaseStore = ReturnType<typeof useBaseStore>; |
| | | |
| | | /** 统一字典入口:按编码拉取,失败时用内置兜底 */ |
| | | export async function loadTmsDic(baseStore: BaseStore, code: string): Promise<TmsDicOpt[]> { |
| | | const fallback = FALLBACK_BY_CODE[code] ?? []; |
| | | try { |
| | | const res = await baseStore.getDictionaryData(code); |
| | | return Array.isArray(res) && res.length ? (res as TmsDicOpt[]) : fallback; |
| | | } catch { |
| | | return fallback; |
| | | } |
| | | } |
| New file |
| | |
| | | /* TMS 详情/工具条通用样式,按需在页面 import */ |
| | | |
| | | .tms-page-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | gap: 12px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .tms-page-header__title { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | line-height: 1.4; |
| | | color: rgba(0, 0, 0, 0.88); |
| | | } |
| | | |
| | | .tms-page-header__sub { |
| | | margin-top: 4px; |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | line-height: 1.4; |
| | | } |
| | | |
| | | .tms-page-header__actions { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | justify-content: flex-end; |
| | | gap: 8px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .tms-page-tip { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .tms-toolbar-hint { |
| | | font-size: 13px; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| New file |
| | |
| | | /** |
| | | * TMS 页面按钮 / 文案统一约定 |
| | | * |
| | | * 颜色: |
| | | * - primary:本页唯一主操作(新增、发布、交卷、签到、进入考试、归档) |
| | | * - default:返回、次要、导航 |
| | | * - primary + ghost:次主操作(进入学习、保存草稿) |
| | | * - danger:删除、废弃、取消任务 |
| | | * |
| | | * 文案: |
| | | * - 返回详情/列表统一用「返回」 |
| | | * - 行内查看统一「详情」 |
| | | * - 关闭弹层用「关闭」 |
| | | */ |
| | | |
| | | export const TMS_BTN = { |
| | | back: '返回', |
| | | close: '关闭', |
| | | detail: '详情', |
| | | edit: '编辑', |
| | | add: '新增', |
| | | delete: '删除', |
| | | save: '保存', |
| | | saveDraft: '保存草稿', |
| | | savePublish: '保存并发布', |
| | | search: '查询', |
| | | reset: '重置', |
| | | export: '导出', |
| | | publish: '发布', |
| | | cancel: '取消', |
| | | archive: '归档', |
| | | sign: '签到', |
| | | learn: '学习', |
| | | enterLearn: '进入学习', |
| | | exam: '考试', |
| | | enterExam: '进入考试', |
| | | retake: '补考', |
| | | continueExam: '继续考试', |
| | | startExam: '开始考试', |
| | | submitExam: '交卷', |
| | | grade: '阅卷', |
| | | viewPaper: '查看试卷', |
| | | restore: '恢复', |
| | | maintain: '维护', |
| | | } as const; |
| | | |
| | | /** 详情页操作区按钮顺序:返回 → 次要 → 主操作 */ |
| | | export type TmsHeaderActionTone = 'back' | 'secondary' | 'primary' | 'danger'; |
| | |
| | | import { BasicModal, useModalInner } from '@jnpf/ui/modal'; |
| | | |
| | | import { createSignMode, getSignModeInfo, updateSignMode } from '#/api/x/tms/signMode'; |
| | | |
| | | import { ENABLE_OPTIONS, YES_NO_OPTIONS } from './constants'; |
| | | import { useBaseStore } from '#/store'; |
| | | import { |
| | | TMS_DIC, |
| | | TMS_DIC_FIELD_NAMES, |
| | | loadTmsDic, |
| | | } from '#/views/x/tms/shared/dic'; |
| | | |
| | | defineOptions({ name: 'TmsSignModeForm' }); |
| | | |
| | | const emit = defineEmits(['register', 'reload']); |
| | | const { createMessage } = useMessage(); |
| | | const baseStore = useBaseStore(); |
| | | |
| | | const state = reactive({ id: '' }); |
| | | const { id } = toRefs(state); |
| | |
| | | label: '须匹配名单', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | label: '启用状态', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | }); |
| | | |
| | | const [registerModal, { closeModal, changeLoading, changeOkLoading }] = useModalInner(init); |
| | | |
| | | async function loadDicOptions() { |
| | | const [yesNo, enable] = await Promise.all([ |
| | | loadTmsDic(baseStore, TMS_DIC.yesNo), |
| | | loadTmsDic(baseStore, TMS_DIC.enableStatus), |
| | | ]); |
| | | updateSchema([ |
| | | { |
| | | field: 'matchRoster', |
| | | componentProps: { placeholder: '请选择', options: yesNo, fieldNames: TMS_DIC_FIELD_NAMES }, |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | componentProps: { placeholder: '请选择', options: enable, fieldNames: TMS_DIC_FIELD_NAMES }, |
| | | }, |
| | | ]); |
| | | } |
| | | |
| | | async function init(data: { id?: string }) { |
| | | changeLoading(true); |
| | |
| | | }, |
| | | }); |
| | | try { |
| | | if (state.id) { |
| | | setFieldsValue(await getSignModeInfo(state.id)); |
| | | } |
| | | await loadDicOptions(); |
| | | if (state.id) setFieldsValue(await getSignModeInfo(state.id)); |
| | | } finally { |
| | | changeLoading(false); |
| | | } |
| | |
| | | |
| | | import type { SignModeItem } from './types'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { useModal } from '@jnpf/ui/modal'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { deleteSignMode, getSignModeList, setSignModeEnabled } from '#/api/x/tms/signMode'; |
| | | import { useBaseStore } from '#/store'; |
| | | import { |
| | | TMS_DIC, |
| | | TMS_DIC_FIELD_NAMES, |
| | | labelOfDic, |
| | | loadTmsDic, |
| | | type TmsDicOpt, |
| | | } from '#/views/x/tms/shared/dic'; |
| | | |
| | | import Form from './Form.vue'; |
| | | import { ENABLE_OPTIONS, labelOfEnabled, labelOfYesNo } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsSignMode' }); |
| | | |
| | | const { createMessage } = useMessage(); |
| | | const baseStore = useBaseStore(); |
| | | const [registerForm, { openModal: openFormModal }] = useModal(); |
| | | |
| | | const yesNoOpts = ref<TmsDicOpt[]>([]); |
| | | const enableOpts = ref<TmsDicOpt[]>([]); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '方式编码', dataIndex: 'modeCode', width: 140 }, |
| | |
| | | dataIndex: 'matchRoster', |
| | | width: 120, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as SignModeItem).matchRoster), |
| | | customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as SignModeItem).matchRoster), |
| | | }, |
| | | { |
| | | title: '状态', |
| | |
| | | { title: '备注', dataIndex: 'remark', minWidth: 220 }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload }] = useVxeTable({ |
| | | const [registerTable, { reload, getForm }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | immediate: false, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | |
| | | field: 'enabled', |
| | | label: '状态', |
| | | component: 'Select', |
| | | componentProps: { allowClear: true, placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: enableOpts.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | onMounted(async () => { |
| | | yesNoOpts.value = await loadTmsDic(baseStore, TMS_DIC.yesNo); |
| | | enableOpts.value = await loadTmsDic(baseStore, TMS_DIC.enableStatus); |
| | | getForm()?.updateSchema?.({ |
| | | field: 'enabled', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: enableOpts.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }); |
| | | reload(); |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | |
| | | } |
| | | |
| | | async function handleDelete(record: SignModeItem) { |
| | | if (record.enabled !== '0') { |
| | | createMessage.warning('请先停用后再删除'); |
| | | return; |
| | | } |
| | | try { |
| | | await deleteSignMode(record.id); |
| | | createMessage.success('删除成功'); |
| | |
| | | |
| | | function getTableActions(record: SignModeItem): ActionItem[] { |
| | | const enableLabel = record.enabled === '1' ? '停用' : '启用'; |
| | | return [ |
| | | const actions: ActionItem[] = [ |
| | | { label: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { |
| | | label: enableLabel, |
| | |
| | | onOk: handleToggleEnabled.bind(null, record), |
| | | }, |
| | | }, |
| | | { |
| | | ]; |
| | | if (record.enabled === '0') { |
| | | actions.push({ |
| | | label: '删除', |
| | | color: 'error', |
| | | modelConfirm: { |
| | | content: `确定删除签到方式「${record.modeName}」吗?`, |
| | | onOk: handleDelete.bind(null, record), |
| | | }, |
| | | }, |
| | | ]; |
| | | }); |
| | | } |
| | | return actions; |
| | | } |
| | | </script> |
| | | |
| | |
| | | </template> |
| | | <template #enabled="{ record }"> |
| | | <a-tag :color="record.enabled === '1' ? 'success' : 'default'"> |
| | | {{ labelOfEnabled(record.enabled) }} |
| | | {{ labelOfDic(enableOpts, record.enabled) }} |
| | | </a-tag> |
| | | </template> |
| | | <template #action="{ record }"> |
| | |
| | | <script lang="ts" setup> |
| | | import type { TrainArchiveDetail } from './types'; |
| | | |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { useUserStore } from '@vben/stores'; |
| | | import { |
| | | Descriptions as ADescriptions, |
| | | DescriptionsItem as ADescriptionsItem, |
| | |
| | | defineOptions({ name: 'TmsTrainArchive' }); |
| | | |
| | | const { createMessage } = useMessage(); |
| | | const userStore = useUserStore(); |
| | | |
| | | const loading = ref(false); |
| | | const detail = ref<TrainArchiveDetail | null>(null); |
| | | |
| | | const userInfo = computed(() => (userStore.getUserInfo || {}) as Record<string, any>); |
| | | |
| | | const workColumns = [ |
| | | { title: '序号', key: 'index', width: 70, align: 'center', customRender: ({ index }: any) => index + 1 }, |
| | |
| | | loadArchive(); |
| | | }); |
| | | |
| | | function resolvePositionName() { |
| | | const list = userInfo.value.positionList; |
| | | if (Array.isArray(list) && list.length) { |
| | | return list.map((o: any) => o.treeName || o.fullName || o.name).filter(Boolean).join('、') || '无'; |
| | | } |
| | | return userInfo.value.positionName || '无'; |
| | | } |
| | | |
| | | async function loadArchive() { |
| | | loading.value = true; |
| | | try { |
| | | const u = userInfo.value; |
| | | detail.value = await getMyTrainArchive({ |
| | | userId: u.userId || u.id, |
| | | userName: u.userName, |
| | | userAccount: u.userAccount, |
| | | organizeName: u.organizeName || u.departmentName, |
| | | positionName: resolvePositionName(), |
| | | }); |
| | | detail.value = await getMyTrainArchive(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载培训档案失败'); |
| | | } finally { |
| | |
| | | import { BasicModal, useModalInner } from '@jnpf/ui/modal'; |
| | | |
| | | import { createTrainMode, getTrainModeInfo, updateTrainMode } from '#/api/x/tms/trainMode'; |
| | | |
| | | import { ENABLE_OPTIONS, SIGN_RULE_OPTIONS, YES_NO_OPTIONS } from './constants'; |
| | | import { useBaseStore } from '#/store'; |
| | | import { TMS_DIC, TMS_DIC_FIELD_NAMES, loadTmsDic } from '#/views/x/tms/shared/dic'; |
| | | |
| | | defineOptions({ name: 'TmsTrainModeForm' }); |
| | | |
| | | const emit = defineEmits(['register', 'reload']); |
| | | const { createMessage } = useMessage(); |
| | | const baseStore = useBaseStore(); |
| | | |
| | | const state = reactive({ id: '' }); |
| | | const { id } = toRefs(state); |
| | |
| | | label: '签到规则', |
| | | component: 'Select', |
| | | defaultValue: 'both', |
| | | componentProps: { placeholder: '请选择', options: SIGN_RULE_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | label: '允许名单外签到', |
| | | component: 'Select', |
| | | defaultValue: '0', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | label: '起止须同一天', |
| | | component: 'Select', |
| | | defaultValue: '0', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | label: '地点必填', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | label: '启用状态', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: [], |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | |
| | | }); |
| | | |
| | | const [registerModal, { closeModal, changeLoading, changeOkLoading }] = useModalInner(init); |
| | | |
| | | async function loadDicOptions() { |
| | | const [signRule, yesNo, enable] = await Promise.all([ |
| | | loadTmsDic(baseStore, TMS_DIC.signRule), |
| | | loadTmsDic(baseStore, TMS_DIC.yesNo), |
| | | loadTmsDic(baseStore, TMS_DIC.enableStatus), |
| | | ]); |
| | | updateSchema([ |
| | | { |
| | | field: 'signRule', |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: signRule, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'allowGuestSign', |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: yesNo, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'sameDayRequired', |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: yesNo, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'placeRequired', |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: yesNo, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | componentProps: { |
| | | placeholder: '请选择', |
| | | options: enable, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | ]); |
| | | } |
| | | |
| | | async function init(data: { id?: string }) { |
| | | changeLoading(true); |
| | |
| | | }, |
| | | }); |
| | | try { |
| | | await loadDicOptions(); |
| | | if (state.id) { |
| | | const info = await getTrainModeInfo(state.id); |
| | | setFieldsValue(info); |
| | | setFieldsValue(await getTrainModeInfo(state.id)); |
| | | } |
| | | } finally { |
| | | changeLoading(false); |
| | |
| | | |
| | | import type { TrainModeItem } from './types'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { useModal } from '@jnpf/ui/modal'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { deleteTrainMode, getTrainModeList, setTrainModeEnabled } from '#/api/x/tms/trainMode'; |
| | | import { useBaseStore } from '#/store'; |
| | | |
| | | import Form from './Form.vue'; |
| | | import { |
| | | ENABLE_OPTIONS, |
| | | labelOfEnabled, |
| | | labelOfSignRule, |
| | | labelOfYesNo, |
| | | } from './constants'; |
| | | TMS_DIC, |
| | | TMS_DIC_FIELD_NAMES, |
| | | labelOfDic, |
| | | loadTmsDic, |
| | | type TmsDicOpt, |
| | | } from '#/views/x/tms/shared/dic'; |
| | | |
| | | defineOptions({ name: 'TmsTrainMode' }); |
| | | |
| | | const { createMessage } = useMessage(); |
| | | const baseStore = useBaseStore(); |
| | | const [registerForm, { openModal: openFormModal }] = useModal(); |
| | | |
| | | const signRuleOpts = ref<TmsDicOpt[]>([]); |
| | | const yesNoOpts = ref<TmsDicOpt[]>([]); |
| | | const enableOpts = ref<TmsDicOpt[]>([]); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '方式编码', dataIndex: 'modeCode', width: 120 }, |
| | |
| | | title: '签到规则', |
| | | dataIndex: 'signRule', |
| | | width: 120, |
| | | customRender: ({ record }) => labelOfSignRule((record as TrainModeItem).signRule), |
| | | customRender: ({ record }) => labelOfDic(signRuleOpts.value, (record as TrainModeItem).signRule), |
| | | }, |
| | | { |
| | | title: '允许名单外签到', |
| | | dataIndex: 'allowGuestSign', |
| | | width: 130, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as TrainModeItem).allowGuestSign), |
| | | customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as TrainModeItem).allowGuestSign), |
| | | }, |
| | | { |
| | | title: '起止须同一天', |
| | | dataIndex: 'sameDayRequired', |
| | | width: 120, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as TrainModeItem).sameDayRequired), |
| | | customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as TrainModeItem).sameDayRequired), |
| | | }, |
| | | { |
| | | title: '地点必填', |
| | | dataIndex: 'placeRequired', |
| | | width: 100, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as TrainModeItem).placeRequired), |
| | | customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as TrainModeItem).placeRequired), |
| | | }, |
| | | { title: '排序', dataIndex: 'sortNo', width: 80, align: 'center' }, |
| | | { |
| | |
| | | { title: '备注', dataIndex: 'remark', minWidth: 180 }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload }] = useVxeTable({ |
| | | const [registerTable, { reload, getForm }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | immediate: false, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: ENABLE_OPTIONS, |
| | | options: enableOpts.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }, |
| | | ], |
| | |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | async function loadDic() { |
| | | const [signRule, yesNo, enable] = await Promise.all([ |
| | | loadTmsDic(baseStore, TMS_DIC.signRule), |
| | | loadTmsDic(baseStore, TMS_DIC.yesNo), |
| | | loadTmsDic(baseStore, TMS_DIC.enableStatus), |
| | | ]); |
| | | signRuleOpts.value = signRule; |
| | | yesNoOpts.value = yesNo; |
| | | enableOpts.value = enable; |
| | | |
| | | getForm()?.updateSchema?.({ |
| | | field: 'enabled', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: enableOpts.value, |
| | | fieldNames: TMS_DIC_FIELD_NAMES, |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | await loadDic(); |
| | | reload(); |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | |
| | | } |
| | | |
| | | async function handleDelete(record: TrainModeItem) { |
| | | if (record.enabled !== '0') { |
| | | createMessage.warning('请先停用后再删除'); |
| | | return; |
| | | } |
| | | try { |
| | | await deleteTrainMode(record.id); |
| | | createMessage.success('删除成功'); |
| | |
| | | |
| | | function getTableActions(record: TrainModeItem): ActionItem[] { |
| | | const enableLabel = record.enabled === '1' ? '停用' : '启用'; |
| | | return [ |
| | | const actions: ActionItem[] = [ |
| | | { label: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { |
| | | label: enableLabel, |
| | |
| | | onOk: handleToggleEnabled.bind(null, record), |
| | | }, |
| | | }, |
| | | { |
| | | ]; |
| | | if (record.enabled === '0') { |
| | | actions.push({ |
| | | label: '删除', |
| | | color: 'error', |
| | | modelConfirm: { |
| | | content: `确定删除培训方式「${record.modeName}」吗?`, |
| | | onOk: handleDelete.bind(null, record), |
| | | }, |
| | | }, |
| | | ]; |
| | | }); |
| | | } |
| | | return actions; |
| | | } |
| | | </script> |
| | | |
| | |
| | | </template> |
| | | <template #enabled="{ record }"> |
| | | <a-tag :color="record.enabled === '1' ? 'success' : 'default'"> |
| | | {{ labelOfEnabled(record.enabled) }} |
| | | {{ labelOfDic(enableOpts, record.enabled) }} |
| | | </a-tag> |
| | | </template> |
| | | <template #action="{ record }"> |
| | |
| | | await delay(null); |
| | | const idx = store.findIndex((x) => x.id === id); |
| | | if (idx < 0) throw new Error('培训方式不存在'); |
| | | if (store[idx]!.enabled !== '0') throw new Error('请先停用后再删除'); |
| | | store.splice(idx, 1); |
| | | return true; |
| | | } |
| | |
| | | import dayjs from 'dayjs'; |
| | | |
| | | import { getTrainRecordCatalog } from '#/api/x/tms/trainRecord'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | defineOptions({ name: 'TmsTrainRecordCatalog' }); |
| | | |
| | |
| | | createMessage.warning('暂无数据可导出'); |
| | | return; |
| | | } |
| | | createMessage.success(`已准备导出 ${list.length} 条(导出接口联调后生效)`); |
| | | const headers = ['培训记录编号', '培训类型', '培训内容', '培训方式', '培训师', '考核方式', '培训结果', '是否合格', '具体培训时间']; |
| | | const escape = (v: unknown) => { |
| | | const s = v === null || v === undefined ? '' : String(v); |
| | | return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; |
| | | }; |
| | | const passText = (v: string) => (v === '1' ? '合格' : v === '0' ? '不合格' : ''); |
| | | const lines = [ |
| | | headers.join(','), |
| | | ...list.map((row) => |
| | | [ |
| | | row.recordNo, |
| | | row.trainType, |
| | | row.trainContent, |
| | | row.trainMode, |
| | | row.trainerName, |
| | | row.evalMode, |
| | | resultText(row), |
| | | passText(row.passFlag), |
| | | row.trainDate || '', |
| | | ] |
| | | .map(escape) |
| | | .join(','), |
| | | ), |
| | | ]; |
| | | const bom = '\uFEFF'; |
| | | const blob = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8;' }); |
| | | const url = URL.createObjectURL(blob); |
| | | const a = document.createElement('a'); |
| | | a.href = url; |
| | | a.download = `培训目录_${detail.value?.userName || detail.value?.archiveNo || 'export'}_${dayjs().format('YYYYMMDD')}.csv`; |
| | | a.click(); |
| | | URL.revokeObjectURL(url); |
| | | createMessage.success(`已导出 ${list.length} 条`); |
| | | } |
| | | |
| | | function goBack() { |
| | |
| | | :placeholder="['开始日期', '结束日期']" |
| | | :get-popup-container="popupContainer" |
| | | /> |
| | | <a-button type="primary" class="ml-3" @click="handleSearch">查询</a-button> |
| | | <a-button class="ml-2" @click="handleReset">重置</a-button> |
| | | <a-button type="primary" class="ml-3" @click="handleSearch">{{ TMS_BTN.search }}</a-button> |
| | | <a-button class="ml-2" @click="handleReset">{{ TMS_BTN.reset }}</a-button> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="handleExport">导出</a-button> |
| | | <a-button @click="goBack">关闭</a-button> |
| | | <a-button type="primary" ghost @click="handleExport">{{ TMS_BTN.export }}</a-button> |
| | | <a-button @click="goBack">{{ TMS_BTN.close }}</a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { getTrainRecordList } from '#/api/x/tms/trainRecord'; |
| | | import { TMS_BTN } from '#/views/x/tms/shared/ui'; |
| | | |
| | | import '#/views/x/tms/shared/page.css'; |
| | | |
| | | defineOptions({ name: 'TmsTrainRecord' }); |
| | | |
| | |
| | | } |
| | | |
| | | function getTableActions(record: TrainRecordListItem): ActionItem[] { |
| | | return [{ label: '查看', onClick: handleView.bind(null, record) }]; |
| | | return [{ label: TMS_BTN.detail, onClick: handleView.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <a-space> |
| | | <a-button type="primary" @click="handleViewSelected">查看</a-button> |
| | | <span class="text-gray-400 text-sm">个人培训记录表,选择人员查看培训目录。</span> |
| | | <a-button type="primary" @click="handleViewSelected">{{ TMS_BTN.detail }}</a-button> |
| | | <span class="tms-toolbar-hint">选择人员查看培训目录</span> |
| | | </a-space> |
| | | </template> |
| | | <template #action="{ record }"> |