| New file |
| | |
| | | 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; |
| | | const prefix = '/api/tms/course'; |
| | | |
| | | export function getCourseList(params: CoursePageQuery) { |
| | | if (USE_MOCK) return mockQueryCourses(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | } |
| | | |
| | | export function getCourseInfo(id: string) { |
| | | if (USE_MOCK) return mockGetCourse(id); |
| | | return defHttp.get<CourseItem>({ url: `${prefix}/${id}` }); |
| | | } |
| | | |
| | | export function getCoursePaperOptions() { |
| | | if (USE_MOCK) return mockPaperOptions(); |
| | | return defHttp.get<PaperOption[]>({ url: `${prefix}/paper-options` }); |
| | | } |
| | | |
| | | export function createCourse(data: Partial<CourseItem>) { |
| | | if (USE_MOCK) return mockSaveCourse(data); |
| | | return 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 }); |
| | | } |
| | | |
| | | export function setCourseEnabled(id: string, enabled: '0' | '1') { |
| | | if (USE_MOCK) return mockSetCourseEnabled(id, enabled); |
| | | return 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}` }); |
| | | } |
| New file |
| | |
| | | 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; |
| | | const prefix = '/api/tms/eval-mode'; |
| | | |
| | | export function getEvalModeList(params: EvalModePageQuery) { |
| | | if (USE_MOCK) return mockQueryEvalModes(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | } |
| | | |
| | | export function getEvalModeInfo(id: string) { |
| | | if (USE_MOCK) return mockGetEvalMode(id); |
| | | return defHttp.get<EvalModeItem>({ url: `${prefix}/${id}` }); |
| | | } |
| | | |
| | | export function createEvalMode(data: Partial<EvalModeItem>) { |
| | | if (USE_MOCK) return mockSaveEvalMode(data); |
| | | return 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 }); |
| | | } |
| | | |
| | | export function setEvalModeEnabled(id: string, enabled: '0' | '1') { |
| | | if (USE_MOCK) return mockSetEvalModeEnabled(id, enabled); |
| | | return 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}` }); |
| | | } |
| New file |
| | |
| | | import type { |
| | | GradeExamDetail, |
| | | 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'; |
| | | |
| | | export function getGradeSessionList(params: GradeSessionPageQuery) { |
| | | if (USE_MOCK) return mockQueryGradeSessions(params); |
| | | return 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}` }); |
| | | } |
| | | |
| | | export function getGradeExamList(sessionId: string) { |
| | | if (USE_MOCK) return mockQueryGradeExams(sessionId); |
| | | return 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}` }); |
| | | } |
| | | |
| | | export function submitGrade(data: GradeSubmitPayload) { |
| | | if (USE_MOCK) return mockSubmitGrade(data); |
| | | return defHttp.post({ url: `${prefix}/exams/${data.examId}/submit`, data }); |
| | | } |
| New file |
| | |
| | | import type { ExamPaperView, ExamScoreDetail, ExamScorePageQuery } 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'; |
| | | |
| | | export function getExamScoreList(params: ExamScorePageQuery) { |
| | | if (USE_MOCK) return mockQueryExamScores(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | } |
| | | |
| | | export function getExamScoreDetail(id: string) { |
| | | if (USE_MOCK) return mockGetExamScoreDetail(id); |
| | | return defHttp.get<ExamScoreDetail>({ url: `${prefix}/${id}` }); |
| | | } |
| | | |
| | | export function getExamPaperView(examId: string) { |
| | | if (USE_MOCK) return mockGetExamPaperView(examId); |
| | | return defHttp.get<ExamPaperView>({ url: `${prefix}/paper/${examId}` }); |
| | | } |
| New file |
| | |
| | | import type { |
| | | MyPaperPageQuery, |
| | | OnlineExamDetail, |
| | | OnlineExamPaper, |
| | | } 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'; |
| | | |
| | | export function getMyPaperList(params: MyPaperPageQuery) { |
| | | if (USE_MOCK) return mockQueryMyPapers(params); |
| | | return 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}` }); |
| | | } |
| | | |
| | | export function startOnlineExam(myPaperId: string) { |
| | | if (USE_MOCK) return mockStartExam(myPaperId); |
| | | return defHttp.post<OnlineExamPaper>({ url: `${prefix}/start`, data: { myPaperId } }); |
| | | } |
| | | |
| | | 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 }); |
| | | } |
| New file |
| | |
| | | import { defHttp } from '#/api/request'; |
| | | |
| | | /** TMS 服务探活:网关 /api/tms/ping → jnpf-tms /ping */ |
| | | export function pingTms() { |
| | | return defHttp.get({ url: '/api/tms/ping' }); |
| | | } |
| New file |
| | |
| | | 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; |
| | | |
| | | const prefix = '/api/tms/question'; |
| | | |
| | | export function getQuestionBanks() { |
| | | if (USE_MOCK) return mockGetBanks(); |
| | | return defHttp.get<QuestionBankOption[]>({ url: `${prefix}/banks` }); |
| | | } |
| | | |
| | | export function getQuestionAdmins() { |
| | | if (USE_MOCK) return mockGetAdmins(); |
| | | return defHttp.get<{ id: string; fullName: string }[]>({ url: `${prefix}/admins` }); |
| | | } |
| | | |
| | | export function getQuestionList(params: QuestionPageQuery) { |
| | | if (USE_MOCK) return mockQueryQuestions(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | } |
| | | |
| | | export function getQuestionInfo(id: string) { |
| | | if (USE_MOCK) return mockGetQuestion(id); |
| | | return defHttp.get<QuestionEntity>({ url: `${prefix}/${id}` }); |
| | | } |
| | | |
| | | export function createQuestion(data: QuestionEntity) { |
| | | if (USE_MOCK) return mockCreateQuestion(data); |
| | | return defHttp.post({ url: prefix, data }); |
| | | } |
| | | |
| | | export function updateQuestion(data: QuestionEntity) { |
| | | if (USE_MOCK) return mockUpdateQuestion(data); |
| | | return defHttp.put({ url: `${prefix}/${data.id}`, data }); |
| | | } |
| | | |
| | | export function deleteQuestion(id: string) { |
| | | if (USE_MOCK) return mockDeleteQuestion(id); |
| | | return defHttp.delete({ url: `${prefix}/${id}` }); |
| | | } |
| New file |
| | |
| | | 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; |
| | | const prefix = '/api/tms/record'; |
| | | |
| | | export function getTmsRecordList(params: TmsRecordPageQuery) { |
| | | if (USE_MOCK) return mockQueryRecords(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | } |
| | | |
| | | export function getTmsRecordDetail(id: string) { |
| | | if (USE_MOCK) return mockGetRecordDetail(id); |
| | | return defHttp.get<TmsRecordDetail>({ 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 }); |
| | | } |
| | | |
| | | export function archiveTmsRecords(ids: string[]) { |
| | | if (USE_MOCK) return mockArchiveRecords(ids); |
| | | return 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 } }); |
| | | } |
| New file |
| | |
| | | import type { QuestionBankOption } from '#/views/x/tms/question/types'; |
| | | import type { SelfTestPaper, SelfTestStartPayload } 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; |
| | | const prefix = '/api/tms/self-test'; |
| | | |
| | | 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 }); |
| | | } |
| New file |
| | |
| | | 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; |
| | | const prefix = '/api/tms/sign-mode'; |
| | | |
| | | export function getSignModeList(params: SignModePageQuery) { |
| | | if (USE_MOCK) return mockQuerySignModes(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | } |
| | | |
| | | export function getSignModeInfo(id: string) { |
| | | if (USE_MOCK) return mockGetSignMode(id); |
| | | return defHttp.get<SignModeItem>({ url: `${prefix}/${id}` }); |
| | | } |
| | | |
| | | export function createSignMode(data: Partial<SignModeItem>) { |
| | | if (USE_MOCK) return mockSaveSignMode(data); |
| | | return 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 }); |
| | | } |
| | | |
| | | export function setSignModeEnabled(id: string, enabled: '0' | '1') { |
| | | if (USE_MOCK) return mockSetSignModeEnabled(id, enabled); |
| | | return 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}` }); |
| | | } |
| New file |
| | |
| | | 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; |
| | | 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` }); |
| | | } |
| New file |
| | |
| | | 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; |
| | | const prefix = '/api/tms/train-mode'; |
| | | |
| | | export function getTrainModeList(params: TrainModePageQuery) { |
| | | if (USE_MOCK) return mockQueryTrainModes(params); |
| | | return defHttp.get({ url: `${prefix}/list`, params }); |
| | | } |
| | | |
| | | export function getTrainModeInfo(id: string) { |
| | | if (USE_MOCK) return mockGetTrainMode(id); |
| | | return defHttp.get<TrainModeItem>({ url: `${prefix}/${id}` }); |
| | | } |
| | | |
| | | export function createTrainMode(data: Partial<TrainModeItem>) { |
| | | if (USE_MOCK) return mockSaveTrainMode(data); |
| | | return 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 }); |
| | | } |
| | | |
| | | export function setTrainModeEnabled(id: string, enabled: '0' | '1') { |
| | | if (USE_MOCK) return mockSetTrainModeEnabled(id, enabled); |
| | | return 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}` }); |
| | | } |
| New file |
| | |
| | | import type { |
| | | TrainRecordCatalogDetail, |
| | | TrainRecordCatalogQuery, |
| | | TrainRecordPageQuery, |
| | | } 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'; |
| | | |
| | | export function getTrainRecordList(params: TrainRecordPageQuery) { |
| | | if (USE_MOCK) return mockQueryTrainRecords(params); |
| | | return 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 }); |
| | | } |
| | |
| | | icon: 'icon-ym icon-ym-webForm', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/question/create', |
| | | name: 'TmsQuestionCreate', |
| | | component: () => import('#/views/x/tms/question/Form.vue'), |
| | | meta: { |
| | | title: '创建试题', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/question', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/question/edit/:id', |
| | | name: 'TmsQuestionEdit', |
| | | component: () => import('#/views/x/tms/question/Form.vue'), |
| | | meta: { |
| | | title: '编辑试题', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/question', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/selfTest/exam', |
| | | name: 'TmsSelfTestExam', |
| | | component: () => import('#/views/x/tms/selfTest/exam.vue'), |
| | | meta: { |
| | | title: '自我检测答题', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/selfTest', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/onlineExam/exam', |
| | | name: 'TmsOnlineExamTake', |
| | | component: () => import('#/views/x/tms/onlineExam/exam.vue'), |
| | | meta: { |
| | | title: '在线考试答题', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/onlineExam', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/onlineExam/detail/:id', |
| | | name: 'TmsOnlineExamDetail', |
| | | component: () => import('#/views/x/tms/onlineExam/Detail.vue'), |
| | | meta: { |
| | | title: '考试详情', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/onlineExam', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/examGrade/session/:id', |
| | | name: 'TmsExamGradeSession', |
| | | component: () => import('#/views/x/tms/examGrade/Session.vue'), |
| | | meta: { |
| | | title: '答卷列表', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/examGrade', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/examGrade/mark/:id', |
| | | name: 'TmsExamGradeMark', |
| | | component: () => import('#/views/x/tms/examGrade/Mark.vue'), |
| | | meta: { |
| | | title: '阅卷', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/examGrade', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/examScore/detail/:id', |
| | | name: 'TmsExamScoreDetail', |
| | | component: () => import('#/views/x/tms/examScore/Detail.vue'), |
| | | meta: { |
| | | title: '考生试卷列表', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/examScore', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/examScore/paper/:id', |
| | | name: 'TmsExamScorePaper', |
| | | component: () => import('#/views/x/tms/examScore/Paper.vue'), |
| | | meta: { |
| | | title: '考生试卷详情', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/examScore', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/trainArchive', |
| | | name: 'TmsTrainArchiveBasic', |
| | | component: () => import('#/views/x/tms/trainArchive/index.vue'), |
| | | meta: { |
| | | title: '个人培训档案', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/trainRecord', |
| | | name: 'TmsTrainRecordBasic', |
| | | component: () => import('#/views/x/tms/trainRecord/index.vue'), |
| | | meta: { |
| | | title: '个人培训记录', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/trainRecord/catalog/:id', |
| | | name: 'TmsTrainRecordCatalog', |
| | | component: () => import('#/views/x/tms/trainRecord/Catalog.vue'), |
| | | meta: { |
| | | title: '培训目录', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/trainRecord', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/record', |
| | | name: 'TmsRecordBasic', |
| | | component: () => import('#/views/x/tms/record/index.vue'), |
| | | meta: { |
| | | title: '培训记录', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/record/ready', |
| | | name: 'TmsRecordReady', |
| | | component: () => import('#/views/x/tms/record/index.vue'), |
| | | meta: { |
| | | title: '可归档列表', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/record', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/record/archived', |
| | | name: 'TmsRecordArchived', |
| | | component: () => import('#/views/x/tms/record/index.vue'), |
| | | meta: { |
| | | title: '已归档列表', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/record', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/record/detail/:id', |
| | | name: 'TmsRecordDetail', |
| | | component: () => import('#/views/x/tms/record/Detail.vue'), |
| | | meta: { |
| | | title: '培训记录详情', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/record', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/record/sign/:id', |
| | | name: 'TmsRecordSign', |
| | | component: () => import('#/views/x/tms/record/Sign.vue'), |
| | | meta: { |
| | | title: '培训签到', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/record', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/record/edit/:id', |
| | | name: 'TmsRecordEdit', |
| | | component: () => import('#/views/x/tms/record/Edit.vue'), |
| | | meta: { |
| | | title: '修改培训记录', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | currentActiveMenu: '/tms/record', |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/trainMode', |
| | | name: 'TmsTrainModeBasic', |
| | | component: () => import('#/views/x/tms/trainMode/index.vue'), |
| | | meta: { |
| | | title: '培训方式', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/signMode', |
| | | name: 'TmsSignModeBasic', |
| | | component: () => import('#/views/x/tms/signMode/index.vue'), |
| | | meta: { |
| | | title: '签到方式', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/evalMode', |
| | | name: 'TmsEvalModeBasic', |
| | | component: () => import('#/views/x/tms/evalMode/index.vue'), |
| | | meta: { |
| | | title: '考核方式', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | { |
| | | path: '/tms/course', |
| | | name: 'TmsCourseBasic', |
| | | component: () => import('#/views/x/tms/course/index.vue'), |
| | | meta: { |
| | | title: '培训课程', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | { |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** 菜单:路由 /tms/course ,页面地址 x/tms/course/index */ |
| | | const tmsCourseRoutes: RouteRecordRaw[] = [ |
| | | { |
| | | path: '/tms/course', |
| | | name: 'TmsCourse', |
| | | component: () => import('#/views/x/tms/course/index.vue'), |
| | | meta: { |
| | | title: '培训课程', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | ]; |
| | | |
| | | export default tmsCourseRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** 本地联调入口(免菜单);正式环境请在后台菜单挂 pageAddress: x/tms/demo/index */ |
| | | const tmsDemoRoutes: RouteRecordRaw[] = [ |
| | | { |
| | | path: '/tms-demo', |
| | | name: 'TmsDemo', |
| | | component: () => import('#/views/x/tms/demo/index.vue'), |
| | | meta: { |
| | | hideInBreadcrumb: true, |
| | | hideInMenu: true, |
| | | hideInTab: false, |
| | | ignoreAccess: true, |
| | | title: 'TMS Demo', |
| | | }, |
| | | }, |
| | | ]; |
| | | |
| | | export default tmsDemoRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** 菜单:路由 /tms/evalMode ,页面地址 x/tms/evalMode/index */ |
| | | const tmsEvalModeRoutes: RouteRecordRaw[] = [ |
| | | { |
| | | path: '/tms/evalMode', |
| | | name: 'TmsEvalMode', |
| | | component: () => import('#/views/x/tms/evalMode/index.vue'), |
| | | meta: { |
| | | title: '考核方式', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | ]; |
| | | |
| | | export default tmsEvalModeRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** |
| | | * 考试阅卷可由后台菜单挂载: |
| | | * 路由地址 /tms/examGrade |
| | | * 页面地址 x/tms/examGrade/index |
| | | * 答卷列表 / 阅卷页已挂 basicRoutes |
| | | */ |
| | | const tmsExamGradeRoutes: RouteRecordRaw[] = []; |
| | | |
| | | export default tmsExamGradeRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** |
| | | * 考试成绩可由后台菜单挂载: |
| | | * 路由地址 /tms/examScore |
| | | * 页面地址 x/tms/examScore/index |
| | | * 详情(考生试卷列表)/ 查看试卷 已挂 basicRoutes |
| | | */ |
| | | const tmsExamScoreRoutes: RouteRecordRaw[] = []; |
| | | |
| | | export default tmsExamScoreRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** |
| | | * 在线考试(我的试卷)可由后台菜单挂载: |
| | | * 路由地址 /tms/onlineExam |
| | | * 页面地址 x/tms/onlineExam/index |
| | | * 答题页 / 详情页已挂 basicRoutes |
| | | */ |
| | | const tmsOnlineExamRoutes: RouteRecordRaw[] = []; |
| | | |
| | | export default tmsOnlineExamRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** |
| | | * 列表可由后台菜单挂载:pageAddress = x/tms/question/index,路由 /tms/question |
| | | * 创建/编辑已挂到 basicRoutes(不依赖菜单) |
| | | */ |
| | | const tmsQuestionRoutes: RouteRecordRaw[] = []; |
| | | |
| | | export default tmsQuestionRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** |
| | | * 培训记录(三个列表页) |
| | | * 菜单:路由 /tms/record ,页面地址 x/tms/record/index |
| | | * 可归档 /tms/record/ready 、已归档 /tms/record/archived 已挂 basicRoutes |
| | | */ |
| | | const tmsRecordRoutes: RouteRecordRaw[] = [ |
| | | { |
| | | path: '/tms/record', |
| | | name: 'TmsRecord', |
| | | component: () => import('#/views/x/tms/record/index.vue'), |
| | | meta: { |
| | | title: '培训记录', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | ]; |
| | | |
| | | export default tmsRecordRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** |
| | | * 自我检测可由后台菜单挂载: |
| | | * 路由地址 /tms/selfTest |
| | | * 页面地址 x/tms/selfTest/index |
| | | * 答题页已挂 basicRoutes:/tms/selfTest/exam |
| | | */ |
| | | const tmsSelfTestRoutes: RouteRecordRaw[] = []; |
| | | |
| | | export default tmsSelfTestRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** 菜单:路由 /tms/signMode ,页面地址 x/tms/signMode/index */ |
| | | const tmsSignModeRoutes: RouteRecordRaw[] = [ |
| | | { |
| | | path: '/tms/signMode', |
| | | name: 'TmsSignMode', |
| | | component: () => import('#/views/x/tms/signMode/index.vue'), |
| | | meta: { |
| | | title: '签到方式', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | ]; |
| | | |
| | | export default tmsSignModeRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** |
| | | * 个人培训档案 |
| | | * 后台菜单:路由 /tms/trainArchive ,页面地址 x/tms/trainArchive/index |
| | | * 同时挂本地路由,避免菜单 pageAddress 配错导致 404 |
| | | */ |
| | | const tmsTrainArchiveRoutes: RouteRecordRaw[] = [ |
| | | { |
| | | path: '/tms/trainArchive', |
| | | name: 'TmsTrainArchive', |
| | | component: () => import('#/views/x/tms/trainArchive/index.vue'), |
| | | meta: { |
| | | title: '个人培训档案', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | ]; |
| | | |
| | | export default tmsTrainArchiveRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** 菜单:路由 /tms/trainMode ,页面地址 x/tms/trainMode/index */ |
| | | const tmsTrainModeRoutes: RouteRecordRaw[] = [ |
| | | { |
| | | path: '/tms/trainMode', |
| | | name: 'TmsTrainMode', |
| | | component: () => import('#/views/x/tms/trainMode/index.vue'), |
| | | meta: { |
| | | title: '培训方式', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | ]; |
| | | |
| | | export default tmsTrainModeRoutes; |
| New file |
| | |
| | | import type { RouteRecordRaw } from 'vue-router'; |
| | | |
| | | /** |
| | | * 个人培训记录 |
| | | * 菜单:路由 /tms/trainRecord ,页面地址 x/tms/trainRecord/index |
| | | * 培训目录详情已挂 basicRoutes |
| | | */ |
| | | const tmsTrainRecordRoutes: RouteRecordRaw[] = [ |
| | | { |
| | | path: '/tms/trainRecord', |
| | | name: 'TmsTrainRecord', |
| | | component: () => import('#/views/x/tms/trainRecord/index.vue'), |
| | | meta: { |
| | | title: '个人培训记录', |
| | | hideInMenu: true, |
| | | ignoreAccess: true, |
| | | }, |
| | | }, |
| | | ]; |
| | | |
| | | export default tmsTrainRecordRoutes; |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, reactive, toRefs, unref } from 'vue'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicForm, useForm } from '@jnpf/ui/form'; |
| | | import { BasicModal, useModalInner } from '@jnpf/ui/modal'; |
| | | |
| | | import { |
| | | createCourse, |
| | | getCourseInfo, |
| | | getCoursePaperOptions, |
| | | updateCourse, |
| | | } from '#/api/x/tms/course'; |
| | | |
| | | import { |
| | | CATEGORY_OPTIONS, |
| | | ENABLE_OPTIONS, |
| | | EVAL_MODE_OPTIONS, |
| | | TRAIN_MODE_OPTIONS, |
| | | evalModeNeedPaper, |
| | | } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsCourseForm' }); |
| | | |
| | | const emit = defineEmits(['register', 'reload']); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const state = reactive({ id: '' }); |
| | | const { id } = toRefs(state); |
| | | const getTitle = computed(() => (unref(id) ? '编辑培训课程' : '新增培训课程')); |
| | | |
| | | const [registerForm, { setFieldsValue, resetFields, validate, updateSchema, clearValidate }] = |
| | | useForm({ |
| | | labelWidth: 120, |
| | | schemas: [ |
| | | { |
| | | field: 'courseNo', |
| | | label: '课程编号', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '保存后自动生成', |
| | | disabled: true, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'courseName', |
| | | label: '课程名称', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入课程名称', maxlength: 200 }, |
| | | rules: [{ required: true, message: '必填', trigger: 'blur' }], |
| | | }, |
| | | { |
| | | field: 'category', |
| | | label: '课程分类', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: CATEGORY_OPTIONS, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'trainMode', |
| | | label: '培训方式', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: TRAIN_MODE_OPTIONS, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'evalMode', |
| | | label: '考核方式', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: EVAL_MODE_OPTIONS, |
| | | onChange: (val: string) => syncPaperRequired(val), |
| | | }, |
| | | }, |
| | | { |
| | | field: 'paperId', |
| | | label: '关联考核试卷', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择试卷', |
| | | options: [], |
| | | showSearch: true, |
| | | optionFilterProp: 'fullName', |
| | | }, |
| | | }, |
| | | { |
| | | field: 'hours', |
| | | label: '学时', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | min: 0, |
| | | max: 9999, |
| | | step: 0.5, |
| | | precision: 1, |
| | | style: { width: '100%' }, |
| | | placeholder: '请输入学时', |
| | | }, |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | label: '启用状态', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'remark', |
| | | label: '备注', |
| | | component: 'Textarea', |
| | | componentProps: { placeholder: '请输入备注', rows: 3, maxlength: 500 }, |
| | | }, |
| | | ], |
| | | }); |
| | | |
| | | const [registerModal, { closeModal, changeLoading, changeOkLoading }] = useModalInner(init); |
| | | |
| | | function syncPaperRequired(evalMode?: string) { |
| | | const need = evalModeNeedPaper(evalMode); |
| | | updateSchema({ |
| | | field: 'paperId', |
| | | rules: need ? [{ required: true, message: '在线考试须关联试卷', trigger: 'change' }] : [], |
| | | }); |
| | | if (!need) { |
| | | setFieldsValue({ paperId: undefined }); |
| | | clearValidate(['paperId']); |
| | | } |
| | | } |
| | | |
| | | async function loadPaperOptions() { |
| | | const list = await getCoursePaperOptions(); |
| | | updateSchema({ |
| | | field: 'paperId', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择试卷', |
| | | options: list, |
| | | showSearch: true, |
| | | optionFilterProp: 'fullName', |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | async function init(data: { id?: string }) { |
| | | changeLoading(true); |
| | | changeOkLoading(false); |
| | | resetFields(); |
| | | state.id = data?.id || ''; |
| | | try { |
| | | await loadPaperOptions(); |
| | | if (state.id) { |
| | | const info = await getCourseInfo(state.id); |
| | | setFieldsValue(info); |
| | | syncPaperRequired(info.evalMode); |
| | | } else { |
| | | setFieldsValue({ courseNo: '', enabled: '1' }); |
| | | syncPaperRequired(undefined); |
| | | } |
| | | } finally { |
| | | changeLoading(false); |
| | | } |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | const values = await validate(); |
| | | if (!values) return; |
| | | changeOkLoading(true); |
| | | try { |
| | | if (state.id) await updateCourse({ ...values, id: state.id }); |
| | | else await createCourse(values); |
| | | createMessage.success('保存成功'); |
| | | closeModal(); |
| | | emit('reload'); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '保存失败'); |
| | | } finally { |
| | | changeOkLoading(false); |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <BasicModal v-bind="$attrs" :title="getTitle" @register="registerModal" @ok="handleSubmit"> |
| | | <BasicForm @register="registerForm" /> |
| | | </BasicModal> |
| | | </template> |
| New file |
| | |
| | | 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'; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { CourseItem } from './types'; |
| | | |
| | | 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 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 [registerForm, { openModal: openFormModal }] = useModal(); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '课程编号', dataIndex: 'courseNo', width: 140 }, |
| | | { title: '课程名称', dataIndex: 'courseName', minWidth: 180 }, |
| | | { |
| | | title: '课程分类', |
| | | dataIndex: 'category', |
| | | width: 100, |
| | | customRender: ({ record }) => labelOfCategory((record as CourseItem).category), |
| | | }, |
| | | { |
| | | title: '培训方式', |
| | | dataIndex: 'trainMode', |
| | | width: 110, |
| | | customRender: ({ record }) => labelOfTrainMode((record as CourseItem).trainMode), |
| | | }, |
| | | { |
| | | title: '考核方式', |
| | | dataIndex: 'evalMode', |
| | | width: 110, |
| | | customRender: ({ record }) => labelOfEvalMode((record as CourseItem).evalMode), |
| | | }, |
| | | { |
| | | title: '关联考核试卷', |
| | | dataIndex: 'paperName', |
| | | minWidth: 180, |
| | | customRender: ({ record }) => (record as CourseItem).paperName || '-', |
| | | }, |
| | | { |
| | | title: '学时', |
| | | dataIndex: 'hours', |
| | | width: 80, |
| | | align: 'center', |
| | | customRender: ({ record }) => { |
| | | const h = (record as CourseItem).hours; |
| | | return h === null || h === undefined || h === ('' as any) ? '-' : h; |
| | | }, |
| | | }, |
| | | { |
| | | title: '状态', |
| | | dataIndex: 'enabled', |
| | | width: 90, |
| | | align: 'center', |
| | | slots: { default: 'enabled' }, |
| | | }, |
| | | { title: '备注', dataIndex: 'remark', minWidth: 140 }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | schemas: [ |
| | | { |
| | | field: 'keyword', |
| | | label: '关键词', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '编号/名称/试卷', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'category', |
| | | label: '课程分类', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: CATEGORY_OPTIONS, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'trainMode', |
| | | label: '培训方式', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: TRAIN_MODE_OPTIONS, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'evalMode', |
| | | label: '考核方式', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: EVAL_MODE_OPTIONS, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | label: '状态', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: ENABLE_OPTIONS, |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 180, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getCourseList(params) }; |
| | | } |
| | | |
| | | function handleAdd() { |
| | | openFormModal(true, {}); |
| | | } |
| | | |
| | | function handleEdit(record: CourseItem) { |
| | | openFormModal(true, { id: record.id }); |
| | | } |
| | | |
| | | async function handleToggleEnabled(record: CourseItem) { |
| | | const next = record.enabled === '1' ? '0' : '1'; |
| | | const action = next === '1' ? '启用' : '停用'; |
| | | try { |
| | | await setCourseEnabled(record.id, next); |
| | | createMessage.success(`已${action}`); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || `${action}失败`); |
| | | } |
| | | } |
| | | |
| | | async function handleDelete(record: CourseItem) { |
| | | try { |
| | | await deleteCourse(record.id); |
| | | createMessage.success('删除成功'); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '删除失败'); |
| | | } |
| | | } |
| | | |
| | | function getTableActions(record: CourseItem): ActionItem[] { |
| | | const enableLabel = record.enabled === '1' ? '停用' : '启用'; |
| | | return [ |
| | | { label: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { |
| | | label: enableLabel, |
| | | modelConfirm: { |
| | | content: `确定${enableLabel}培训课程「${record.courseName}」吗?`, |
| | | onOk: handleToggleEnabled.bind(null, record), |
| | | }, |
| | | }, |
| | | { |
| | | label: '删除', |
| | | color: 'error', |
| | | modelConfirm: { |
| | | content: `确定删除培训课程「${record.courseName}」吗?`, |
| | | onOk: handleDelete.bind(null, record), |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleAdd">新增</a-button> |
| | | </template> |
| | | <template #enabled="{ record }"> |
| | | <a-tag :color="record.enabled === '1' ? 'success' : 'default'"> |
| | | {{ labelOfEnabled(record.enabled) }} |
| | | </a-tag> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | <Form @register="registerForm" @reload="reload" /> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | import type { CourseItem, CoursePageQuery, PaperOption } from './types'; |
| | | |
| | | function delay<T>(data: T, ms = 180): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | const papers: PaperOption[] = [ |
| | | { id: 'p4', fullName: '2026-药物警戒年度培训试题' }, |
| | | { id: 'p5', fullName: 'GMP基础知识培训(QC、分析)' }, |
| | | { id: 'p6', fullName: '再确认测试' }, |
| | | { id: 'p7', fullName: 'SOP-偏差与变更综合考核' }, |
| | | { id: 'p8', fullName: '安全生产月专项考试' }, |
| | | ]; |
| | | |
| | | const store: CourseItem[] = [ |
| | | { |
| | | id: 'course_001', |
| | | courseNo: 'KC2026030001', |
| | | courseName: 'GMP基础知识培训', |
| | | category: 'gmp', |
| | | trainMode: 'onsite', |
| | | evalMode: 'exam', |
| | | paperId: 'p5', |
| | | paperName: 'GMP基础知识培训(QC、分析)', |
| | | hours: 4, |
| | | enabled: '1', |
| | | remark: '新人入职必修', |
| | | }, |
| | | { |
| | | id: 'course_002', |
| | | courseNo: 'KC2026030002', |
| | | courseName: '偏差与变更管理 SOP', |
| | | category: 'sop', |
| | | trainMode: 'onsite', |
| | | evalMode: 'quiz', |
| | | paperId: undefined, |
| | | paperName: undefined, |
| | | hours: 2, |
| | | enabled: '1', |
| | | remark: '', |
| | | }, |
| | | { |
| | | id: 'course_003', |
| | | courseNo: 'KC2026030003', |
| | | courseName: '设备操作实操培训', |
| | | category: 'skill', |
| | | trainMode: 'practice', |
| | | evalMode: 'practice', |
| | | hours: 8, |
| | | enabled: '1', |
| | | remark: '需现场实操评分', |
| | | }, |
| | | { |
| | | id: 'course_004', |
| | | courseNo: 'KC2026030004', |
| | | courseName: '药物警戒年度培训', |
| | | category: 'gmp', |
| | | trainMode: 'online', |
| | | evalMode: 'exam', |
| | | paperId: 'p4', |
| | | paperName: '2026-药物警戒年度培训试题', |
| | | hours: 3, |
| | | enabled: '1', |
| | | remark: '', |
| | | }, |
| | | { |
| | | id: 'course_005', |
| | | courseNo: 'KC2026030005', |
| | | courseName: '安全生产月宣贯', |
| | | category: 'safety', |
| | | trainMode: 'onsite', |
| | | evalMode: 'none', |
| | | hours: 1.5, |
| | | enabled: '1', |
| | | remark: '无需考核', |
| | | }, |
| | | { |
| | | id: 'course_006', |
| | | courseNo: 'KC2026020001', |
| | | courseName: '旧版文件培训(停用)', |
| | | category: 'other', |
| | | trainMode: 'online', |
| | | evalMode: 'exam', |
| | | paperId: 'p6', |
| | | paperName: '再确认测试', |
| | | hours: 2, |
| | | enabled: '0', |
| | | remark: '已停用,历史引用保留', |
| | | }, |
| | | ]; |
| | | |
| | | function nextCourseNo() { |
| | | const now = new Date(); |
| | | const ym = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`; |
| | | const prefix = `KC${ym}`; |
| | | const seqs = store |
| | | .filter((x) => x.courseNo.startsWith(prefix)) |
| | | .map((x) => Number(x.courseNo.slice(prefix.length)) || 0); |
| | | const next = (seqs.length ? Math.max(...seqs) : 0) + 1; |
| | | return `${prefix}${String(next).padStart(4, '0')}`; |
| | | } |
| | | |
| | | function resolvePaperName(paperId?: string) { |
| | | if (!paperId) return undefined; |
| | | return papers.find((p) => p.id === paperId)?.fullName; |
| | | } |
| | | |
| | | export function mockPaperOptions() { |
| | | return delay([...papers]); |
| | | } |
| | | |
| | | export function mockQueryCourses(params: CoursePageQuery = {}) { |
| | | const { |
| | | currentPage = 1, |
| | | pageSize = 20, |
| | | keyword = '', |
| | | category, |
| | | trainMode, |
| | | evalMode, |
| | | enabled, |
| | | } = params; |
| | | let list = [...store]; |
| | | if (category) list = list.filter((x) => x.category === category); |
| | | if (trainMode) list = list.filter((x) => x.trainMode === trainMode); |
| | | if (evalMode) list = list.filter((x) => x.evalMode === evalMode); |
| | | if (enabled) list = list.filter((x) => x.enabled === enabled); |
| | | if (keyword) { |
| | | const k = keyword.trim().toLowerCase(); |
| | | list = list.filter( |
| | | (x) => |
| | | x.courseNo.toLowerCase().includes(k) || |
| | | x.courseName.toLowerCase().includes(k) || |
| | | (x.paperName || '').toLowerCase().includes(k), |
| | | ); |
| | | } |
| | | list.sort((a, b) => b.courseNo.localeCompare(a.courseNo)); |
| | | const start = (currentPage - 1) * pageSize; |
| | | return delay({ |
| | | list: list.slice(start, start + pageSize), |
| | | pagination: { total: list.length, currentPage, pageSize }, |
| | | }); |
| | | } |
| | | |
| | | export async function mockGetCourse(id: string) { |
| | | await delay(null); |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) throw new Error('培训课程不存在'); |
| | | return { ...row }; |
| | | } |
| | | |
| | | export async function mockSaveCourse(data: Partial<CourseItem> & { id?: string }) { |
| | | await delay(null); |
| | | if (data.evalMode === 'exam' && !data.paperId) { |
| | | throw new Error('在线考试须关联考核试卷'); |
| | | } |
| | | const paperName = resolvePaperName(data.paperId); |
| | | if (data.id) { |
| | | const row = store.find((x) => x.id === data.id); |
| | | if (!row) throw new Error('培训课程不存在'); |
| | | Object.assign(row, { |
| | | ...data, |
| | | paperName: data.paperId ? paperName : undefined, |
| | | }); |
| | | if (!data.paperId) { |
| | | row.paperId = undefined; |
| | | row.paperName = undefined; |
| | | } |
| | | return row; |
| | | } |
| | | if (!data.courseName?.trim()) throw new Error('请填写课程名称'); |
| | | const row: CourseItem = { |
| | | id: `course_${Date.now()}`, |
| | | courseNo: nextCourseNo(), |
| | | courseName: data.courseName.trim(), |
| | | category: data.category, |
| | | trainMode: data.trainMode, |
| | | evalMode: data.evalMode, |
| | | paperId: data.paperId, |
| | | paperName: data.paperId ? paperName : undefined, |
| | | hours: data.hours ?? null, |
| | | enabled: (data.enabled as any) || '1', |
| | | remark: data.remark || '', |
| | | }; |
| | | store.unshift(row); |
| | | return row; |
| | | } |
| | | |
| | | export async function mockSetCourseEnabled(id: string, enabled: '0' | '1') { |
| | | await delay(null); |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) throw new Error('培训课程不存在'); |
| | | row.enabled = enabled; |
| | | return row; |
| | | } |
| | | |
| | | export async function mockDeleteCourse(id: string) { |
| | | await delay(null); |
| | | const idx = store.findIndex((x) => x.id === id); |
| | | if (idx < 0) throw new Error('培训课程不存在'); |
| | | store.splice(idx, 1); |
| | | return true; |
| | | } |
| New file |
| | |
| | | /** 培训课程 */ |
| | | export interface CourseItem { |
| | | id: string; |
| | | courseNo: string; |
| | | courseName: string; |
| | | category?: string; |
| | | trainMode?: string; |
| | | evalMode?: string; |
| | | paperId?: string; |
| | | paperName?: string; |
| | | hours?: number | null; |
| | | enabled: '0' | '1'; |
| | | remark?: string; |
| | | } |
| | | |
| | | export interface CoursePageQuery { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | keyword?: string; |
| | | category?: string; |
| | | trainMode?: string; |
| | | evalMode?: string; |
| | | enabled?: string; |
| | | } |
| | | |
| | | export interface PaperOption { |
| | | id: string; |
| | | fullName: string; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { pingTms } from '#/api/x/tms/ping'; |
| | | |
| | | defineOptions({ name: 'TmsDemo' }); |
| | | |
| | | const status = ref('checking…'); |
| | | |
| | | onMounted(async () => { |
| | | try { |
| | | const res = await pingTms(); |
| | | // ActionResult 经拦截器后通常直接是业务 data,也可能是整包 |
| | | status.value = typeof res === 'string' ? res : String((res as any)?.msg ?? (res as any)?.data ?? res ?? 'ok'); |
| | | } catch { |
| | | status.value = 'error(请确认网关 30000 与 jnpf-tms 已启动)'; |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="p-4"> |
| | | <h2 class="mb-2 text-lg font-medium">TMS Demo</h2> |
| | | <p>ping: {{ status }}</p> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, reactive, toRefs, unref } from 'vue'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicForm, useForm } from '@jnpf/ui/form'; |
| | | import { BasicModal, useModalInner } from '@jnpf/ui/modal'; |
| | | |
| | | import { createEvalMode, getEvalModeInfo, updateEvalMode } from '#/api/x/tms/evalMode'; |
| | | |
| | | import { ENABLE_OPTIONS, YES_NO_OPTIONS } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsEvalModeForm' }); |
| | | |
| | | const emit = defineEmits(['register', 'reload']); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const state = reactive({ id: '' }); |
| | | const { id } = toRefs(state); |
| | | const getTitle = computed(() => (unref(id) ? '编辑考核方式' : '新增考核方式')); |
| | | |
| | | const [registerForm, { setFieldsValue, resetFields, validate, updateSchema }] = useForm({ |
| | | labelWidth: 120, |
| | | schemas: [ |
| | | { |
| | | field: 'modeCode', |
| | | label: '方式编码', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '如 quiz / practice / exam / none', maxlength: 50 }, |
| | | rules: [{ required: true, message: '必填', trigger: 'blur' }], |
| | | }, |
| | | { |
| | | field: 'modeName', |
| | | label: '方式名称', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入方式名称', maxlength: 100 }, |
| | | rules: [{ required: true, message: '必填', trigger: 'blur' }], |
| | | }, |
| | | { |
| | | field: 'needPaper', |
| | | label: '需要试卷', |
| | | component: 'Select', |
| | | defaultValue: '0', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'needQuiz', |
| | | label: '需要提问预设', |
| | | component: 'Select', |
| | | defaultValue: '0', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'needPractice', |
| | | label: '需要实操评分', |
| | | component: 'Select', |
| | | defaultValue: '0', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | label: '启用状态', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'remark', |
| | | label: '备注', |
| | | component: 'Textarea', |
| | | componentProps: { placeholder: '请输入备注', rows: 3, maxlength: 500 }, |
| | | }, |
| | | ], |
| | | }); |
| | | |
| | | const [registerModal, { closeModal, changeLoading, changeOkLoading }] = useModalInner(init); |
| | | |
| | | async function init(data: { id?: string }) { |
| | | changeLoading(true); |
| | | changeOkLoading(false); |
| | | resetFields(); |
| | | state.id = data?.id || ''; |
| | | updateSchema({ |
| | | field: 'modeCode', |
| | | componentProps: { |
| | | placeholder: '如 quiz / practice / exam / none', |
| | | maxlength: 50, |
| | | disabled: !!state.id, |
| | | }, |
| | | }); |
| | | try { |
| | | if (state.id) setFieldsValue(await getEvalModeInfo(state.id)); |
| | | } finally { |
| | | changeLoading(false); |
| | | } |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | const values = await validate(); |
| | | if (!values) return; |
| | | changeOkLoading(true); |
| | | try { |
| | | if (state.id) await updateEvalMode({ ...values, id: state.id }); |
| | | else await createEvalMode(values); |
| | | createMessage.success('保存成功'); |
| | | closeModal(); |
| | | emit('reload'); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '保存失败'); |
| | | } finally { |
| | | changeOkLoading(false); |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <BasicModal v-bind="$attrs" :title="getTitle" @register="registerModal" @ok="handleSubmit"> |
| | | <BasicForm @register="registerForm" /> |
| | | </BasicModal> |
| | | </template> |
| New file |
| | |
| | | export const YES_NO_OPTIONS = [ |
| | | { id: '1', fullName: '是' }, |
| | | { id: '0', fullName: '否' }, |
| | | ]; |
| | | |
| | | export const ENABLE_OPTIONS = [ |
| | | { id: '1', fullName: '启用' }, |
| | | { id: '0', fullName: '停用' }, |
| | | ]; |
| | | |
| | | export function labelOfYesNo(v?: string) { |
| | | return YES_NO_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| | | |
| | | export function labelOfEnabled(v?: string) { |
| | | return ENABLE_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { EvalModeItem } from './types'; |
| | | |
| | | 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 Form from './Form.vue'; |
| | | import { ENABLE_OPTIONS, labelOfEnabled, labelOfYesNo } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsEvalMode' }); |
| | | |
| | | const { createMessage } = useMessage(); |
| | | const [registerForm, { openModal: openFormModal }] = useModal(); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '方式编码', dataIndex: 'modeCode', width: 120 }, |
| | | { title: '方式名称', dataIndex: 'modeName', width: 140 }, |
| | | { |
| | | title: '需要试卷', |
| | | dataIndex: 'needPaper', |
| | | width: 100, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as EvalModeItem).needPaper), |
| | | }, |
| | | { |
| | | title: '需要提问预设', |
| | | dataIndex: 'needQuiz', |
| | | width: 120, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as EvalModeItem).needQuiz), |
| | | }, |
| | | { |
| | | title: '需要实操评分', |
| | | dataIndex: 'needPractice', |
| | | width: 120, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as EvalModeItem).needPractice), |
| | | }, |
| | | { |
| | | title: '状态', |
| | | dataIndex: 'enabled', |
| | | width: 90, |
| | | align: 'center', |
| | | slots: { default: 'enabled' }, |
| | | }, |
| | | { title: '备注', dataIndex: 'remark', minWidth: 200 }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | schemas: [ |
| | | { |
| | | field: 'keyword', |
| | | label: '关键词', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '编码/名称', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | label: '状态', |
| | | component: 'Select', |
| | | componentProps: { allowClear: true, placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 180, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getEvalModeList(params) }; |
| | | } |
| | | |
| | | function handleAdd() { |
| | | openFormModal(true, {}); |
| | | } |
| | | |
| | | function handleEdit(record: EvalModeItem) { |
| | | openFormModal(true, { id: record.id }); |
| | | } |
| | | |
| | | async function handleToggleEnabled(record: EvalModeItem) { |
| | | const next = record.enabled === '1' ? '0' : '1'; |
| | | const action = next === '1' ? '启用' : '停用'; |
| | | try { |
| | | await setEvalModeEnabled(record.id, next); |
| | | createMessage.success(`已${action}`); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || `${action}失败`); |
| | | } |
| | | } |
| | | |
| | | async function handleDelete(record: EvalModeItem) { |
| | | try { |
| | | await deleteEvalMode(record.id); |
| | | createMessage.success('删除成功'); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '删除失败'); |
| | | } |
| | | } |
| | | |
| | | function getTableActions(record: EvalModeItem): ActionItem[] { |
| | | const enableLabel = record.enabled === '1' ? '停用' : '启用'; |
| | | return [ |
| | | { label: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { |
| | | label: enableLabel, |
| | | modelConfirm: { |
| | | content: `确定${enableLabel}考核方式「${record.modeName}」吗?`, |
| | | onOk: handleToggleEnabled.bind(null, record), |
| | | }, |
| | | }, |
| | | { |
| | | label: '删除', |
| | | color: 'error', |
| | | modelConfirm: { |
| | | content: `确定删除考核方式「${record.modeName}」吗?`, |
| | | onOk: handleDelete.bind(null, record), |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleAdd">新增</a-button> |
| | | </template> |
| | | <template #enabled="{ record }"> |
| | | <a-tag :color="record.enabled === '1' ? 'success' : 'default'"> |
| | | {{ labelOfEnabled(record.enabled) }} |
| | | </a-tag> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | <Form @register="registerForm" @reload="reload" /> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | import type { EvalModeItem, EvalModePageQuery } from './types'; |
| | | |
| | | function delay<T>(data: T, ms = 180): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | const store: EvalModeItem[] = [ |
| | | { |
| | | id: 'tms_eval_quiz', |
| | | modeCode: 'quiz', |
| | | modeName: '提问', |
| | | needPaper: '0', |
| | | needQuiz: '1', |
| | | needPractice: '0', |
| | | enabled: '1', |
| | | remark: '现场提问考核', |
| | | }, |
| | | { |
| | | id: 'tms_eval_practice', |
| | | modeCode: 'practice', |
| | | modeName: '现场操作', |
| | | needPaper: '0', |
| | | needQuiz: '0', |
| | | needPractice: '1', |
| | | enabled: '1', |
| | | remark: '实操评分', |
| | | }, |
| | | { |
| | | id: 'tms_eval_exam', |
| | | modeCode: 'exam', |
| | | modeName: '在线考试', |
| | | needPaper: '1', |
| | | needQuiz: '0', |
| | | needPractice: '0', |
| | | enabled: '1', |
| | | remark: '发布任务时生成在线考试待考行', |
| | | }, |
| | | { |
| | | id: 'tms_eval_none', |
| | | modeCode: 'none', |
| | | modeName: '无需考核', |
| | | needPaper: '0', |
| | | needQuiz: '0', |
| | | needPractice: '0', |
| | | enabled: '1', |
| | | remark: '', |
| | | }, |
| | | ]; |
| | | |
| | | export function mockQueryEvalModes(params: EvalModePageQuery = {}) { |
| | | const { currentPage = 1, pageSize = 20, keyword = '', enabled } = params; |
| | | let list = [...store]; |
| | | if (enabled) list = list.filter((x) => x.enabled === enabled); |
| | | if (keyword) { |
| | | const k = keyword.trim().toLowerCase(); |
| | | list = list.filter( |
| | | (x) => x.modeCode.toLowerCase().includes(k) || x.modeName.toLowerCase().includes(k), |
| | | ); |
| | | } |
| | | const start = (currentPage - 1) * pageSize; |
| | | return delay({ |
| | | list: list.slice(start, start + pageSize), |
| | | pagination: { total: list.length, currentPage, pageSize }, |
| | | }); |
| | | } |
| | | |
| | | export async function mockGetEvalMode(id: string) { |
| | | await delay(null); |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) throw new Error('考核方式不存在'); |
| | | return { ...row }; |
| | | } |
| | | |
| | | export async function mockSaveEvalMode(data: Partial<EvalModeItem> & { id?: string }) { |
| | | await delay(null); |
| | | if (data.id) { |
| | | const row = store.find((x) => x.id === data.id); |
| | | if (!row) throw new Error('考核方式不存在'); |
| | | if (data.modeCode && data.modeCode !== row.modeCode) { |
| | | if (store.some((x) => x.modeCode === data.modeCode && x.id !== data.id)) { |
| | | throw new Error('方式编码已存在'); |
| | | } |
| | | } |
| | | Object.assign(row, data); |
| | | return row; |
| | | } |
| | | if (!data.modeCode) throw new Error('请填写方式编码'); |
| | | if (store.some((x) => x.modeCode === data.modeCode)) throw new Error('方式编码已存在'); |
| | | const row: EvalModeItem = { |
| | | id: `em_${Date.now()}`, |
| | | modeCode: data.modeCode, |
| | | modeName: data.modeName || data.modeCode, |
| | | needPaper: (data.needPaper as any) || '0', |
| | | needQuiz: (data.needQuiz as any) || '0', |
| | | needPractice: (data.needPractice as any) || '0', |
| | | enabled: (data.enabled as any) || '1', |
| | | remark: data.remark || '', |
| | | }; |
| | | store.push(row); |
| | | return row; |
| | | } |
| | | |
| | | export async function mockSetEvalModeEnabled(id: string, enabled: '0' | '1') { |
| | | await delay(null); |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) throw new Error('考核方式不存在'); |
| | | row.enabled = enabled; |
| | | return row; |
| | | } |
| | | |
| | | export async function mockDeleteEvalMode(id: string) { |
| | | await delay(null); |
| | | const idx = store.findIndex((x) => x.id === id); |
| | | if (idx < 0) throw new Error('考核方式不存在'); |
| | | store.splice(idx, 1); |
| | | return true; |
| | | } |
| New file |
| | |
| | | /** 考核方式(评估方式)配置 */ |
| | | export interface EvalModeItem { |
| | | id: string; |
| | | modeCode: string; |
| | | modeName: string; |
| | | needPaper: '0' | '1'; |
| | | needQuiz: '0' | '1'; |
| | | needPractice: '0' | '1'; |
| | | enabled: '0' | '1'; |
| | | remark?: string; |
| | | } |
| | | |
| | | export interface EvalModePageQuery { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | keyword?: string; |
| | | enabled?: string; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { GradeExamDetail, GradeExamItem } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | |
| | | import { getGradeExamDetail, submitGrade } from '#/api/x/tms/examGrade'; |
| | | import { labelOfType } from '#/views/x/tms/question/constants'; |
| | | |
| | | import { colorOfGradeStatus, labelOfGradeStatus } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsExamGradeMark' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const loading = ref(false); |
| | | const saving = ref(false); |
| | | const detail = ref<GradeExamDetail | null>(null); |
| | | const scoreMap = reactive<Record<string, number | undefined>>({}); |
| | | |
| | | const readonly = computed(() => detail.value?.gradeStatus === 'graded'); |
| | | |
| | | const subjectiveTotal = computed(() => { |
| | | if (!detail.value) return 0; |
| | | return detail.value.items |
| | | .filter((x) => x.isSubjective === '1') |
| | | .reduce((sum, x) => sum + Number(scoreMap[x.id] ?? 0), 0); |
| | | }); |
| | | |
| | | const previewTotal = computed(() => { |
| | | if (!detail.value) return 0; |
| | | return Number(detail.value.objectiveScore || 0) + subjectiveTotal.value; |
| | | }); |
| | | |
| | | onMounted(() => { |
| | | loadDetail(); |
| | | }); |
| | | |
| | | async function loadDetail() { |
| | | const id = String(route.params.id || ''); |
| | | if (!id) { |
| | | router.replace('/tms/examGrade'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | detail.value = await getGradeExamDetail(id); |
| | | detail.value.items.forEach((it) => { |
| | | if (it.isSubjective === '1') { |
| | | scoreMap[it.id] = it.gotScore; |
| | | } |
| | | }); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载答卷失败'); |
| | | router.back(); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function stripHtml(html?: string) { |
| | | if (!html) return ''; |
| | | return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); |
| | | } |
| | | |
| | | function goBack() { |
| | | if (detail.value?.sessionId) { |
| | | router.push(`/tms/examGrade/session/${detail.value.sessionId}`); |
| | | } else { |
| | | router.push('/tms/examGrade'); |
| | | } |
| | | } |
| | | |
| | | function validateScores(): string | null { |
| | | if (!detail.value) return '答卷不存在'; |
| | | for (const it of detail.value.items) { |
| | | if (it.isSubjective !== '1') continue; |
| | | const v = scoreMap[it.id]; |
| | | if (v === undefined || v === null || Number.isNaN(Number(v))) { |
| | | return '请为所有主观题打分'; |
| | | } |
| | | if (Number(v) < 0 || Number(v) > Number(it.score)) { |
| | | return `主观题得分需在 0 ~ ${it.score} 之间`; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | if (!detail.value || readonly.value) return; |
| | | const err = validateScores(); |
| | | if (err) { |
| | | createMessage.warning(err); |
| | | 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}`); |
| | | goBack(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '提交失败'); |
| | | } finally { |
| | | saving.value = false; |
| | | } |
| | | } |
| | | |
| | | function itemClass(it: GradeExamItem) { |
| | | return it.isSubjective === '1' ? 'grade-item subjective' : 'grade-item'; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-grade-page"> |
| | | <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> |
| | | <div class="text-base font-medium"> |
| | | {{ detail?.paperName || '阅卷' }} · 阅卷 |
| | | </div> |
| | | <div v-if="detail" class="mt-1 text-gray-400 text-sm"> |
| | | 考生:{{ detail.userName }} |
| | | <span v-if="detail.deptName">({{ detail.deptName }})</span> |
| | | <span class="ml-3">交卷:{{ detail.submitTime || '-' }}</span> |
| | | <span class="ml-3" :style="{ color: colorOfGradeStatus(detail.gradeStatus) }"> |
| | | {{ labelOfGradeStatus(detail.gradeStatus) }} |
| | | </span> |
| | | </div> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="goBack">返回</a-button> |
| | | <a-button v-if="detail && !readonly" type="primary" :loading="saving" @click="handleSubmit"> |
| | | 提交阅卷 |
| | | </a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | | <div v-if="detail" class="score-bar"> |
| | | <span>客观题 {{ detail.objectiveScore }} 分</span> |
| | | <span class="mx-3">主观题 {{ subjectiveTotal }} 分</span> |
| | | <span> |
| | | 合计 |
| | | <b class="text-primary">{{ previewTotal }}</b> |
| | | / {{ detail.totalScore }}(合格 {{ detail.passScore }}) |
| | | </span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="mark-body"> |
| | | <a-spin :spinning="loading"> |
| | | <template v-if="detail"> |
| | | <div |
| | | v-for="(it, idx) in detail.items" |
| | | :key="it.id" |
| | | :class="itemClass(it)" |
| | | > |
| | | <div class="mb-2 text-sm text-gray-500"> |
| | | 第 {{ idx + 1 }} 题 · {{ labelOfType(it.questionType) }} |
| | | ({{ it.score }} 分) |
| | | <span v-if="it.isSubjective === '1'" class="text-orange-500 ml-2">主观题</span> |
| | | </div> |
| | | <div class="stem mb-3">{{ stripHtml(it.stem) }}</div> |
| | | |
| | | <div v-if="it.options?.length" class="mb-2 text-sm text-gray-600"> |
| | | <div v-for="opt in it.options" :key="opt.optionLabel"> |
| | | {{ opt.optionLabel }}. {{ opt.optionContent }} |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="answer-row text-sm"> |
| | | <div>考生答案:{{ it.userAnswer || '(未作答)' }}</div> |
| | | <div v-if="it.isSubjective !== '1'">正确答案:{{ it.correctAnswer || '-' }}</div> |
| | | <div v-if="it.analysis" class="text-gray-400">解析:{{ it.analysis }}</div> |
| | | </div> |
| | | |
| | | <div class="mt-3 flex items-center gap-2"> |
| | | <template v-if="it.isSubjective === '1'"> |
| | | <span>得分</span> |
| | | <a-input-number |
| | | v-model:value="scoreMap[it.id]" |
| | | :min="0" |
| | | :max="it.score" |
| | | :precision="1" |
| | | :disabled="readonly" |
| | | class="!w-[120px]" |
| | | /> |
| | | <span class="text-gray-400">/ {{ it.score }}</span> |
| | | </template> |
| | | <template v-else> |
| | | <span class="text-gray-500">自动得分:{{ it.gotScore ?? 0 }}</span> |
| | | </template> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </a-spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* 全局 jnpf-content-wrapper* 为 overflow:hidden 且无 min-height:0,必须整条链补齐 */ |
| | | .tms-grade-page { |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-grade-center { |
| | | min-height: 0 !important; |
| | | } |
| | | |
| | | .tms-grade-mark-wrap { |
| | | 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; |
| | | } |
| | | |
| | | .mark-top { |
| | | flex-shrink: 0; |
| | | padding: 16px 20px 0; |
| | | } |
| | | |
| | | .mark-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | padding-bottom: 12px; |
| | | margin-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .score-bar { |
| | | background: #fafafa; |
| | | border-radius: 6px; |
| | | padding: 10px 14px; |
| | | margin-bottom: 12px; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .mark-body { |
| | | flex: 1 1 0; |
| | | min-height: 0; |
| | | overflow-y: auto !important; |
| | | overflow-x: hidden; |
| | | padding: 0 20px 32px; |
| | | -webkit-overflow-scrolling: touch; |
| | | } |
| | | |
| | | .grade-item { |
| | | border: 1px solid #f0f0f0; |
| | | border-radius: 8px; |
| | | padding: 14px 16px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .grade-item.subjective { |
| | | border-color: #ffd591; |
| | | background: #fffbe6; |
| | | } |
| | | |
| | | .stem { |
| | | font-size: 15px; |
| | | line-height: 1.7; |
| | | } |
| | | |
| | | .answer-row { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 4px; |
| | | color: rgba(0, 0, 0, 0.75); |
| | | } |
| | | </style> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { GradeExamListItem, GradeSessionListItem } from './types'; |
| | | |
| | | import { 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 { colorOfGradeStatus, labelOfGradeStatus } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsExamGradeSession' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const session = ref<GradeSessionListItem | null>(null); |
| | | const sessionId = String(route.params.id || ''); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '考生', dataIndex: 'userName', width: 120 }, |
| | | { title: '部门', dataIndex: 'deptName', minWidth: 140 }, |
| | | { title: '交卷时间', dataIndex: 'submitTime', width: 170 }, |
| | | { |
| | | title: '客观题得分', |
| | | dataIndex: 'objectiveScore', |
| | | width: 110, |
| | | align: 'center', |
| | | customRender: ({ record }) => (record as GradeExamListItem).objectiveScore ?? '-', |
| | | }, |
| | | { |
| | | title: '主观题得分', |
| | | dataIndex: 'subjectiveScore', |
| | | width: 110, |
| | | align: 'center', |
| | | customRender: ({ record }) => (record as GradeExamListItem).subjectiveScore ?? '-', |
| | | }, |
| | | { |
| | | title: '总分', |
| | | dataIndex: 'totalScore', |
| | | width: 90, |
| | | align: 'center', |
| | | customRender: ({ record }) => (record as GradeExamListItem).totalScore ?? '-', |
| | | }, |
| | | { |
| | | title: '阅卷状态', |
| | | dataIndex: 'gradeStatus', |
| | | width: 100, |
| | | align: 'center', |
| | | slots: { default: 'gradeStatus' }, |
| | | }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: false, |
| | | rowKey: 'id', |
| | | useSearchForm: false, |
| | | pagination: false, |
| | | actionColumn: { |
| | | width: 100, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | onMounted(async () => { |
| | | if (!sessionId) { |
| | | router.replace('/tms/examGrade'); |
| | | return; |
| | | } |
| | | try { |
| | | session.value = await getGradeSessionInfo(sessionId); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载失败'); |
| | | router.replace('/tms/examGrade'); |
| | | } |
| | | }); |
| | | |
| | | async function fetchList() { |
| | | return { data: await getGradeExamList(sessionId) }; |
| | | } |
| | | |
| | | function goBack() { |
| | | router.push('/tms/examGrade'); |
| | | } |
| | | |
| | | function handleMark(record: GradeExamListItem) { |
| | | router.push(`/tms/examGrade/mark/${record.id}`); |
| | | } |
| | | |
| | | function getTableActions(record: GradeExamListItem): ActionItem[] { |
| | | if (record.gradeStatus === 'pending') { |
| | | return [{ label: '阅卷', onClick: handleMark.bind(null, record) }]; |
| | | } |
| | | return [{ label: '查看', onClick: handleMark.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <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> |
| | | <div class="text-base font-medium"> |
| | | {{ session?.paperName || '答卷列表' }} |
| | | </div> |
| | | <div class="mt-1 text-gray-400 text-sm"> |
| | | {{ session?.taskNo }} · {{ session?.taskSubject }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | <template #gradeStatus="{ record }"> |
| | | <span :style="{ color: colorOfGradeStatus(record.gradeStatus) }"> |
| | | {{ labelOfGradeStatus(record.gradeStatus) }} |
| | | </span> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | import type { GradeStatus } from './types'; |
| | | |
| | | 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' }, |
| | | ]; |
| | | |
| | | export function labelOfGradeStatus(v?: string) { |
| | | return GRADE_STATUS_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-'; |
| | | } |
| | | |
| | | export function colorOfGradeStatus(v?: string) { |
| | | return GRADE_STATUS_OPTIONS.find((x) => x.id === v)?.color; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { GradeSessionListItem } from './types'; |
| | | |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { getGradeSessionList } from '#/api/x/tms/examGrade'; |
| | | |
| | | defineOptions({ name: 'TmsExamGrade' }); |
| | | |
| | | const router = useRouter(); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '培训编号', dataIndex: 'taskNo', width: 140 }, |
| | | { title: '培训主题', dataIndex: 'taskSubject', minWidth: 260 }, |
| | | { title: '试卷名称', dataIndex: 'paperName', minWidth: 200 }, |
| | | { |
| | | title: '考试时间', |
| | | dataIndex: 'examTime', |
| | | minWidth: 220, |
| | | slots: { default: 'examTime' }, |
| | | }, |
| | | { |
| | | title: '卷面总分', |
| | | dataIndex: 'totalScore', |
| | | width: 100, |
| | | align: 'center', |
| | | slots: { default: 'totalScore' }, |
| | | }, |
| | | { |
| | | title: '合格分数', |
| | | dataIndex: 'passScore', |
| | | width: 100, |
| | | align: 'center', |
| | | slots: { default: 'passScore' }, |
| | | }, |
| | | ]; |
| | | |
| | | const [registerTable] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | schemas: [ |
| | | { |
| | | field: 'taskNo', |
| | | label: '培训编号', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入培训编号', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'keyword', |
| | | label: '关键词', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '主题/试卷名称', submitOnPressEnter: true }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 100, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getGradeSessionList(params) }; |
| | | } |
| | | |
| | | function handleEnter(record: GradeSessionListItem) { |
| | | router.push(`/tms/examGrade/session/${record.id}`); |
| | | } |
| | | |
| | | function getTableActions(record: GradeSessionListItem): ActionItem[] { |
| | | return [{ label: '进入', onClick: handleEnter.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <div> |
| | | <div class="text-base font-medium">考试阅卷</div> |
| | | <div class="mt-1 text-gray-400 text-sm">阅卷列表,选择试卷进行阅卷,或查看考试详情。</div> |
| | | </div> |
| | | </template> |
| | | <template #examTime="{ record }"> |
| | | <div class="leading-5"> |
| | | <div>{{ record.durationMin }} 分钟</div> |
| | | <div class="text-gray-400 text-xs">{{ record.examStart }} 到 {{ record.examEnd }}</div> |
| | | </div> |
| | | </template> |
| | | <template #totalScore="{ record }"> |
| | | <div class="leading-5"> |
| | | <div class="text-xs text-gray-400">总分</div> |
| | | <div>{{ record.totalScore }}分</div> |
| | | </div> |
| | | </template> |
| | | <template #passScore="{ record }"> |
| | | <div class="leading-5"> |
| | | <div class="text-xs text-gray-400">合格分</div> |
| | | <div>{{ record.passScore }}分</div> |
| | | </div> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | import type { |
| | | GradeExamDetail, |
| | | GradeExamListItem, |
| | | GradeSessionListItem, |
| | | GradeSessionPageQuery, |
| | | GradeSubmitPayload, |
| | | } from './types'; |
| | | |
| | | function delay<T>(data: T, ms = 220): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | const sessions: GradeSessionListItem[] = [ |
| | | { |
| | | id: 'gs1', |
| | | taskNo: 'TT2025120575', |
| | | taskSubject: '再确认测试', |
| | | paperId: 'p1', |
| | | paperName: '再确认测试', |
| | | durationMin: 60, |
| | | examStart: '2025-12-22 09:00', |
| | | examEnd: '2025-12-22 09:30', |
| | | totalScore: 100, |
| | | passScore: 100, |
| | | pendingCount: 1, |
| | | submittedCount: 2, |
| | | }, |
| | | { |
| | | id: 'gs2', |
| | | taskNo: 'TT2026040074', |
| | | taskSubject: '2026年度非无菌分析实验室安全培训与日常安全检查宣讲', |
| | | paperId: 'p2', |
| | | paperName: '分析卷13 安全培训', |
| | | durationMin: 60, |
| | | examStart: '2026-04-07 11:40', |
| | | examEnd: '2026-04-10 17:30', |
| | | totalScore: 100, |
| | | passScore: 100, |
| | | pendingCount: 3, |
| | | submittedCount: 5, |
| | | }, |
| | | { |
| | | id: 'gs3', |
| | | taskNo: 'TT2026060155', |
| | | taskSubject: |
| | | 'GMP基础知识培训及案例分析(临床试验用药保障目录)、药品管理法、药品生产质量管理规范', |
| | | paperId: 'p3', |
| | | paperName: 'GMP基础知识培训(QC、分析)', |
| | | durationMin: 60, |
| | | examStart: '2026-06-15 10:00', |
| | | examEnd: '2026-06-18 18:00', |
| | | totalScore: 100, |
| | | passScore: 100, |
| | | pendingCount: 2, |
| | | submittedCount: 8, |
| | | }, |
| | | { |
| | | id: 'gs4', |
| | | taskNo: 'TT2026090101', |
| | | taskSubject: '2026-药物警戒年度培训', |
| | | paperId: 'p4', |
| | | paperName: '2026-药物警戒年度培训试题', |
| | | durationMin: 90, |
| | | examStart: '2026-09-01 09:00', |
| | | examEnd: '2026-09-30 18:00', |
| | | totalScore: 100, |
| | | passScore: 60, |
| | | pendingCount: 4, |
| | | submittedCount: 12, |
| | | }, |
| | | ]; |
| | | |
| | | const examsBySession: Record<string, GradeExamListItem[]> = { |
| | | gs1: [ |
| | | { |
| | | id: 'ex1', |
| | | sessionId: 'gs1', |
| | | userId: 'u1', |
| | | userName: '张三', |
| | | deptName: '质量管理部', |
| | | submitTime: '2025-12-22 09:25:00', |
| | | objectiveScore: 80, |
| | | subjectiveScore: undefined, |
| | | totalScore: undefined, |
| | | passScore: 100, |
| | | gradeStatus: 'pending', |
| | | }, |
| | | { |
| | | id: 'ex2', |
| | | sessionId: 'gs1', |
| | | userId: 'u2', |
| | | userName: '李四', |
| | | deptName: '生产部', |
| | | submitTime: '2025-12-22 09:28:00', |
| | | objectiveScore: 100, |
| | | subjectiveScore: 0, |
| | | totalScore: 100, |
| | | passScore: 100, |
| | | gradeStatus: 'graded', |
| | | passFlag: '1', |
| | | }, |
| | | ], |
| | | gs2: [ |
| | | { |
| | | id: 'ex3', |
| | | sessionId: 'gs2', |
| | | userId: 'u3', |
| | | userName: '王五', |
| | | deptName: '分析实验室', |
| | | submitTime: '2026-04-08 14:20:00', |
| | | objectiveScore: 70, |
| | | gradeStatus: 'pending', |
| | | passScore: 100, |
| | | }, |
| | | { |
| | | id: 'ex4', |
| | | sessionId: 'gs2', |
| | | userId: 'u4', |
| | | userName: '赵六', |
| | | deptName: '分析实验室', |
| | | submitTime: '2026-04-09 10:05:00', |
| | | objectiveScore: 85, |
| | | gradeStatus: 'pending', |
| | | passScore: 100, |
| | | }, |
| | | ], |
| | | gs3: [ |
| | | { |
| | | id: 'ex5', |
| | | sessionId: 'gs3', |
| | | userId: 'u5', |
| | | userName: '陈七', |
| | | deptName: 'QC', |
| | | submitTime: '2026-06-16 11:00:00', |
| | | objectiveScore: 60, |
| | | gradeStatus: 'pending', |
| | | passScore: 100, |
| | | }, |
| | | ], |
| | | gs4: [ |
| | | { |
| | | id: 'ex6', |
| | | sessionId: 'gs4', |
| | | userId: 'u6', |
| | | userName: '周八', |
| | | deptName: '药物警戒', |
| | | submitTime: '2026-09-10 16:40:00', |
| | | objectiveScore: 40, |
| | | gradeStatus: 'pending', |
| | | passScore: 60, |
| | | }, |
| | | { |
| | | id: 'ex7', |
| | | sessionId: 'gs4', |
| | | userId: 'u7', |
| | | userName: '吴九', |
| | | deptName: '药物警戒', |
| | | submitTime: '2026-09-12 09:15:00', |
| | | objectiveScore: 50, |
| | | subjectiveScore: 20, |
| | | totalScore: 70, |
| | | gradeStatus: 'graded', |
| | | passScore: 60, |
| | | passFlag: '1', |
| | | }, |
| | | ], |
| | | }; |
| | | |
| | | const examDetails: Record<string, GradeExamDetail> = { |
| | | ex1: { |
| | | examId: 'ex1', |
| | | sessionId: 'gs1', |
| | | paperName: '再确认测试', |
| | | userName: '张三', |
| | | deptName: '质量管理部', |
| | | submitTime: '2025-12-22 09:25:00', |
| | | totalScore: 100, |
| | | passScore: 100, |
| | | objectiveScore: 80, |
| | | subjectiveScore: 0, |
| | | gradeStatus: 'pending', |
| | | items: [ |
| | | { |
| | | id: 'ei1', |
| | | questionType: 'single', |
| | | stem: 'GMP 的全称是?', |
| | | options: [ |
| | | { optionLabel: 'A', optionContent: '药品生产质量管理规范' }, |
| | | { optionLabel: 'B', optionContent: '药品经营质量管理规范' }, |
| | | ], |
| | | correctAnswer: 'A', |
| | | userAnswer: 'A', |
| | | score: 20, |
| | | gotScore: 20, |
| | | isSubjective: '0', |
| | | }, |
| | | { |
| | | id: 'ei2', |
| | | questionType: 'multi', |
| | | stem: '下列哪些属于特种设备?', |
| | | options: [ |
| | | { optionLabel: 'A', optionContent: '电梯' }, |
| | | { optionLabel: 'B', optionContent: '压力容器' }, |
| | | { optionLabel: 'C', optionContent: '普通办公桌' }, |
| | | ], |
| | | correctAnswer: 'A,B', |
| | | userAnswer: 'A,B', |
| | | score: 30, |
| | | gotScore: 30, |
| | | isSubjective: '0', |
| | | }, |
| | | { |
| | | id: 'ei3', |
| | | questionType: 'judge', |
| | | stem: '特种作业人员必须取得有效资格证书后方可上岗作业。', |
| | | correctAnswer: '正确', |
| | | userAnswer: '正确', |
| | | score: 30, |
| | | gotScore: 30, |
| | | isSubjective: '0', |
| | | }, |
| | | { |
| | | id: 'ei4', |
| | | questionType: 'essay', |
| | | stem: '请简述实验室安全操作要点。', |
| | | userAnswer: '穿戴防护用品,遵守SOP,异常及时上报。', |
| | | score: 20, |
| | | gotScore: undefined, |
| | | isSubjective: '1', |
| | | analysis: '需覆盖防护、SOP、应急处理等要点。', |
| | | }, |
| | | ], |
| | | }, |
| | | }; |
| | | |
| | | export function mockQueryGradeSessions(params: GradeSessionPageQuery) { |
| | | let list = [...sessions]; |
| | | if (params.taskNo && params.taskNo !== 'null') { |
| | | const kw = params.taskNo.trim().toLowerCase(); |
| | | list = list.filter((x) => x.taskNo.toLowerCase().includes(kw)); |
| | | } |
| | | if (params.keyword && params.keyword !== 'null') { |
| | | const kw = params.keyword.trim().toLowerCase(); |
| | | list = list.filter( |
| | | (x) => |
| | | x.taskSubject.toLowerCase().includes(kw) || |
| | | x.paperName.toLowerCase().includes(kw) || |
| | | x.taskNo.toLowerCase().includes(kw), |
| | | ); |
| | | } |
| | | const currentPage = Number(params.currentPage || 1); |
| | | const pageSize = Number(params.pageSize || 20); |
| | | const start = (currentPage - 1) * pageSize; |
| | | return delay({ |
| | | list: list.slice(start, start + pageSize), |
| | | pagination: { total: list.length, currentPage, pageSize }, |
| | | }); |
| | | } |
| | | |
| | | export function mockGetGradeSession(id: string) { |
| | | const row = sessions.find((x) => x.id === id); |
| | | if (!row) return Promise.reject(new Error('阅卷场次不存在')); |
| | | return delay({ ...row }); |
| | | } |
| | | |
| | | export function mockQueryGradeExams(sessionId: string) { |
| | | const list = examsBySession[sessionId] || []; |
| | | return delay({ |
| | | list, |
| | | pagination: { total: list.length, currentPage: 1, pageSize: 50 }, |
| | | }); |
| | | } |
| | | |
| | | export function mockGetGradeExamDetail(examId: string): Promise<GradeExamDetail> { |
| | | const cached = examDetails[examId]; |
| | | if (cached) return delay(JSON.parse(JSON.stringify(cached))); |
| | | |
| | | // 通用兜底:按列表生成一份可阅卷答卷 |
| | | let found: GradeExamListItem | undefined; |
| | | let sessionId = ''; |
| | | for (const [sid, list] of Object.entries(examsBySession)) { |
| | | found = list.find((x) => x.id === examId); |
| | | if (found) { |
| | | sessionId = sid; |
| | | break; |
| | | } |
| | | } |
| | | if (!found) return Promise.reject(new Error('答卷不存在')); |
| | | const session = sessions.find((x) => x.id === sessionId); |
| | | return delay({ |
| | | examId: found.id, |
| | | sessionId, |
| | | paperName: session?.paperName || '', |
| | | userName: found.userName, |
| | | deptName: found.deptName, |
| | | submitTime: found.submitTime, |
| | | totalScore: session?.totalScore || 100, |
| | | passScore: found.passScore || session?.passScore || 60, |
| | | objectiveScore: found.objectiveScore || 0, |
| | | subjectiveScore: found.subjectiveScore || 0, |
| | | gradeStatus: found.gradeStatus, |
| | | items: [ |
| | | { |
| | | id: `${examId}_obj`, |
| | | questionType: 'single', |
| | | stem: '客观题示例(已自动计分)', |
| | | correctAnswer: 'A', |
| | | userAnswer: 'A', |
| | | score: found.objectiveScore || 60, |
| | | gotScore: found.objectiveScore || 60, |
| | | isSubjective: '0', |
| | | }, |
| | | { |
| | | id: `${examId}_sub`, |
| | | questionType: 'essay', |
| | | stem: '请简述本次培训的核心要点。', |
| | | userAnswer: '考生作答内容……', |
| | | score: 40, |
| | | gotScore: found.gradeStatus === 'graded' ? found.subjectiveScore : undefined, |
| | | isSubjective: '1', |
| | | }, |
| | | ], |
| | | }); |
| | | } |
| | | |
| | | export function mockSubmitGrade(payload: GradeSubmitPayload) { |
| | | const detail = examDetails[payload.examId]; |
| | | let subjective = 0; |
| | | payload.items.forEach((it) => { |
| | | subjective += Number(it.gotScore || 0); |
| | | if (detail) { |
| | | const row = detail.items.find((x) => x.id === it.id); |
| | | if (row) row.gotScore = it.gotScore; |
| | | } |
| | | }); |
| | | |
| | | for (const list of Object.values(examsBySession)) { |
| | | const exam = list.find((x) => x.id === payload.examId); |
| | | if (exam) { |
| | | exam.subjectiveScore = subjective; |
| | | exam.totalScore = Number(exam.objectiveScore || 0) + subjective; |
| | | exam.gradeStatus = 'graded'; |
| | | exam.passFlag = (exam.totalScore || 0) >= (exam.passScore || 0) ? '1' : '0'; |
| | | break; |
| | | } |
| | | } |
| | | if (detail) { |
| | | detail.subjectiveScore = subjective; |
| | | detail.gradeStatus = 'graded'; |
| | | detail.items.forEach((it) => { |
| | | const hit = payload.items.find((p) => p.id === it.id); |
| | | if (hit) it.gotScore = hit.gotScore; |
| | | }); |
| | | } |
| | | return delay({ msg: '阅卷提交成功' }); |
| | | } |
| New file |
| | |
| | | /** 阅卷列表行(按培训任务+试卷聚合) */ |
| | | export interface GradeSessionListItem { |
| | | id: string; |
| | | taskNo: string; |
| | | taskSubject: string; |
| | | paperId: string; |
| | | paperName: string; |
| | | durationMin: number; |
| | | examStart: string; |
| | | examEnd: string; |
| | | totalScore: number; |
| | | passScore: number; |
| | | /** 待批改人数 */ |
| | | pendingCount?: number; |
| | | /** 已交卷人数 */ |
| | | submittedCount?: number; |
| | | } |
| | | |
| | | export interface GradeSessionPageQuery { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | keyword?: string; |
| | | taskNo?: string; |
| | | } |
| | | |
| | | export type GradeStatus = 'auto' | 'pending' | 'graded'; |
| | | |
| | | /** 某场阅卷下的考生答卷 */ |
| | | export interface GradeExamListItem { |
| | | id: string; |
| | | sessionId: string; |
| | | userId: string; |
| | | userName: string; |
| | | deptName?: string; |
| | | submitTime?: string; |
| | | objectiveScore?: number; |
| | | subjectiveScore?: number; |
| | | totalScore?: number; |
| | | passScore?: number; |
| | | gradeStatus: GradeStatus; |
| | | passFlag?: '0' | '1'; |
| | | } |
| | | |
| | | /** 阅卷详情(单份答卷) */ |
| | | export interface GradeExamDetail { |
| | | examId: string; |
| | | sessionId: string; |
| | | paperName: string; |
| | | userName: string; |
| | | deptName?: string; |
| | | submitTime?: string; |
| | | totalScore: number; |
| | | passScore: number; |
| | | objectiveScore: number; |
| | | subjectiveScore: number; |
| | | gradeStatus: GradeStatus; |
| | | items: GradeExamItem[]; |
| | | } |
| | | |
| | | export interface GradeExamItem { |
| | | id: string; |
| | | questionType: 'single' | 'multi' | 'judge' | 'blank' | 'essay'; |
| | | stem: string; |
| | | options?: { optionLabel: string; optionContent: string }[]; |
| | | correctAnswer?: string; |
| | | userAnswer?: string; |
| | | score: number; |
| | | gotScore?: number; |
| | | isSubjective: '0' | '1'; |
| | | analysis?: string; |
| | | } |
| | | |
| | | export interface GradeSubmitPayload { |
| | | examId: string; |
| | | items: { id: string; gotScore: number }[]; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { ExamScoreDetailRow } from './types'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { getExamScoreDetail } from '#/api/x/tms/examScore'; |
| | | |
| | | defineOptions({ name: 'TmsExamScoreDetail' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const titleText = ref('考生试卷列表'); |
| | | const summaryId = String(route.params.id || ''); |
| | | const loaded = ref(false); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '培训主题', dataIndex: 'taskSubject', minWidth: 220 }, |
| | | { title: '培训任务编号', dataIndex: 'taskNo', width: 140 }, |
| | | { |
| | | title: '用户ID', |
| | | dataIndex: 'userName', |
| | | width: 120, |
| | | customRender: ({ record }) => { |
| | | const row = record as ExamScoreDetailRow; |
| | | return row.userName || row.userId; |
| | | }, |
| | | }, |
| | | { title: '考试开始时间', dataIndex: 'examStart', width: 170 }, |
| | | { title: '考试结束时间', dataIndex: 'examEnd', width: 170 }, |
| | | { title: '成绩', dataIndex: 'score', width: 80, align: 'center' }, |
| | | { |
| | | title: '是否合格', |
| | | dataIndex: 'passFlag', |
| | | width: 100, |
| | | align: 'center', |
| | | slots: { default: 'passFlag' }, |
| | | }, |
| | | { title: '来源IP地址', dataIndex: 'sourceIp', width: 140 }, |
| | | ]; |
| | | |
| | | const [registerTable, { getSelectRows, reload }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: false, |
| | | rowKey: 'id', |
| | | rowSelection: { type: 'checkbox' }, |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | schemas: [ |
| | | { |
| | | field: 'keyword', |
| | | label: '关键词', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入检索关键字', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'passFlag', |
| | | label: '是否合格', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: [ |
| | | { id: '1', fullName: '是' }, |
| | | { id: '0', fullName: '否' }, |
| | | ], |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | pagination: false, |
| | | actionColumn: { |
| | | width: 110, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | onMounted(async () => { |
| | | if (!summaryId) { |
| | | router.replace('/tms/examScore'); |
| | | return; |
| | | } |
| | | try { |
| | | const detail = await getExamScoreDetail(summaryId); |
| | | titleText.value = `考生试卷列表 · ${detail.taskNo}`; |
| | | loaded.value = true; |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载失败'); |
| | | router.replace('/tms/examScore'); |
| | | } |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | if (!loaded.value) { |
| | | return { data: { list: [], pagination: { total: 0 } } }; |
| | | } |
| | | const detail = await getExamScoreDetail(summaryId); |
| | | let list = [...(detail.rows || [])]; |
| | | if (params.keyword && params.keyword !== 'null') { |
| | | const kw = String(params.keyword).trim().toLowerCase(); |
| | | list = list.filter( |
| | | (x) => |
| | | (x.userName || '').toLowerCase().includes(kw) || |
| | | (x.userId || '').toLowerCase().includes(kw) || |
| | | (x.taskSubject || '').toLowerCase().includes(kw), |
| | | ); |
| | | } |
| | | if (params.passFlag === '0' || params.passFlag === '1') { |
| | | list = list.filter((x) => x.passFlag === params.passFlag); |
| | | } |
| | | return { data: { list, pagination: { total: list.length, currentPage: 1, pageSize: list.length || 20 } } }; |
| | | } |
| | | |
| | | function goBack() { |
| | | router.push('/tms/examScore'); |
| | | } |
| | | |
| | | function viewPaper(record: ExamScoreDetailRow) { |
| | | router.push(`/tms/examScore/paper/${record.id}`); |
| | | } |
| | | |
| | | function handleViewSelected() { |
| | | const rows = (getSelectRows?.() || []) as ExamScoreDetailRow[]; |
| | | if (!rows.length) { |
| | | createMessage.warning('请先勾选一名考生'); |
| | | return; |
| | | } |
| | | if (rows.length > 1) { |
| | | createMessage.warning('一次只能查看一份试卷,请只勾选一条'); |
| | | return; |
| | | } |
| | | viewPaper(rows[0]); |
| | | } |
| | | |
| | | function getTableActions(record: ExamScoreDetailRow): ActionItem[] { |
| | | return [{ label: '查看试卷', onClick: viewPaper.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <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-space> |
| | | </template> |
| | | <template #passFlag="{ record }"> |
| | | <span :class="record.passFlag === '1' ? 'text-green-600' : 'text-red-500'"> |
| | | {{ record.passFlag === '1' ? '是' : '否' }} |
| | | </span> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ExamPaperView } from './types'; |
| | | |
| | | import { 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 { getExamPaperView } from '#/api/x/tms/examScore'; |
| | | import { labelOfType } from '#/views/x/tms/question/constants'; |
| | | |
| | | defineOptions({ name: 'TmsExamScorePaper' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const loading = ref(false); |
| | | const paper = ref<ExamPaperView | null>(null); |
| | | |
| | | onMounted(() => { |
| | | loadPaper(); |
| | | }); |
| | | |
| | | async function loadPaper() { |
| | | const id = String(route.params.id || ''); |
| | | if (!id) { |
| | | router.replace('/tms/examScore'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | paper.value = await getExamPaperView(id); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载试卷失败'); |
| | | router.back(); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function stripHtml(html?: string) { |
| | | if (!html) return ''; |
| | | return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); |
| | | } |
| | | |
| | | function goBack() { |
| | | if (paper.value?.summaryId) { |
| | | router.push(`/tms/examScore/detail/${paper.value.summaryId}`); |
| | | } else { |
| | | router.back(); |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-paper-page"> |
| | | <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> |
| | | <div class="text-base font-medium">考生试卷详情</div> |
| | | <div v-if="paper" class="mt-1 text-gray-400 text-sm"> |
| | | {{ paper.userName }}({{ paper.userId }}) · {{ paper.paperName }} |
| | | </div> |
| | | </div> |
| | | <a-button @click="goBack">返回</a-button> |
| | | </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="考试开始">{{ 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'"> |
| | | {{ paper.passFlag === '1' ? '是' : '否' }} |
| | | </span> |
| | | </ADescriptionsItem> |
| | | </ADescriptions> |
| | | </div> |
| | | |
| | | <div class="paper-body"> |
| | | <a-spin :spinning="loading"> |
| | | <div |
| | | v-for="(it, idx) in paper?.items || []" |
| | | :key="it.id" |
| | | class="paper-item" |
| | | :class="{ subjective: it.isSubjective === '1' }" |
| | | > |
| | | <div class="mb-2 text-sm text-gray-500"> |
| | | 第 {{ idx + 1 }} 题 · {{ labelOfType(it.questionType) }} |
| | | (满分 {{ it.score }} · 得分 {{ it.gotScore }}) |
| | | <span |
| | | v-if="it.isSubjective !== '1'" |
| | | class="ml-2" |
| | | :class="it.isCorrect ? 'text-green-600' : 'text-red-500'" |
| | | > |
| | | {{ it.isCorrect ? '正确' : '错误' }} |
| | | </span> |
| | | <span v-else class="ml-2 text-orange-500">主观题</span> |
| | | </div> |
| | | <div class="stem mb-3">{{ stripHtml(it.stem) }}</div> |
| | | <div v-if="it.options?.length" class="mb-2 text-sm text-gray-600"> |
| | | <div v-for="opt in it.options" :key="opt.optionLabel"> |
| | | {{ opt.optionLabel }}. {{ opt.optionContent }} |
| | | </div> |
| | | </div> |
| | | <div class="text-sm answer-row"> |
| | | <div>考生答案:{{ it.userAnswer || '(未作答)' }}</div> |
| | | <div v-if="it.correctAnswer">参考/正确答案:{{ it.correctAnswer }}</div> |
| | | </div> |
| | | </div> |
| | | </a-spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-paper-page { |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-paper-center { |
| | | min-height: 0 !important; |
| | | } |
| | | |
| | | .tms-paper-wrap { |
| | | display: flex !important; |
| | | flex-direction: column; |
| | | flex: 1 1 0 !important; |
| | | min-height: 0 !important; |
| | | overflow: hidden !important; |
| | | background: #fff; |
| | | padding: 0; |
| | | } |
| | | |
| | | .paper-top { |
| | | flex-shrink: 0; |
| | | padding: 16px 20px 0; |
| | | } |
| | | |
| | | .paper-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 12px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .paper-body { |
| | | flex: 1 1 0; |
| | | min-height: 0; |
| | | overflow-y: auto !important; |
| | | padding: 0 20px 32px; |
| | | } |
| | | |
| | | .paper-item { |
| | | border: 1px solid #f0f0f0; |
| | | border-radius: 8px; |
| | | padding: 14px 16px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .paper-item.subjective { |
| | | border-color: #ffd591; |
| | | background: #fffbe6; |
| | | } |
| | | |
| | | .stem { |
| | | font-size: 15px; |
| | | line-height: 1.7; |
| | | } |
| | | |
| | | .answer-row { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 4px; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { ExamScoreSummaryItem } from './types'; |
| | | |
| | | 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'; |
| | | |
| | | defineOptions({ name: 'TmsExamScore' }); |
| | | |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '培训主题', dataIndex: 'taskSubject', minWidth: 240 }, |
| | | { title: '培训编号', dataIndex: 'taskNo', width: 140 }, |
| | | { |
| | | title: '试卷名称', |
| | | dataIndex: 'paperName', |
| | | minWidth: 220, |
| | | slots: { default: 'paperName' }, |
| | | }, |
| | | { |
| | | title: '参考人数', |
| | | dataIndex: 'participantCount', |
| | | width: 90, |
| | | align: 'center', |
| | | }, |
| | | { |
| | | title: '不及格数', |
| | | dataIndex: 'failCount', |
| | | width: 90, |
| | | align: 'center', |
| | | slots: { default: 'failCount' }, |
| | | }, |
| | | { title: '最高分', dataIndex: 'maxScore', width: 80, align: 'center' }, |
| | | { title: '最低分', dataIndex: 'minScore', width: 80, align: 'center' }, |
| | | { title: '平均分', dataIndex: 'avgScore', width: 80, align: 'center' }, |
| | | { title: '及格分', dataIndex: 'passScore', width: 80, align: 'center' }, |
| | | { title: '总分', dataIndex: 'totalScore', width: 80, align: 'center' }, |
| | | ]; |
| | | |
| | | const [registerTable, { getSelectRows }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | rowKey: 'id', |
| | | rowSelection: { type: 'checkbox' }, |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | schemas: [ |
| | | { |
| | | field: 'keyword', |
| | | label: '关键词', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入检索关键字', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'taskNo', |
| | | label: '培训编号', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入培训编号', submitOnPressEnter: true }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 110, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getExamScoreList(params) }; |
| | | } |
| | | |
| | | function handleDetail(record: ExamScoreSummaryItem) { |
| | | router.push(`/tms/examScore/detail/${record.id}`); |
| | | } |
| | | |
| | | function handleExport() { |
| | | const rows = getSelectRows?.() || []; |
| | | if (!rows.length) { |
| | | createMessage.warning('请先勾选要导出的考试记录'); |
| | | return; |
| | | } |
| | | createMessage.success(`已选择 ${rows.length} 条(导出接口联调后生效)`); |
| | | } |
| | | |
| | | function getTableActions(record: ExamScoreSummaryItem): ActionItem[] { |
| | | return [{ label: '考试详情', onClick: handleDetail.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <a-space> |
| | | <a-button type="primary" @click="handleExport">导出考试</a-button> |
| | | </a-space> |
| | | </template> |
| | | <template #paperName="{ record }"> |
| | | <span :class="record.paperInvalid ? 'text-gray-400' : ''">{{ record.paperName }}</span> |
| | | </template> |
| | | <template #failCount="{ record }"> |
| | | <span :class="record.failCount > 0 ? 'text-red-500' : ''">{{ record.failCount }}</span> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | import type { |
| | | ExamPaperView, |
| | | ExamScoreDetail, |
| | | ExamScoreDetailRow, |
| | | ExamScorePageQuery, |
| | | ExamScoreSummaryItem, |
| | | } from './types'; |
| | | |
| | | function delay<T>(data: T, ms = 220): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | const store: ExamScoreSummaryItem[] = [ |
| | | { |
| | | id: 'es1', |
| | | taskSubject: 'BQA0002 记录要求,Rev 02', |
| | | taskNo: 'TT2020060534', |
| | | paperId: 'p1', |
| | | paperName: '(已失效)BQA0002 记录要求,Rev 02', |
| | | paperInvalid: true, |
| | | participantCount: 1, |
| | | failCount: 0, |
| | | maxScore: 100, |
| | | minScore: 100, |
| | | avgScore: 100, |
| | | passScore: 80, |
| | | totalScore: 100, |
| | | }, |
| | | { |
| | | id: 'es2', |
| | | taskSubject: 'BAL0003 留样管理程序,Rev 03', |
| | | taskNo: 'TT2020060535', |
| | | paperId: 'p2', |
| | | paperName: '(已失效)BAL0003 留样管理程序,Rev 03', |
| | | paperInvalid: true, |
| | | participantCount: 1, |
| | | failCount: 1, |
| | | maxScore: 70, |
| | | minScore: 70, |
| | | avgScore: 70, |
| | | passScore: 80, |
| | | totalScore: 100, |
| | | }, |
| | | { |
| | | id: 'es3', |
| | | taskSubject: 'SOP-QA-012 偏差管理;SOP-QA-015 变更控制', |
| | | taskNo: 'TT2020070102', |
| | | paperId: 'p3', |
| | | paperName: '(已失效)偏差与变更综合考核', |
| | | paperInvalid: true, |
| | | participantCount: 12, |
| | | failCount: 2, |
| | | maxScore: 98, |
| | | minScore: 55, |
| | | avgScore: 82.5, |
| | | passScore: 80, |
| | | totalScore: 100, |
| | | }, |
| | | { |
| | | id: 'es4', |
| | | taskSubject: '2026-药物警戒年度培训', |
| | | taskNo: 'TT2026090101', |
| | | paperId: 'p4', |
| | | paperName: '2026-药物警戒年度培训试题', |
| | | paperInvalid: false, |
| | | participantCount: 28, |
| | | failCount: 3, |
| | | maxScore: 100, |
| | | minScore: 48, |
| | | avgScore: 86.2, |
| | | passScore: 60, |
| | | totalScore: 100, |
| | | }, |
| | | { |
| | | id: 'es5', |
| | | taskSubject: 'GMP基础知识培训及案例分析', |
| | | taskNo: 'TT2026060155', |
| | | paperId: 'p5', |
| | | paperName: 'GMP基础知识培训(QC、分析)', |
| | | paperInvalid: false, |
| | | participantCount: 15, |
| | | failCount: 1, |
| | | maxScore: 100, |
| | | minScore: 72, |
| | | avgScore: 91, |
| | | passScore: 100, |
| | | totalScore: 100, |
| | | }, |
| | | { |
| | | id: 'es6', |
| | | taskSubject: '再确认测试', |
| | | taskNo: 'TT2025120575', |
| | | paperId: 'p6', |
| | | paperName: '再确认测试', |
| | | paperInvalid: false, |
| | | participantCount: 2, |
| | | failCount: 0, |
| | | maxScore: 100, |
| | | minScore: 80, |
| | | avgScore: 90, |
| | | passScore: 100, |
| | | totalScore: 100, |
| | | }, |
| | | ]; |
| | | |
| | | function makeRow( |
| | | partial: Omit<ExamScoreDetailRow, 'taskSubject' | 'taskNo' | 'passScore'> & { summaryId: string }, |
| | | ): ExamScoreDetailRow { |
| | | const summary = store.find((x) => x.id === partial.summaryId)!; |
| | | return { |
| | | ...partial, |
| | | taskSubject: summary.taskSubject, |
| | | taskNo: summary.taskNo, |
| | | passScore: summary.passScore, |
| | | }; |
| | | } |
| | | |
| | | const detailRows: Record<string, ExamScoreDetailRow[]> = { |
| | | es1: [ |
| | | makeRow({ |
| | | id: 'r1', |
| | | summaryId: 'es1', |
| | | userId: 'chensj', |
| | | userName: '陈思佳', |
| | | deptName: '质量保证部', |
| | | examStart: '2020-07-15 09:00:00', |
| | | examEnd: '2020-07-15 09:35:00', |
| | | score: 100, |
| | | passFlag: '1', |
| | | sourceIp: '192.168.1.56', |
| | | }), |
| | | ], |
| | | es2: [ |
| | | makeRow({ |
| | | id: 'r2', |
| | | summaryId: 'es2', |
| | | userId: 'lisi', |
| | | userName: '李四', |
| | | deptName: '分析实验室', |
| | | examStart: '2020-06-12 09:30:00', |
| | | examEnd: '2020-06-12 10:05:00', |
| | | score: 70, |
| | | passFlag: '0', |
| | | sourceIp: '192.168.1.88', |
| | | }), |
| | | ], |
| | | es4: [ |
| | | makeRow({ |
| | | id: 'r3', |
| | | summaryId: 'es4', |
| | | userId: 'zhouba', |
| | | userName: '周八', |
| | | deptName: '药物警戒', |
| | | examStart: '2026-09-10 16:00:00', |
| | | examEnd: '2026-09-10 16:40:00', |
| | | score: 70, |
| | | passFlag: '1', |
| | | sourceIp: '10.0.12.21', |
| | | }), |
| | | makeRow({ |
| | | id: 'r4', |
| | | summaryId: 'es4', |
| | | userId: 'wujiu', |
| | | userName: '吴九', |
| | | deptName: '药物警戒', |
| | | examStart: '2026-09-12 08:40:00', |
| | | examEnd: '2026-09-12 09:15:00', |
| | | score: 48, |
| | | passFlag: '0', |
| | | sourceIp: '10.0.12.33', |
| | | }), |
| | | makeRow({ |
| | | id: 'r5', |
| | | summaryId: 'es4', |
| | | userId: 'zhengshi', |
| | | userName: '郑十', |
| | | deptName: '药物警戒', |
| | | examStart: '2026-09-11 13:20:00', |
| | | examEnd: '2026-09-11 14:00:00', |
| | | score: 100, |
| | | passFlag: '1', |
| | | sourceIp: '10.0.12.45', |
| | | }), |
| | | ], |
| | | es6: [ |
| | | makeRow({ |
| | | id: 'r6', |
| | | summaryId: 'es6', |
| | | userId: 'zhangsan', |
| | | userName: '张三', |
| | | deptName: '质量管理部', |
| | | examStart: '2025-12-22 09:00:00', |
| | | examEnd: '2025-12-22 09:25:00', |
| | | score: 80, |
| | | passFlag: '0', |
| | | sourceIp: '192.168.110.20', |
| | | }), |
| | | makeRow({ |
| | | id: 'r7', |
| | | summaryId: 'es6', |
| | | userId: 'lisi', |
| | | userName: '李四', |
| | | deptName: '生产部', |
| | | examStart: '2025-12-22 09:00:00', |
| | | examEnd: '2025-12-22 09:28:00', |
| | | score: 100, |
| | | passFlag: '1', |
| | | sourceIp: '192.168.110.21', |
| | | }), |
| | | ], |
| | | }; |
| | | |
| | | function findRow(examId: string): ExamScoreDetailRow | undefined { |
| | | for (const list of Object.values(detailRows)) { |
| | | const hit = list.find((x) => x.id === examId); |
| | | if (hit) return hit; |
| | | } |
| | | // 动态兜底行(如 es3_demo) |
| | | const m = /^(.+)_demo$/.exec(examId); |
| | | if (m) { |
| | | const summary = store.find((x) => x.id === m[1]); |
| | | if (summary) { |
| | | return { |
| | | id: examId, |
| | | summaryId: summary.id, |
| | | taskSubject: summary.taskSubject, |
| | | taskNo: summary.taskNo, |
| | | userId: 'demo', |
| | | userName: '示例考生', |
| | | deptName: '示例部门', |
| | | examStart: '2026-01-01 09:00:00', |
| | | examEnd: '2026-01-01 10:00:00', |
| | | score: summary.avgScore, |
| | | passScore: summary.passScore, |
| | | passFlag: summary.avgScore >= summary.passScore ? '1' : '0', |
| | | sourceIp: '127.0.0.1', |
| | | }; |
| | | } |
| | | } |
| | | return undefined; |
| | | } |
| | | |
| | | export function mockQueryExamScores(params: ExamScorePageQuery) { |
| | | let list = [...store]; |
| | | if (params.taskNo && params.taskNo !== 'null') { |
| | | const kw = params.taskNo.trim().toLowerCase(); |
| | | list = list.filter((x) => x.taskNo.toLowerCase().includes(kw)); |
| | | } |
| | | if (params.keyword && params.keyword !== 'null') { |
| | | const kw = params.keyword.trim().toLowerCase(); |
| | | list = list.filter( |
| | | (x) => |
| | | x.taskSubject.toLowerCase().includes(kw) || |
| | | x.paperName.toLowerCase().includes(kw) || |
| | | x.taskNo.toLowerCase().includes(kw), |
| | | ); |
| | | } |
| | | const currentPage = Number(params.currentPage || 1); |
| | | const pageSize = Number(params.pageSize || 20); |
| | | const start = (currentPage - 1) * pageSize; |
| | | return delay({ |
| | | list: list.slice(start, start + pageSize), |
| | | pagination: { total: list.length, currentPage, pageSize }, |
| | | }); |
| | | } |
| | | |
| | | export function mockGetExamScoreDetail(id: string): Promise<ExamScoreDetail> { |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) return Promise.reject(new Error('成绩记录不存在')); |
| | | const rows = detailRows[id] || [ |
| | | makeRow({ |
| | | id: `${id}_demo`, |
| | | summaryId: id, |
| | | userId: 'demo', |
| | | userName: '示例考生', |
| | | deptName: '示例部门', |
| | | examStart: '2026-01-01 09:00:00', |
| | | examEnd: '2026-01-01 10:00:00', |
| | | score: row.avgScore, |
| | | passFlag: row.avgScore >= row.passScore ? '1' : '0', |
| | | sourceIp: '127.0.0.1', |
| | | }), |
| | | ]; |
| | | return delay({ ...row, rows }); |
| | | } |
| | | |
| | | function buildPaperItems(examId: string, score: number): ExamPaperView['items'] { |
| | | return [ |
| | | { |
| | | id: `${examId}_q1`, |
| | | questionType: 'single', |
| | | stem: 'GMP 的全称是?', |
| | | options: [ |
| | | { optionLabel: 'A', optionContent: '药品生产质量管理规范' }, |
| | | { optionLabel: 'B', optionContent: '药品经营质量管理规范' }, |
| | | { optionLabel: 'C', optionContent: '实验室管理规范' }, |
| | | ], |
| | | correctAnswer: 'A', |
| | | userAnswer: score >= 60 ? 'A' : 'B', |
| | | score: 30, |
| | | gotScore: score >= 60 ? 30 : 0, |
| | | isCorrect: score >= 60, |
| | | isSubjective: '0', |
| | | }, |
| | | { |
| | | id: `${examId}_q2`, |
| | | questionType: 'multi', |
| | | stem: '下列哪些属于特种设备?', |
| | | options: [ |
| | | { optionLabel: 'A', optionContent: '电梯' }, |
| | | { optionLabel: 'B', optionContent: '压力容器' }, |
| | | { optionLabel: 'C', optionContent: '普通办公桌' }, |
| | | { optionLabel: 'D', optionContent: '锅炉' }, |
| | | ], |
| | | correctAnswer: 'A,B,D', |
| | | userAnswer: score >= 80 ? 'A,B,D' : 'A,B', |
| | | score: 30, |
| | | gotScore: score >= 80 ? 30 : 15, |
| | | isCorrect: score >= 80, |
| | | isSubjective: '0', |
| | | }, |
| | | { |
| | | id: `${examId}_q3`, |
| | | questionType: 'judge', |
| | | stem: '特种作业人员必须取得有效资格证书后方可上岗作业。', |
| | | correctAnswer: '正确', |
| | | userAnswer: '正确', |
| | | score: 20, |
| | | gotScore: 20, |
| | | isCorrect: true, |
| | | isSubjective: '0', |
| | | }, |
| | | { |
| | | id: `${examId}_q4`, |
| | | questionType: 'essay', |
| | | stem: '请简述本次培训的核心要点。', |
| | | userAnswer: '按 SOP 执行,做好记录,异常及时上报。', |
| | | correctAnswer: '参考:覆盖制度要点、操作规范与记录要求。', |
| | | score: 20, |
| | | gotScore: Math.max(0, Math.min(20, score - 80)), |
| | | isSubjective: '1', |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | export function mockGetExamPaperView(examId: string): Promise<ExamPaperView> { |
| | | let exam = findRow(examId); |
| | | // 再兜底:任意 id 也能打开模拟答卷,避免“没有模拟试卷” |
| | | if (!exam) { |
| | | const summary = store[0]; |
| | | exam = { |
| | | id: examId, |
| | | summaryId: summary.id, |
| | | taskSubject: summary.taskSubject, |
| | | taskNo: summary.taskNo, |
| | | userId: 'mock_user', |
| | | userName: '模拟考生', |
| | | deptName: '模拟部门', |
| | | examStart: '2026-09-01 09:00:00', |
| | | examEnd: '2026-09-01 09:45:00', |
| | | score: 85, |
| | | passScore: summary.passScore, |
| | | passFlag: '1', |
| | | sourceIp: '192.168.0.1', |
| | | }; |
| | | } |
| | | const summary = store.find((x) => x.id === exam!.summaryId) || store[0]; |
| | | return delay({ |
| | | examId: exam.id, |
| | | summaryId: exam.summaryId, |
| | | taskSubject: exam.taskSubject, |
| | | taskNo: exam.taskNo, |
| | | paperName: summary.paperName || exam.taskSubject, |
| | | userId: exam.userId, |
| | | userName: exam.userName, |
| | | deptName: exam.deptName, |
| | | examStart: exam.examStart, |
| | | examEnd: exam.examEnd, |
| | | score: exam.score, |
| | | passScore: exam.passScore, |
| | | totalScore: summary.totalScore || 100, |
| | | passFlag: exam.passFlag, |
| | | sourceIp: exam.sourceIp, |
| | | items: buildPaperItems(exam.id, exam.score), |
| | | }); |
| | | } |
| New file |
| | |
| | | /** 考试成绩汇总行 */ |
| | | export interface ExamScoreSummaryItem { |
| | | id: string; |
| | | taskSubject: string; |
| | | taskNo: string; |
| | | paperId: string; |
| | | paperName: string; |
| | | /** 试卷是否已失效 */ |
| | | paperInvalid?: boolean; |
| | | participantCount: number; |
| | | failCount: number; |
| | | maxScore: number; |
| | | minScore: number; |
| | | avgScore: number; |
| | | passScore: number; |
| | | totalScore: number; |
| | | } |
| | | |
| | | export interface ExamScorePageQuery { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | keyword?: string; |
| | | taskNo?: string; |
| | | } |
| | | |
| | | /** 考生试卷列表行 */ |
| | | export interface ExamScoreDetailRow { |
| | | id: string; |
| | | summaryId: string; |
| | | taskSubject: string; |
| | | taskNo: string; |
| | | /** 用户ID / 登录名 */ |
| | | userId: string; |
| | | /** 显示名 */ |
| | | userName: string; |
| | | deptName?: string; |
| | | examStart?: string; |
| | | examEnd?: string; |
| | | score: number; |
| | | passScore: number; |
| | | passFlag: '0' | '1'; |
| | | sourceIp?: string; |
| | | } |
| | | |
| | | export interface ExamScoreDetail { |
| | | id: string; |
| | | taskSubject: string; |
| | | taskNo: string; |
| | | paperName: string; |
| | | paperInvalid?: boolean; |
| | | participantCount: number; |
| | | failCount: number; |
| | | maxScore: number; |
| | | minScore: number; |
| | | avgScore: number; |
| | | passScore: number; |
| | | totalScore: number; |
| | | rows: ExamScoreDetailRow[]; |
| | | } |
| | | |
| | | /** 考生答卷详情(查看试卷) */ |
| | | export interface ExamPaperView { |
| | | examId: string; |
| | | summaryId: string; |
| | | taskSubject: string; |
| | | taskNo: string; |
| | | paperName: string; |
| | | userId: string; |
| | | userName: string; |
| | | deptName?: string; |
| | | examStart?: string; |
| | | examEnd?: string; |
| | | score: number; |
| | | passScore: number; |
| | | totalScore: number; |
| | | passFlag: '0' | '1'; |
| | | sourceIp?: string; |
| | | items: ExamPaperViewItem[]; |
| | | } |
| | | |
| | | export interface ExamPaperViewItem { |
| | | id: string; |
| | | questionType: 'single' | 'multi' | 'judge' | 'blank' | 'essay'; |
| | | stem: string; |
| | | options?: { optionLabel: string; optionContent: string }[]; |
| | | correctAnswer?: string; |
| | | userAnswer?: string; |
| | | score: number; |
| | | gotScore: number; |
| | | isCorrect?: boolean; |
| | | isSubjective?: '0' | '1'; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { OnlineExamDetail } from './types'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { |
| | | Descriptions as ADescriptions, |
| | | DescriptionsItem as ADescriptionsItem, |
| | | Spin, |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { getMyPaperDetail, startOnlineExam } from '#/api/x/tms/onlineExam'; |
| | | |
| | | import { colorOfMyPaperStatus, labelOfMyPaperStatus } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsOnlineExamDetail' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const loading = ref(false); |
| | | const starting = ref(false); |
| | | const detail = ref<OnlineExamDetail | null>(null); |
| | | |
| | | onMounted(() => { |
| | | loadDetail(); |
| | | }); |
| | | |
| | | async function loadDetail() { |
| | | const id = String(route.params.id || ''); |
| | | if (!id) { |
| | | router.replace('/tms/onlineExam'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | detail.value = await getMyPaperDetail(id); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载详情失败'); |
| | | router.replace('/tms/onlineExam'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function goBack() { |
| | | router.push('/tms/onlineExam'); |
| | | } |
| | | |
| | | async function handleStart() { |
| | | if (!detail.value || starting.value) return; |
| | | starting.value = true; |
| | | try { |
| | | const paper = await startOnlineExam(detail.value.id); |
| | | 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 || '开始考试失败'); |
| | | } finally { |
| | | starting.value = false; |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <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> |
| | | <a-button |
| | | v-if="detail && detail.status !== 'submitted'" |
| | | type="primary" |
| | | :loading="starting" |
| | | @click="handleStart" |
| | | > |
| | | {{ detail?.status === 'doing' ? '继续考试' : '开始考试' }} |
| | | </a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | | <ADescriptions v-if="detail" bordered :column="2" size="middle"> |
| | | <ADescriptionsItem label="试卷名称" :span="2">{{ detail.paperName }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="状态"> |
| | | <span :style="{ color: colorOfMyPaperStatus(detail.status) }"> |
| | | {{ labelOfMyPaperStatus(detail.status) }} |
| | | </span> |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem label="卷面总分">{{ detail.totalScore }}</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> |
| | | </Spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-exam-detail-page { |
| | | background: #fff; |
| | | padding: 20px 24px; |
| | | height: 100%; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .tms-exam-detail-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 20px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | </style> |
| New file |
| | |
| | | import type { MyPaperStatus } from './types'; |
| | | |
| | | 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' }, |
| | | ]; |
| | | |
| | | export function labelOfMyPaperStatus(v?: string) { |
| | | return MY_PAPER_STATUS_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-'; |
| | | } |
| | | |
| | | export function colorOfMyPaperStatus(v?: string) { |
| | | return MY_PAPER_STATUS_OPTIONS.find((x) => x.id === v)?.color; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { OnlineExamPaper, OnlineExamQuestionItem } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { Modal } from 'ant-design-vue'; |
| | | |
| | | import { submitOnlineExam } from '#/api/x/tms/onlineExam'; |
| | | import { labelOfType } from '#/views/x/tms/question/constants'; |
| | | |
| | | defineOptions({ name: 'TmsOnlineExamTake' }); |
| | | |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const paper = ref<OnlineExamPaper | null>(null); |
| | | const currentIndex = ref(0); |
| | | const answers = reactive<Record<string, string | string[]>>({}); |
| | | const submitted = ref(false); |
| | | const scoreText = ref(''); |
| | | const submitting = ref(false); |
| | | |
| | | const current = computed(() => paper.value?.questions?.[currentIndex.value]); |
| | | const total = computed(() => paper.value?.questions?.length || 0); |
| | | |
| | | onMounted(() => { |
| | | const raw = sessionStorage.getItem('tms_online_exam_paper'); |
| | | if (!raw) { |
| | | createMessage.warning('请先从试卷列表选择考试'); |
| | | router.replace('/tms/onlineExam'); |
| | | return; |
| | | } |
| | | try { |
| | | paper.value = JSON.parse(raw); |
| | | } catch { |
| | | router.replace('/tms/onlineExam'); |
| | | } |
| | | }); |
| | | |
| | | 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(','); |
| | | } |
| | | 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; |
| | | } |
| | | |
| | | function handleSubmit() { |
| | | if (!paper.value || submitted.value) return; |
| | | Modal.confirm({ |
| | | title: '确认交卷', |
| | | content: '交卷后不可再修改答案,确定交卷吗?', |
| | | onOk: doSubmit, |
| | | }); |
| | | } |
| | | |
| | | async function doSubmit() { |
| | | if (!paper.value) return; |
| | | submitting.value = true; |
| | | try { |
| | | const got = calcScore(); |
| | | await submitOnlineExam(paper.value.examId, { score: got, answers: { ...answers } }); |
| | | submitted.value = true; |
| | | scoreText.value = `${got} / ${paper.value.totalScore}`; |
| | | createMessage.success(`交卷成功,得分 ${got} 分(满分 ${paper.value.totalScore})`); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '交卷失败'); |
| | | } finally { |
| | | submitting.value = false; |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <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="mt-1 text-gray-400 text-sm"> |
| | | 第 {{ currentIndex + 1 }} / {{ total }} 题 |
| | | <span v-if="paper.durationMin" class="ml-3">时长 {{ paper.durationMin }} 分钟</span> |
| | | <span v-if="submitted" class="ml-3 text-primary">得分:{{ scoreText }}</span> |
| | | </div> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="goBack">返回列表</a-button> |
| | | <a-button type="primary" :loading="submitting" :disabled="submitted" @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) }} |
| | | <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'" |
| | | v-model:value="answers[current.id]" |
| | | class="!flex !flex-col gap-3" |
| | | :disabled="submitted" |
| | | > |
| | | <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'" |
| | | v-model:value="answers[current.id]" |
| | | class="!flex !flex-col gap-3" |
| | | :disabled="submitted" |
| | | > |
| | | <a-checkbox v-for="opt in current.options" :key="opt.optionLabel" :value="opt.optionLabel"> |
| | | {{ opt.optionLabel }}. {{ opt.optionContent }} |
| | | </a-checkbox> |
| | | </a-checkbox-group> |
| | | |
| | | <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('、') }} |
| | | </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> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-online-exam-page { |
| | | background: #fff; |
| | | padding: 20px 24px; |
| | | height: 100%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-exam-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | padding-bottom: 12px; |
| | | margin-bottom: 16px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .tms-exam-body { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .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> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { MyPaperListItem } 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 { getMyPaperList, startOnlineExam } from '#/api/x/tms/onlineExam'; |
| | | |
| | | import { MY_PAPER_STATUS_OPTIONS, colorOfMyPaperStatus, labelOfMyPaperStatus } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsOnlineExam' }); |
| | | |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | const starting = ref(false); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '试卷名称', dataIndex: 'paperName', minWidth: 260 }, |
| | | { |
| | | title: '状态', |
| | | dataIndex: 'status', |
| | | width: 100, |
| | | align: 'center', |
| | | slots: { default: 'status' }, |
| | | }, |
| | | { |
| | | title: '考试时间', |
| | | dataIndex: 'examTimeText', |
| | | minWidth: 280, |
| | | customRender: ({ record }) => (record as MyPaperListItem).examTimeText || '-', |
| | | }, |
| | | { |
| | | title: '卷面总分', |
| | | dataIndex: 'totalScore', |
| | | width: 100, |
| | | align: 'center', |
| | | }, |
| | | ]; |
| | | |
| | | const [registerTable] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | schemas: [ |
| | | { |
| | | field: 'keyword', |
| | | label: '试卷名称', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入试卷名称', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'status', |
| | | label: '状态', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择状态', |
| | | options: MY_PAPER_STATUS_OPTIONS, |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 160, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getMyPaperList(params) }; |
| | | } |
| | | |
| | | function handleDetail(record: MyPaperListItem) { |
| | | router.push(`/tms/onlineExam/detail/${record.id}`); |
| | | } |
| | | |
| | | async function handleStart(record: MyPaperListItem) { |
| | | if (starting.value) return; |
| | | starting.value = true; |
| | | try { |
| | | const paper = await startOnlineExam(record.id); |
| | | 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 || '开始考试失败'); |
| | | } finally { |
| | | starting.value = false; |
| | | } |
| | | } |
| | | |
| | | function getTableActions(record: MyPaperListItem): ActionItem[] { |
| | | const actions: ActionItem[] = []; |
| | | if (record.status === 'notStarted') { |
| | | actions.push({ label: '开始考试', onClick: handleStart.bind(null, record) }); |
| | | } else if (record.status === 'doing') { |
| | | actions.push({ label: '继续考试', onClick: handleStart.bind(null, record) }); |
| | | } |
| | | actions.push({ label: '查看详情', onClick: handleDetail.bind(null, record) }); |
| | | return actions; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <div> |
| | | <div class="text-base font-medium">我的试卷</div> |
| | | <div class="mt-1 text-gray-400 text-sm">我的试卷,选择试卷参加考试,或者查看考试详情。</div> |
| | | </div> |
| | | </template> |
| | | <template #status="{ record }"> |
| | | <span :style="{ color: colorOfMyPaperStatus(record.status) }"> |
| | | {{ labelOfMyPaperStatus(record.status) }} |
| | | </span> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | import type { |
| | | MyPaperListItem, |
| | | MyPaperPageQuery, |
| | | OnlineExamDetail, |
| | | OnlineExamPaper, |
| | | 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)); |
| | | } |
| | | |
| | | const store: MyPaperListItem[] = [ |
| | | { |
| | | id: 'mp1', |
| | | paperId: 'paper1', |
| | | paperName: '2026-药物警戒年度培训考核卷', |
| | | status: 'notStarted', |
| | | examStart: '2026-09-01 09:00:00', |
| | | examEnd: '2026-12-31 18:00:00', |
| | | examTimeText: '2026-09-01 09:00 ~ 2026-12-31 18:00', |
| | | totalScore: 100, |
| | | passScore: 60, |
| | | durationMin: 60, |
| | | }, |
| | | { |
| | | id: 'mp2', |
| | | paperId: 'paper2', |
| | | paperName: 'GMP基础培训考试', |
| | | status: 'doing', |
| | | examStart: '2026-09-10 08:00:00', |
| | | examEnd: '2026-09-30 23:59:00', |
| | | examTimeText: '2026-09-10 08:00 ~ 2026-09-30 23:59', |
| | | totalScore: 100, |
| | | passScore: 70, |
| | | durationMin: 90, |
| | | examId: 'exam_doing_1', |
| | | }, |
| | | { |
| | | id: 'mp3', |
| | | paperId: 'paper3', |
| | | paperName: '安全生产知识考试(已交卷示例)', |
| | | status: 'submitted', |
| | | examStart: '2026-08-01 09:00:00', |
| | | examEnd: '2026-08-31 18:00:00', |
| | | examTimeText: '2026-08-01 09:00 ~ 2026-08-31 18:00', |
| | | totalScore: 100, |
| | | passScore: 60, |
| | | durationMin: 45, |
| | | gotScore: 85, |
| | | examId: 'exam_done_1', |
| | | }, |
| | | ]; |
| | | |
| | | export function mockQueryMyPapers(params: MyPaperPageQuery) { |
| | | let list = [...store]; |
| | | if (params.status) list = list.filter((x) => x.status === params.status); |
| | | if (params.keyword && params.keyword !== 'null') { |
| | | const kw = params.keyword.trim().toLowerCase(); |
| | | list = list.filter((x) => (x.paperName || '').toLowerCase().includes(kw)); |
| | | } |
| | | const currentPage = Number(params.currentPage || 1); |
| | | const pageSize = Number(params.pageSize || 20); |
| | | const start = (currentPage - 1) * pageSize; |
| | | return delay({ |
| | | list: list.slice(start, start + pageSize), |
| | | pagination: { total: list.length, currentPage, pageSize }, |
| | | }); |
| | | } |
| | | |
| | | export function mockGetMyPaperDetail(id: string): Promise<OnlineExamDetail> { |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) return Promise.reject(new Error('试卷不存在')); |
| | | return delay({ |
| | | id: row.id, |
| | | paperId: row.paperId, |
| | | paperName: row.paperName, |
| | | status: row.status, |
| | | examTimeText: row.examTimeText, |
| | | totalScore: row.totalScore, |
| | | passScore: row.passScore, |
| | | durationMin: row.durationMin, |
| | | gotScore: row.gotScore, |
| | | startTime: row.status !== 'notStarted' ? '2026-09-15 10:00:00' : undefined, |
| | | submitTime: row.status === 'submitted' ? '2026-09-15 10:35:00' : undefined, |
| | | passFlag: row.status === 'submitted' ? ((row.gotScore || 0) >= (row.passScore || 0) ? '1' : '0') : undefined, |
| | | }); |
| | | } |
| | | |
| | | 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; |
| | | |
| | | return delay({ |
| | | examId, |
| | | paperId: row.paperId, |
| | | paperName: row.paperName, |
| | | totalScore: row.totalScore, |
| | | passScore: row.passScore, |
| | | durationMin: row.durationMin, |
| | | questions, |
| | | }); |
| | | } |
| | | |
| | | export async function mockSubmitExam(examId: string, score: number): Promise<{ msg: string }> { |
| | | const row = store.find((x) => x.examId === examId); |
| | | if (row) { |
| | | row.status = 'submitted'; |
| | | row.gotScore = score; |
| | | } |
| | | return delay({ msg: '交卷成功' }); |
| | | } |
| New file |
| | |
| | | /** 我的试卷 / 在线考试状态(列表展示) */ |
| | | export type MyPaperStatus = 'notStarted' | 'doing' | 'submitted'; |
| | | |
| | | /** 列表行:我的试卷 */ |
| | | export interface MyPaperListItem { |
| | | id: string; |
| | | paperId: string; |
| | | paperName: string; |
| | | /** 考生侧状态 */ |
| | | status: MyPaperStatus; |
| | | examStart?: string; |
| | | examEnd?: string; |
| | | /** 展示用考试时间文案 */ |
| | | examTimeText?: string; |
| | | totalScore: number; |
| | | passScore?: number; |
| | | durationMin?: number; |
| | | /** 已交卷时的得分 */ |
| | | gotScore?: number; |
| | | examId?: string; |
| | | } |
| | | |
| | | export interface MyPaperPageQuery { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | keyword?: string; |
| | | status?: MyPaperStatus | ''; |
| | | } |
| | | |
| | | /** 开考后试卷内容 */ |
| | | export interface OnlineExamPaper { |
| | | examId: string; |
| | | paperId: string; |
| | | paperName: string; |
| | | totalScore: number; |
| | | passScore?: number; |
| | | durationMin?: number; |
| | | questions: OnlineExamQuestionItem[]; |
| | | } |
| | | |
| | | export interface OnlineExamQuestionItem { |
| | | id: string; |
| | | questionNo?: string; |
| | | questionType: 'single' | 'multi' | 'judge'; |
| | | stem: string; |
| | | score: number; |
| | | options: { optionLabel: string; optionContent: string; isCorrect: '0' | '1' }[]; |
| | | } |
| | | |
| | | export interface OnlineExamDetail { |
| | | id: string; |
| | | paperId: string; |
| | | paperName: string; |
| | | status: MyPaperStatus; |
| | | examTimeText?: string; |
| | | totalScore: number; |
| | | passScore?: number; |
| | | durationMin?: number; |
| | | gotScore?: number; |
| | | startTime?: string; |
| | | submitTime?: string; |
| | | passFlag?: '0' | '1'; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { QuestionBankOption, QuestionFormModel } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref, watch } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | |
| | | import { createQuestion, getQuestionBanks, getQuestionInfo, updateQuestion } from '#/api/x/tms/question'; |
| | | |
| | | import AnswerSettings from './components/AnswerSettings.vue'; |
| | | import { |
| | | DIFFICULTY_OPTIONS, |
| | | QUESTION_TYPE_OPTIONS, |
| | | SOURCE_OPTIONS, |
| | | STATUS_OPTIONS, |
| | | } from './constants'; |
| | | import { |
| | | createDefaultOptions, |
| | | hydrateFormFromEntity, |
| | | parseFormToEntity, |
| | | resetAnswerForType, |
| | | validateAnswerByType, |
| | | } from './parseAnswer'; |
| | | |
| | | defineOptions({ name: 'TmsQuestionForm' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const banks = ref<QuestionBankOption[]>([]); |
| | | const loading = ref(false); |
| | | const submitting = ref(false); |
| | | const formRef = ref(); |
| | | |
| | | const isEdit = computed(() => !!route.params.id && route.params.id !== 'create'); |
| | | |
| | | const dataForm = reactive<QuestionFormModel>({ |
| | | questionType: 'single', |
| | | bankId: undefined, |
| | | difficulty: 'normal', |
| | | sourceType: 'self', |
| | | bizStatus: 'closed', |
| | | stem: '', |
| | | analysis: '', |
| | | options: createDefaultOptions('single'), |
| | | judgeAnswer: true, |
| | | essayAnswer: '', |
| | | 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 rules = { |
| | | questionType: [{ required: true, message: '请选择题型', trigger: 'change' }], |
| | | bankId: [{ required: true, message: '请选择所属题库', trigger: 'change' }], |
| | | stem: [{ required: true, message: '请填写题干内容', trigger: 'blur' }], |
| | | }; |
| | | |
| | | watch( |
| | | () => dataForm.questionType, |
| | | (type, prev) => { |
| | | if (!prev || type === prev) return; |
| | | Object.assign(dataForm, resetAnswerForType(type)); |
| | | }, |
| | | ); |
| | | |
| | | onMounted(async () => { |
| | | banks.value = (await getQuestionBanks()) || []; |
| | | if (isEdit.value) { |
| | | await loadDetail(String(route.params.id)); |
| | | } |
| | | }); |
| | | |
| | | async function loadDetail(id: string) { |
| | | loading.value = true; |
| | | try { |
| | | const info = await getQuestionInfo(id); |
| | | Object.assign(dataForm, hydrateFormFromEntity(info)); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载失败'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function goList() { |
| | | router.push('/tms/question'); |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | try { |
| | | await formRef.value?.validate(); |
| | | } catch { |
| | | return; |
| | | } |
| | | const err = validateAnswerByType(dataForm); |
| | | if (err) { |
| | | createMessage.warning(err); |
| | | return; |
| | | } |
| | | submitting.value = true; |
| | | try { |
| | | const payload = parseFormToEntity(dataForm); |
| | | if (isEdit.value) { |
| | | await updateQuestion(payload as any); |
| | | createMessage.success('更新成功'); |
| | | } else { |
| | | await createQuestion(payload as any); |
| | | createMessage.success('创建成功'); |
| | | } |
| | | goList(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '保存失败'); |
| | | } finally { |
| | | submitting.value = false; |
| | | } |
| | | } |
| | | |
| | | function handleReset() { |
| | | if (isEdit.value && dataForm.id) { |
| | | loadDetail(dataForm.id); |
| | | return; |
| | | } |
| | | Object.assign(dataForm, { |
| | | questionType: 'single', |
| | | bankId: undefined, |
| | | difficulty: 'normal', |
| | | sourceType: 'self', |
| | | bizStatus: 'closed', |
| | | stem: '', |
| | | analysis: '', |
| | | ...resetAnswerForType('single'), |
| | | }); |
| | | formRef.value?.clearValidate?.(); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-question-form-page"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content tms-question-form-wrap"> |
| | | <div class="tms-question-form-header"> |
| | | <div> |
| | | <div class="text-base font-medium">{{ isEdit ? '编辑试题' : '创建试题' }}</div> |
| | | <div class="mt-1 text-gray-400 text-sm">填写下列基本信息,{{ isEdit ? '保存试题' : '创建试题' }}。</div> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="goList">管理试题</a-button> |
| | | <a-button type="primary" :loading="submitting" @click="handleSubmit"> |
| | | {{ isEdit ? '保存试题' : '创建试题' }} |
| | | </a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | | <div class="tms-question-form-body"> |
| | | <a-spin :spinning="loading"> |
| | | <a-form |
| | | ref="formRef" |
| | | :model="dataForm" |
| | | :rules="rules" |
| | | :label-col="{ style: { width: '100px' } }" |
| | | class="max-w-[1100px] pb-8" |
| | | > |
| | | <a-row :gutter="16"> |
| | | <a-col :span="12"> |
| | | <a-form-item label="试题类型" name="questionType"> |
| | | <jnpf-select |
| | | v-model:value="dataForm.questionType" |
| | | :options="QUESTION_TYPE_OPTIONS" |
| | | :allow-clear="false" |
| | | placeholder="请选择题型" |
| | | /> |
| | | </a-form-item> |
| | | </a-col> |
| | | <a-col :span="12"> |
| | | <a-form-item label="所属题库" name="bankId"> |
| | | <jnpf-select |
| | | v-model:value="dataForm.bankId" |
| | | :options="banks" |
| | | show-search |
| | | placeholder="请选择所属题库" |
| | | /> |
| | | </a-form-item> |
| | | </a-col> |
| | | <a-col :span="12"> |
| | | <a-form-item label="试题难度" name="difficulty"> |
| | | <jnpf-select |
| | | v-model:value="dataForm.difficulty" |
| | | :options="DIFFICULTY_OPTIONS" |
| | | :allow-clear="false" |
| | | /> |
| | | </a-form-item> |
| | | </a-col> |
| | | <a-col :span="12"> |
| | | <a-form-item label="试题状态" name="bizStatus"> |
| | | <div class="flex items-center gap-3"> |
| | | <jnpf-select |
| | | v-model:value="dataForm.bizStatus" |
| | | :options="STATUS_OPTIONS" |
| | | :allow-clear="false" |
| | | class="!w-[200px]" |
| | | /> |
| | | <span |
| | | v-if="statusTip" |
| | | class="text-sm" |
| | | :class="statusTipColor === 'red' ? 'text-red-500' : 'text-green-600'" |
| | | >{{ statusTip }}</span> |
| | | </div> |
| | | </a-form-item> |
| | | </a-col> |
| | | <a-col :span="12"> |
| | | <a-form-item label="试题来源" name="sourceType"> |
| | | <jnpf-select |
| | | v-model:value="dataForm.sourceType" |
| | | :options="SOURCE_OPTIONS" |
| | | :allow-clear="false" |
| | | /> |
| | | </a-form-item> |
| | | </a-col> |
| | | </a-row> |
| | | |
| | | <a-form-item label="题干内容" name="stem"> |
| | | <div class="tms-question-editor"> |
| | | <jnpf-editor v-model:value="dataForm.stem" /> |
| | | </div> |
| | | </a-form-item> |
| | | |
| | | <a-form-item label="答案设置" required> |
| | | <AnswerSettings v-model="dataForm" /> |
| | | </a-form-item> |
| | | |
| | | <a-form-item label="试题解析" name="analysis"> |
| | | <a-textarea v-model:value="dataForm.analysis" :rows="4" placeholder="请输入试题解析(可选)" /> |
| | | </a-form-item> |
| | | |
| | | <div class="mt-6 flex justify-center gap-3"> |
| | | <a-button type="primary" :loading="submitting" @click="handleSubmit">提交</a-button> |
| | | <a-button @click="handleReset">重置</a-button> |
| | | </div> |
| | | </a-form> |
| | | </a-spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-question-form-page { |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-question-form-wrap { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: 100%; |
| | | min-height: 0; |
| | | overflow: hidden; |
| | | background: #fff; |
| | | padding: 16px; |
| | | } |
| | | |
| | | .tms-question-form-header { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | flex-shrink: 0; |
| | | margin-bottom: 16px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .tms-question-form-body { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow-x: hidden; |
| | | overflow-y: auto; |
| | | } |
| | | |
| | | .tms-question-editor :deep(.tox-tinymce), |
| | | .tms-question-editor :deep(.tox), |
| | | .tms-question-editor :deep(.ql-container), |
| | | .tms-question-editor :deep(.tox-edit-area), |
| | | .tms-question-editor :deep(iframe) { |
| | | max-height: 280px; |
| | | } |
| | | |
| | | .tms-question-editor :deep(.tox-tinymce) { |
| | | height: 280px !important; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { QuestionFormModel, QuestionOption, QuestionType } from '../types'; |
| | | |
| | | import { computed } from 'vue'; |
| | | |
| | | import { OPTION_LABELS } from '../constants'; |
| | | import { createDefaultOptions } from '../parseAnswer'; |
| | | |
| | | const model = defineModel<QuestionFormModel>({ required: true }); |
| | | |
| | | const type = computed(() => model.value.questionType); |
| | | |
| | | function relabel(options: QuestionOption[], qType: QuestionType) { |
| | | return options.map((opt, i) => ({ |
| | | ...opt, |
| | | sortNo: i + 1, |
| | | optionLabel: qType === 'blank' ? String(i + 1) : OPTION_LABELS[i] || String(i + 1), |
| | | })); |
| | | } |
| | | |
| | | function addOption() { |
| | | const qType = type.value; |
| | | const next = [...model.value.options]; |
| | | if (qType === 'blank') { |
| | | next.push({ sortNo: next.length + 1, optionLabel: String(next.length + 1), optionContent: '', isCorrect: '1' }); |
| | | } else { |
| | | if (next.length >= OPTION_LABELS.length) return; |
| | | next.push({ |
| | | sortNo: next.length + 1, |
| | | optionLabel: OPTION_LABELS[next.length], |
| | | optionContent: '', |
| | | isCorrect: '0', |
| | | }); |
| | | } |
| | | model.value.options = relabel(next, qType); |
| | | } |
| | | |
| | | function removeOption(index: number) { |
| | | const qType = type.value; |
| | | const min = qType === 'blank' ? 1 : 2; |
| | | if (model.value.options.length <= min) return; |
| | | const next = model.value.options.filter((_, i) => i !== index); |
| | | model.value.options = relabel(next, qType); |
| | | } |
| | | |
| | | function onSingleCorrect(index: number) { |
| | | model.value.options = model.value.options.map((opt, i) => ({ |
| | | ...opt, |
| | | isCorrect: i === index ? '1' : '0', |
| | | })); |
| | | } |
| | | |
| | | function onMultiCorrect(index: number, checked: boolean) { |
| | | model.value.options = model.value.options.map((opt, i) => |
| | | i === index ? { ...opt, isCorrect: checked ? '1' : '0' } : opt, |
| | | ); |
| | | } |
| | | |
| | | function resetIfEmpty() { |
| | | if (!model.value.options?.length) { |
| | | model.value.options = createDefaultOptions(type.value); |
| | | } |
| | | } |
| | | |
| | | resetIfEmpty(); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="answer-settings"> |
| | | <!-- 单选 --> |
| | | <template v-if="type === 'single'"> |
| | | <a-button type="link" class="!px-0 mb-2" @click="addOption">增加选项</a-button> |
| | | <div v-for="(opt, index) in model.options" :key="index" class="option-row"> |
| | | <a-radio :checked="opt.isCorrect === '1'" @change="onSingleCorrect(index)" /> |
| | | <span class="option-label">选项{{ opt.optionLabel }}</span> |
| | | <a-input v-model:value="opt.optionContent" placeholder="请输入选项内容" class="flex-1" /> |
| | | <a class="text-primary" @click="removeOption(index)">移除</a> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- 多选 --> |
| | | <template v-else-if="type === 'multi'"> |
| | | <a-button type="link" class="!px-0 mb-2" @click="addOption">增加选项</a-button> |
| | | <div v-for="(opt, index) in model.options" :key="index" class="option-row option-row--multi"> |
| | | <a-checkbox :checked="opt.isCorrect === '1'" @change="(e: any) => onMultiCorrect(index, !!e?.target?.checked)" /> |
| | | <span class="option-label">选项{{ opt.optionLabel }}</span> |
| | | <a-textarea v-model:value="opt.optionContent" :rows="2" placeholder="请输入选项内容" class="flex-1" /> |
| | | <a class="text-primary self-start mt-1" @click="removeOption(index)">移除</a> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- 判断 --> |
| | | <template v-else-if="type === 'judge'"> |
| | | <a-radio-group v-model:value="model.judgeAnswer"> |
| | | <a-radio :value="true">正确</a-radio> |
| | | <a-radio :value="false">错误</a-radio> |
| | | </a-radio-group> |
| | | </template> |
| | | |
| | | <!-- 填空 --> |
| | | <template v-else-if="type === 'blank'"> |
| | | <div class="mb-2 flex items-center gap-4"> |
| | | <a-button type="link" class="!px-0" @click="addOption">增加填空</a-button> |
| | | <a-checkbox v-model:checked="model.blankMixMode">混杂模式批改</a-checkbox> |
| | | </div> |
| | | <div v-for="(opt, index) in model.options" :key="index" class="option-row"> |
| | | <span class="option-label">填空{{ opt.optionLabel }}</span> |
| | | <a-input v-model:value="opt.optionContent" placeholder="请输入标准答案" class="flex-1" /> |
| | | <a class="text-primary" @click="removeOption(index)">移除</a> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- 问答 --> |
| | | <template v-else-if="type === 'essay'"> |
| | | <a-textarea v-model:value="model.essayAnswer" :rows="5" placeholder="请输入参考答案 / 评分要点" /> |
| | | </template> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .option-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 12px; |
| | | margin-bottom: 12px; |
| | | } |
| | | .option-row--multi { |
| | | align-items: flex-start; |
| | | } |
| | | .option-label { |
| | | width: 56px; |
| | | flex-shrink: 0; |
| | | color: rgba(0, 0, 0, 0.65); |
| | | } |
| | | </style> |
| New file |
| | |
| | | import type { Difficulty, QuestionStatus, QuestionType, SourceType } from './types'; |
| | | |
| | | export const QUESTION_TYPE_OPTIONS: { id: QuestionType; fullName: string }[] = [ |
| | | { id: 'single', fullName: '单选题' }, |
| | | { id: 'multi', fullName: '多选题' }, |
| | | { id: 'judge', fullName: '判断题' }, |
| | | { id: 'blank', fullName: '填空题' }, |
| | | { id: 'essay', fullName: '问答题' }, |
| | | ]; |
| | | |
| | | export const DIFFICULTY_OPTIONS: { id: Difficulty; fullName: string }[] = [ |
| | | { id: 'easy', fullName: '简单' }, |
| | | { id: 'normal', fullName: '一般' }, |
| | | { id: 'hard', fullName: '困难' }, |
| | | ]; |
| | | |
| | | export const SOURCE_OPTIONS: { id: SourceType; fullName: string }[] = [ |
| | | { id: 'self', fullName: '自主命题' }, |
| | | { id: 'import', fullName: '导入' }, |
| | | { id: 'external', fullName: '外购' }, |
| | | ]; |
| | | |
| | | 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 OPTION_LABELS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); |
| | | |
| | | export function labelOfType(type?: string) { |
| | | return QUESTION_TYPE_OPTIONS.find((x) => x.id === type)?.fullName ?? type ?? '-'; |
| | | } |
| | | |
| | | export function labelOfStatus(status?: string) { |
| | | return STATUS_OPTIONS.find((x) => x.id === status)?.fullName ?? status ?? '-'; |
| | | } |
| | | |
| | | export function labelOfDifficulty(v?: string) { |
| | | return DIFFICULTY_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-'; |
| | | } |
| | | |
| | | export function labelOfSource(v?: string) { |
| | | return SOURCE_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-'; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { QuestionBankOption, QuestionEntity } from './types'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { Modal } from 'ant-design-vue'; |
| | | |
| | | import { deleteQuestion, getQuestionBanks, getQuestionList } from '#/api/x/tms/question'; |
| | | |
| | | import { |
| | | QUESTION_TYPE_OPTIONS, |
| | | STATUS_OPTIONS, |
| | | labelOfStatus, |
| | | labelOfType, |
| | | } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsQuestionList' }); |
| | | |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const banks = ref<QuestionBankOption[]>([]); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '编号', dataIndex: 'questionNo', width: 90 }, |
| | | { title: '题库', dataIndex: 'bankName', minWidth: 220 }, |
| | | { |
| | | title: '类型', |
| | | dataIndex: 'questionType', |
| | | width: 90, |
| | | customRender: ({ record }) => labelOfType((record as QuestionEntity).questionType), |
| | | }, |
| | | { |
| | | title: '题干', |
| | | dataIndex: 'stem', |
| | | minWidth: 280, |
| | | customRender: ({ record }) => stripHtml((record as QuestionEntity).stem), |
| | | }, |
| | | { title: '创建时间', dataIndex: 'creatorTime', width: 170 }, |
| | | { title: '管理员', dataIndex: 'adminUserName', width: 100 }, |
| | | { |
| | | title: '状态', |
| | | dataIndex: 'bizStatus', |
| | | width: 90, |
| | | align: 'center', |
| | | slots: { default: 'bizStatus' }, |
| | | }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload, getForm }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | showAdvancedButton: true, |
| | | autoAdvancedLine: 1, |
| | | schemas: [ |
| | | { |
| | | field: 'bankId', |
| | | label: '题库', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | showSearch: true, |
| | | placeholder: '请选择题库', |
| | | options: [], |
| | | }, |
| | | }, |
| | | { |
| | | field: 'questionType', |
| | | label: '题型', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | showSearch: true, |
| | | placeholder: '请选择题型', |
| | | options: QUESTION_TYPE_OPTIONS, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'bizStatus', |
| | | label: '状态', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | showSearch: true, |
| | | placeholder: '请选择状态', |
| | | options: STATUS_OPTIONS.filter((x) => x.id !== 'invalid'), |
| | | }, |
| | | }, |
| | | { |
| | | field: 'adminUserId', |
| | | label: '管理员', |
| | | component: 'UserSelect', |
| | | componentProps: { |
| | | placeholder: '请选择管理员', |
| | | }, |
| | | }, |
| | | { |
| | | field: 'keyword', |
| | | label: '关键词', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入关键词', submitOnPressEnter: true }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 100, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | onMounted(async () => { |
| | | banks.value = (await getQuestionBanks()) || []; |
| | | getForm()?.updateSchema?.({ |
| | | field: 'bankId', |
| | | componentProps: { |
| | | allowClear: true, |
| | | showSearch: true, |
| | | placeholder: '请选择题库', |
| | | options: banks.value, |
| | | }, |
| | | }); |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getQuestionList(params) }; |
| | | } |
| | | |
| | | function stripHtml(html?: string) { |
| | | if (!html) return ''; |
| | | const text = html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); |
| | | return text.length > 80 ? `${text.slice(0, 80)}…` : text; |
| | | } |
| | | |
| | | function statusColor(status?: string) { |
| | | if (status === 'open') return '#52c41a'; |
| | | if (status === 'closed') return '#ff4d4f'; |
| | | return undefined; |
| | | } |
| | | |
| | | function handleCreate() { |
| | | router.push('/tms/question/create'); |
| | | } |
| | | |
| | | function handleEdit(record: QuestionEntity) { |
| | | router.push(`/tms/question/edit/${record.id}`); |
| | | } |
| | | |
| | | function handleDelete(record: QuestionEntity) { |
| | | Modal.confirm({ |
| | | title: '确认删除', |
| | | content: `确定删除试题「${stripHtml(record.stem) || record.questionNo}」吗?`, |
| | | onOk: async () => { |
| | | await deleteQuestion(record.id!); |
| | | createMessage.success('删除成功'); |
| | | reload(); |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | function getTableActions(record: QuestionEntity): ActionItem[] { |
| | | return [ |
| | | { icon: 'icon-ym icon-ym-btn-edit', tooltip: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { |
| | | icon: 'icon-ym icon-ym-btn-clearn', |
| | | tooltip: '删除', |
| | | color: 'error', |
| | | onClick: handleDelete.bind(null, record), |
| | | }, |
| | | ]; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <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-space> |
| | | </template> |
| | | <template #bizStatus="{ record }"> |
| | | <span :style="{ color: statusColor(record.bizStatus) }">{{ labelOfStatus(record.bizStatus) }}</span> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | import type { QuestionBankOption, QuestionEntity, QuestionPageQuery } from './types'; |
| | | |
| | | import dayjs from 'dayjs'; |
| | | |
| | | const banks: QuestionBankOption[] = [ |
| | | { id: 'bank1', fullName: '2026年安全生产知识培训试题1' }, |
| | | { id: 'bank2', fullName: 'GMP基础培训题库' }, |
| | | { id: 'bank3', fullName: 'SOP操作考核题库' }, |
| | | ]; |
| | | |
| | | let seq = 23560; |
| | | const store: QuestionEntity[] = [ |
| | | { |
| | | id: 'q1', |
| | | questionNo: '23561', |
| | | bankId: 'bank1', |
| | | bankName: '2026年安全生产知识培训试题1', |
| | | questionType: 'judge', |
| | | difficulty: 'normal', |
| | | sourceType: 'self', |
| | | stem: '特种作业人员必须取得有效资格证书后方可上岗作业。', |
| | | analysis: '依据安全生产相关规定。', |
| | | adminUserId: 'u1', |
| | | adminUserName: '潘志通', |
| | | bizStatus: 'open', |
| | | creatorTime: '2026-09-04 11:12:00', |
| | | options: [ |
| | | { sortNo: 1, optionLabel: 'T', optionContent: '正确', isCorrect: '1' }, |
| | | { sortNo: 2, optionLabel: 'F', optionContent: '错误', isCorrect: '0' }, |
| | | ], |
| | | }, |
| | | { |
| | | id: 'q2', |
| | | questionNo: '23562', |
| | | bankId: 'bank1', |
| | | bankName: '2026年安全生产知识培训试题1', |
| | | questionType: 'multi', |
| | | difficulty: 'normal', |
| | | sourceType: 'self', |
| | | stem: '下列哪些属于特种设备?', |
| | | adminUserId: 'u1', |
| | | adminUserName: '潘志通', |
| | | bizStatus: 'open', |
| | | creatorTime: '2026-09-04 11:20:00', |
| | | options: [ |
| | | { sortNo: 1, optionLabel: 'A', optionContent: '电梯', isCorrect: '1' }, |
| | | { sortNo: 2, optionLabel: 'B', optionContent: '压力容器', isCorrect: '1' }, |
| | | { sortNo: 3, optionLabel: 'C', optionContent: '普通办公桌', isCorrect: '0' }, |
| | | { sortNo: 4, optionLabel: 'D', optionContent: '锅炉', isCorrect: '1' }, |
| | | ], |
| | | }, |
| | | { |
| | | id: 'q3', |
| | | questionNo: '23563', |
| | | bankId: 'bank2', |
| | | bankName: 'GMP基础培训题库', |
| | | questionType: 'single', |
| | | difficulty: 'easy', |
| | | sourceType: 'self', |
| | | stem: 'GMP 的全称是?', |
| | | adminUserId: 'u1', |
| | | adminUserName: '潘志通', |
| | | bizStatus: 'closed', |
| | | creatorTime: '2026-09-05 09:00:00', |
| | | options: [ |
| | | { sortNo: 1, optionLabel: 'A', optionContent: '药品生产质量管理规范', isCorrect: '1' }, |
| | | { sortNo: 2, optionLabel: 'B', optionContent: '药品经营质量管理规范', isCorrect: '0' }, |
| | | { sortNo: 3, optionLabel: 'C', optionContent: '实验室管理规范', isCorrect: '0' }, |
| | | { sortNo: 4, optionLabel: 'D', optionContent: '文件管理规范', isCorrect: '0' }, |
| | | ], |
| | | }, |
| | | ]; |
| | | |
| | | function delay<T>(data: T, ms = 200): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | export function mockGetBanks() { |
| | | return delay([...banks]); |
| | | } |
| | | |
| | | export function mockGetAdmins() { |
| | | const map = new Map<string, string>(); |
| | | store.forEach((x) => { |
| | | if (x.adminUserId && x.adminUserName) map.set(x.adminUserId, x.adminUserName); |
| | | }); |
| | | if (!map.size) map.set('u1', '潘志通'); |
| | | return delay([...map.entries()].map(([id, fullName]) => ({ id, fullName }))); |
| | | } |
| | | |
| | | export function mockQueryQuestions(params: QuestionPageQuery) { |
| | | let list = [...store]; |
| | | if (params.bankId) list = list.filter((x) => x.bankId === params.bankId); |
| | | if (params.questionType) list = list.filter((x) => x.questionType === params.questionType); |
| | | if (params.bizStatus) list = list.filter((x) => x.bizStatus === params.bizStatus); |
| | | if (params.adminUserId) list = list.filter((x) => x.adminUserId === params.adminUserId); |
| | | if (params.keyword && params.keyword !== 'null') { |
| | | const kw = params.keyword.trim().toLowerCase(); |
| | | list = list.filter((x) => (x.stem || '').toLowerCase().includes(kw) || (x.questionNo || '').includes(kw)); |
| | | } |
| | | const currentPage = Number(params.currentPage || 1); |
| | | const pageSize = Number(params.pageSize || 20); |
| | | const start = (currentPage - 1) * pageSize; |
| | | return delay({ |
| | | list: list.slice(start, start + pageSize), |
| | | pagination: { total: list.length, currentPage, pageSize }, |
| | | }); |
| | | } |
| | | |
| | | export function mockGetQuestion(id: string) { |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) return Promise.reject(new Error('试题不存在')); |
| | | return delay({ ...row, options: row.options ? [...row.options] : [] }); |
| | | } |
| | | |
| | | export function mockCreateQuestion(data: QuestionEntity) { |
| | | seq += 1; |
| | | const bank = banks.find((b) => b.id === data.bankId); |
| | | const row: QuestionEntity = { |
| | | ...data, |
| | | id: `q${Date.now()}`, |
| | | questionNo: String(seq), |
| | | bankName: bank?.fullName, |
| | | adminUserId: 'u1', |
| | | adminUserName: '当前用户', |
| | | creatorTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), |
| | | }; |
| | | store.unshift(row); |
| | | return delay({ id: row.id, msg: '创建成功' }); |
| | | } |
| | | |
| | | export function mockUpdateQuestion(data: QuestionEntity) { |
| | | const idx = store.findIndex((x) => x.id === data.id); |
| | | if (idx < 0) return Promise.reject(new Error('试题不存在')); |
| | | const bank = banks.find((b) => b.id === data.bankId); |
| | | store[idx] = { |
| | | ...store[idx], |
| | | ...data, |
| | | bankName: bank?.fullName || store[idx].bankName, |
| | | }; |
| | | return delay({ msg: '更新成功' }); |
| | | } |
| | | |
| | | export function mockDeleteQuestion(id: string) { |
| | | const idx = store.findIndex((x) => x.id === id); |
| | | if (idx >= 0) store.splice(idx, 1); |
| | | return delay({ msg: '删除成功' }); |
| | | } |
| New file |
| | |
| | | import type { QuestionEntity, QuestionFormModel, QuestionOption, QuestionType } from './types'; |
| | | |
| | | import { OPTION_LABELS } from './constants'; |
| | | |
| | | /** 按题型生成默认答案区 */ |
| | | export function createDefaultOptions(type: QuestionType): QuestionOption[] { |
| | | if (type === 'single' || type === 'multi') { |
| | | return OPTION_LABELS.slice(0, 4).map((label, i) => ({ |
| | | sortNo: i + 1, |
| | | optionLabel: label, |
| | | optionContent: '', |
| | | isCorrect: '0', |
| | | })); |
| | | } |
| | | if (type === 'judge') { |
| | | return [ |
| | | { sortNo: 1, optionLabel: 'T', optionContent: '正确', isCorrect: '1' }, |
| | | { sortNo: 2, optionLabel: 'F', optionContent: '错误', isCorrect: '0' }, |
| | | ]; |
| | | } |
| | | if (type === 'blank') { |
| | | return [{ sortNo: 1, optionLabel: '1', optionContent: '', isCorrect: '1' }]; |
| | | } |
| | | // essay:参考答案存在 option[0] |
| | | return [{ sortNo: 1, optionLabel: 'A', optionContent: '', isCorrect: '1' }]; |
| | | } |
| | | |
| | | /** 表单 → 落库实体(各题型解析) */ |
| | | export function parseFormToEntity(form: QuestionFormModel): Omit<QuestionEntity, 'bankName' | 'adminUserName' | 'creatorTime'> { |
| | | const options = parseOptionsByType(form); |
| | | return { |
| | | id: form.id, |
| | | bankId: form.bankId || '', |
| | | questionType: form.questionType, |
| | | difficulty: form.difficulty, |
| | | sourceType: form.sourceType, |
| | | stem: form.stem, |
| | | analysis: form.analysis, |
| | | bizStatus: form.bizStatus, |
| | | blankMixMode: form.questionType === 'blank' ? form.blankMixMode : undefined, |
| | | options, |
| | | }; |
| | | } |
| | | |
| | | export function parseOptionsByType(form: QuestionFormModel): QuestionOption[] { |
| | | const { questionType } = form; |
| | | if (questionType === 'judge') { |
| | | const correct = form.judgeAnswer !== false; |
| | | return [ |
| | | { sortNo: 1, optionLabel: 'T', optionContent: '正确', isCorrect: correct ? '1' : '0' }, |
| | | { sortNo: 2, optionLabel: 'F', optionContent: '错误', isCorrect: correct ? '0' : '1' }, |
| | | ]; |
| | | } |
| | | if (questionType === 'essay') { |
| | | return [ |
| | | { |
| | | sortNo: 1, |
| | | optionLabel: 'A', |
| | | optionContent: form.essayAnswer ?? '', |
| | | isCorrect: '1', |
| | | }, |
| | | ]; |
| | | } |
| | | // single / multi / blank:直接用 options,并重排 label |
| | | return (form.options || []).map((opt, i) => ({ |
| | | ...opt, |
| | | sortNo: i + 1, |
| | | optionLabel: questionType === 'blank' ? String(i + 1) : OPTION_LABELS[i] || String(i + 1), |
| | | })); |
| | | } |
| | | |
| | | /** 实体 → 表单回填(各题型解析) */ |
| | | export function hydrateFormFromEntity(entity: QuestionEntity): QuestionFormModel { |
| | | const type = entity.questionType; |
| | | const options = entity.options?.length ? [...entity.options] : createDefaultOptions(type); |
| | | const form: QuestionFormModel = { |
| | | id: entity.id, |
| | | questionType: type, |
| | | bankId: entity.bankId, |
| | | difficulty: entity.difficulty || 'normal', |
| | | sourceType: entity.sourceType || 'self', |
| | | bizStatus: entity.bizStatus || 'closed', |
| | | stem: entity.stem || '', |
| | | analysis: entity.analysis || '', |
| | | options, |
| | | blankMixMode: !!entity.blankMixMode, |
| | | judgeAnswer: true, |
| | | essayAnswer: '', |
| | | }; |
| | | |
| | | if (type === 'judge') { |
| | | const correct = options.find((o) => o.isCorrect === '1'); |
| | | form.judgeAnswer = !correct || correct.optionLabel === 'T' || correct.optionContent === '正确'; |
| | | } else if (type === 'essay') { |
| | | form.essayAnswer = options[0]?.optionContent || ''; |
| | | } |
| | | return form; |
| | | } |
| | | |
| | | /** 提交前校验(按题型) */ |
| | | export function validateAnswerByType(form: QuestionFormModel): string | null { |
| | | if (!form.bankId) return '请选择所属题库'; |
| | | if (!form.stem?.trim()) return '请填写题干内容'; |
| | | |
| | | switch (form.questionType) { |
| | | case 'single': { |
| | | const filled = form.options.filter((o) => o.optionContent?.trim()); |
| | | if (filled.length < 2) return '单选题至少填写 2 个选项'; |
| | | if (form.options.filter((o) => o.isCorrect === '1').length !== 1) return '单选题必须且只能选择 1 个正确答案'; |
| | | break; |
| | | } |
| | | case 'multi': { |
| | | const filled = form.options.filter((o) => o.optionContent?.trim()); |
| | | if (filled.length < 2) return '多选题至少填写 2 个选项'; |
| | | if (form.options.filter((o) => o.isCorrect === '1').length < 2) return '多选题至少选择 2 个正确答案'; |
| | | break; |
| | | } |
| | | case 'judge': |
| | | if (form.judgeAnswer === undefined) return '请选择判断题答案'; |
| | | break; |
| | | case 'blank': { |
| | | if (!form.options.length) return '请至少增加一个填空'; |
| | | if (form.options.some((o) => !o.optionContent?.trim())) return '请填写每个填空的答案'; |
| | | break; |
| | | } |
| | | case 'essay': |
| | | if (!form.essayAnswer?.trim()) return '请填写问答题参考答案'; |
| | | break; |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** 切换题型时重置答案区 */ |
| | | export function resetAnswerForType(type: QuestionType): Pick<QuestionFormModel, 'options' | 'judgeAnswer' | 'essayAnswer' | 'blankMixMode'> { |
| | | return { |
| | | options: createDefaultOptions(type), |
| | | judgeAnswer: true, |
| | | essayAnswer: '', |
| | | blankMixMode: false, |
| | | }; |
| | | } |
| New file |
| | |
| | | /** 题型编码(字典 tmsQuestionType) */ |
| | | export type QuestionType = 'single' | 'multi' | 'judge' | 'blank' | 'essay'; |
| | | |
| | | /** 难度(字典 tmsDifficulty) */ |
| | | export type Difficulty = 'easy' | 'normal' | 'hard'; |
| | | |
| | | /** 来源(字典 tmsQuestionSource) */ |
| | | export type SourceType = 'self' | 'import' | 'external'; |
| | | |
| | | /** 状态(字典 tmsOpenClosedInvalid) */ |
| | | export type QuestionStatus = 'open' | 'closed' | 'invalid'; |
| | | |
| | | export interface QuestionOption { |
| | | id?: string; |
| | | sortNo: number; |
| | | optionLabel: string; |
| | | optionContent: string; |
| | | /** '1' 正确 / '0' 错误 */ |
| | | isCorrect: '0' | '1'; |
| | | } |
| | | |
| | | export interface QuestionEntity { |
| | | id?: string; |
| | | questionNo?: string; |
| | | bankId: string; |
| | | bankName?: string; |
| | | questionType: QuestionType; |
| | | difficulty?: Difficulty; |
| | | sourceType?: SourceType; |
| | | stem: string; |
| | | analysis?: string; |
| | | adminUserId?: string; |
| | | adminUserName?: string; |
| | | bizStatus: QuestionStatus; |
| | | creatorTime?: string; |
| | | options?: QuestionOption[]; |
| | | /** 填空:混杂模式批改 */ |
| | | blankMixMode?: boolean; |
| | | } |
| | | |
| | | export interface QuestionPageQuery { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | bankId?: string; |
| | | questionType?: QuestionType | ''; |
| | | bizStatus?: QuestionStatus | ''; |
| | | adminUserId?: string; |
| | | keyword?: string; |
| | | } |
| | | |
| | | export interface QuestionBankOption { |
| | | id: string; |
| | | fullName: string; |
| | | } |
| | | |
| | | export interface QuestionFormModel { |
| | | id?: string; |
| | | questionType: QuestionType; |
| | | bankId?: string; |
| | | difficulty: Difficulty; |
| | | sourceType: SourceType; |
| | | bizStatus: QuestionStatus; |
| | | stem: string; |
| | | analysis: string; |
| | | options: QuestionOption[]; |
| | | /** 判断题答案:true=正确 false=错误 */ |
| | | judgeAnswer?: boolean; |
| | | /** 问答题参考答案 */ |
| | | essayAnswer?: string; |
| | | /** 填空混杂批改 */ |
| | | blankMixMode: boolean; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { TmsRecordDetail, TmsRecordParticipant } from './types'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { |
| | | Descriptions as ADescriptions, |
| | | DescriptionsItem as ADescriptionsItem, |
| | | Table as ATable, |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { getTmsRecordDetail } from '#/api/x/tms/record'; |
| | | |
| | | defineOptions({ name: 'TmsRecordDetail' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const loading = ref(false); |
| | | const detail = ref<TmsRecordDetail | null>(null); |
| | | |
| | | const participantColumns = [ |
| | | { title: '姓名', dataIndex: 'userName', key: 'userName', width: 140, align: 'center' }, |
| | | { title: '部门', dataIndex: 'deptName', key: 'deptName', width: 200, align: 'center' }, |
| | | { title: '签到日期', dataIndex: 'signDate', key: 'signDate', width: 180, align: 'center' }, |
| | | { title: '考试成绩', dataIndex: 'examScore', key: 'examScore', width: 120, align: 'center' }, |
| | | { |
| | | title: '是否合格', |
| | | dataIndex: 'passFlag', |
| | | key: 'passFlag', |
| | | width: 120, |
| | | align: 'center', |
| | | customRender: ({ record }: { record: TmsRecordParticipant }) => passText(record), |
| | | }, |
| | | ]; |
| | | |
| | | onMounted(() => { |
| | | loadData(); |
| | | }); |
| | | |
| | | async function loadData() { |
| | | const id = String(route.params.id || ''); |
| | | if (!id) { |
| | | router.replace('/tms/record'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | detail.value = await getTmsRecordDetail(id); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载培训记录失败'); |
| | | router.replace('/tms/record'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function goBack() { |
| | | const from = String(route.query.from || ''); |
| | | if (from === 'archived') { |
| | | router.push('/tms/record/archived'); |
| | | return; |
| | | } |
| | | if (from === 'ready') { |
| | | router.push('/tms/record/ready'); |
| | | return; |
| | | } |
| | | if (from === 'main') { |
| | | router.push('/tms/record'); |
| | | return; |
| | | } |
| | | router.back(); |
| | | } |
| | | |
| | | function passText(row: TmsRecordParticipant) { |
| | | if (row.passFlag === '1') return '合格'; |
| | | if (row.passFlag === '0') return '不合格'; |
| | | return ''; |
| | | } |
| | | |
| | | function openAttachment(url?: string) { |
| | | if (!url || url === '#') { |
| | | createMessage.info('附件预览联调后生效'); |
| | | return; |
| | | } |
| | | window.open(url, '_blank'); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-record-detail-page"> |
| | | <div class="jnpf-content-wrapper-center tms-record-detail-center"> |
| | | <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> |
| | | |
| | | <ADescriptions bordered :column="2" size="middle" class="detail-desc"> |
| | | <ADescriptionsItem label="培训编号">{{ detail.recordNo }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="任务关闭时间">{{ detail.closeTime || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训主题" :span="2"> |
| | | <div class="pre-line">{{ detail.subject }}</div> |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem label="培训要点" :span="2"> |
| | | <div class="pre-line">{{ detail.keyPoints || '-' }}</div> |
| | | </ADescriptionsItem> |
| | | <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="培训师">{{ detail.trainerName }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训考核方式">{{ detail.evalMode }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="培训对象" :span="2">{{ detail.trainees || '-' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="附件" :span="2"> |
| | | <div v-if="detail.attachments?.length" class="attach-list"> |
| | | <div v-for="file in detail.attachments" :key="file.id" class="attach-item"> |
| | | <span>{{ file.name }}</span> |
| | | <a class="attach-link" @click.prevent="openAttachment(file.url)">原文</a> |
| | | </div> |
| | | </div> |
| | | <span v-else>-</span> |
| | | </ADescriptionsItem> |
| | | <ADescriptionsItem label="备注" :span="2">{{ detail.remark || '-' }}</ADescriptionsItem> |
| | | </ADescriptions> |
| | | |
| | | <div class="section-title">参加人员名单如下</div> |
| | | <div class="participant-table"> |
| | | <ATable |
| | | size="middle" |
| | | bordered |
| | | row-key="id" |
| | | table-layout="fixed" |
| | | :columns="participantColumns" |
| | | :data-source="detail.participants || []" |
| | | :pagination="false" |
| | | :scroll="{ x: 760 }" |
| | | :locale="{ emptyText: '暂无参加人员' }" |
| | | /> |
| | | </div> </div> |
| | | </a-spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* 全局 jnpf-content-wrapper* 为 overflow:hidden 且无 min-height:0,整条高度链补齐才能滚 */ |
| | | .tms-record-detail-page { |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-record-detail-center { |
| | | height: 100% !important; |
| | | min-height: 0 !important; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .tms-record-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; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 16px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .detail-desc { |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .pre-line { |
| | | white-space: pre-wrap; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .attach-list { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 6px; |
| | | } |
| | | |
| | | .attach-item { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .attach-link { |
| | | flex-shrink: 0; |
| | | color: var(--primary-color, #1677ff); |
| | | cursor: pointer; |
| | | } |
| | | |
| | | .attach-link:hover { |
| | | text-decoration: underline; |
| | | } |
| | | |
| | | .section-title { |
| | | margin: 8px 0 12px; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .participant-table :deep(.ant-table-table) { |
| | | table-layout: fixed !important; |
| | | } |
| | | |
| | | .participant-table :deep(.ant-table-thead > tr > th), |
| | | .participant-table :deep(.ant-table-tbody > tr > td) { |
| | | text-align: center; |
| | | } |
| | | |
| | | .participant-table :deep(.ant-table-placeholder .ant-table-cell) { |
| | | text-align: center; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { TmsRecordDetail } from './types'; |
| | | |
| | | import { onMounted, reactive, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | |
| | | import { getTmsRecordDetail, updateTmsRecord } from '#/api/x/tms/record'; |
| | | |
| | | defineOptions({ name: 'TmsRecordEdit' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const loading = ref(false); |
| | | const submitting = ref(false); |
| | | const detail = ref<TmsRecordDetail | null>(null); |
| | | |
| | | const form = reactive({ |
| | | subject: '', |
| | | category: '', |
| | | trainerName: '', |
| | | trainType: '', |
| | | evalMode: '', |
| | | trainMode: '', |
| | | remark: '', |
| | | }); |
| | | |
| | | onMounted(() => { |
| | | loadData(); |
| | | }); |
| | | |
| | | async function loadData() { |
| | | const id = String(route.params.id || ''); |
| | | if (!id) { |
| | | router.replace('/tms/record'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | detail.value = await getTmsRecordDetail(id); |
| | | form.subject = detail.value.subject; |
| | | form.category = detail.value.category; |
| | | form.trainerName = detail.value.trainerName; |
| | | form.trainType = detail.value.trainType; |
| | | form.evalMode = detail.value.evalMode; |
| | | form.trainMode = detail.value.trainMode || ''; |
| | | form.remark = detail.value.remark || ''; |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载培训记录失败'); |
| | | router.replace('/tms/record'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | if (!detail.value) return; |
| | | if (!form.subject.trim()) { |
| | | createMessage.warning('请填写培训主题'); |
| | | return; |
| | | } |
| | | submitting.value = true; |
| | | try { |
| | | await updateTmsRecord(detail.value.id, { ...form }); |
| | | createMessage.success('保存成功'); |
| | | router.push('/tms/record'); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '保存失败'); |
| | | } finally { |
| | | submitting.value = false; |
| | | } |
| | | } |
| | | |
| | | function goBack() { |
| | | router.push('/tms/record'); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-record-page"> |
| | | <div class="jnpf-content-wrapper-center tms-record-center"> |
| | | <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> |
| | | <div class="text-base font-medium">修改培训记录</div> |
| | | <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-space> |
| | | </div> |
| | | |
| | | <a-form layout="vertical" class="edit-form"> |
| | | <a-row :gutter="16"> |
| | | <a-col :span="12"> |
| | | <a-form-item label="培训主题" required> |
| | | <a-input v-model:value="form.subject" placeholder="请输入培训主题" /> |
| | | </a-form-item> |
| | | </a-col> |
| | | <a-col :span="12"> |
| | | <a-form-item label="培训分类"> |
| | | <a-input v-model:value="form.category" placeholder="请输入培训分类" /> |
| | | </a-form-item> |
| | | </a-col> |
| | | <a-col :span="12"> |
| | | <a-form-item label="培训师"> |
| | | <a-input v-model:value="form.trainerName" placeholder="请输入培训师" /> |
| | | </a-form-item> |
| | | </a-col> |
| | | <a-col :span="12"> |
| | | <a-form-item label="培训类型"> |
| | | <a-input v-model:value="form.trainType" placeholder="请输入培训类型" /> |
| | | </a-form-item> |
| | | </a-col> |
| | | <a-col :span="12"> |
| | | <a-form-item label="培训方式"> |
| | | <a-input v-model:value="form.trainMode" placeholder="请输入培训方式" /> |
| | | </a-form-item> |
| | | </a-col> |
| | | <a-col :span="12"> |
| | | <a-form-item label="考核方式"> |
| | | <a-input v-model:value="form.evalMode" placeholder="请输入考核方式" /> |
| | | </a-form-item> |
| | | </a-col> |
| | | <a-col :span="24"> |
| | | <a-form-item label="备注"> |
| | | <a-textarea v-model:value="form.remark" :rows="3" placeholder="备注(可选)" /> |
| | | </a-form-item> |
| | | </a-col> |
| | | </a-row> |
| | | </a-form> |
| | | </div> |
| | | </a-spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-record-page, |
| | | .tms-record-center { |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-record-wrap { |
| | | display: flex !important; |
| | | flex-direction: column; |
| | | flex: 1 1 0 !important; |
| | | min-height: 0 !important; |
| | | height: 100%; |
| | | overflow: auto !important; |
| | | background: #fff; |
| | | } |
| | | |
| | | .record-spin { |
| | | display: block; |
| | | min-height: 100%; |
| | | width: 100%; |
| | | } |
| | | |
| | | .record-inner { |
| | | padding: 16px 20px 24px; |
| | | max-width: 960px; |
| | | } |
| | | |
| | | .record-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-bottom: 16px; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { TmsRecordDetail } from './types'; |
| | | |
| | | import { onMounted, reactive, 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'; |
| | | |
| | | defineOptions({ name: 'TmsRecordSign' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | 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 }, |
| | | { title: '姓名', dataIndex: 'userName', key: 'userName', width: 120 }, |
| | | { title: '部门', dataIndex: 'deptName', key: 'deptName', minWidth: 140 }, |
| | | { title: '签到时间', dataIndex: 'signTime', key: 'signTime', width: 180 }, |
| | | { title: '签到方式', dataIndex: 'signMode', key: 'signMode', width: 120 }, |
| | | ]; |
| | | |
| | | onMounted(() => { |
| | | loadData(); |
| | | }); |
| | | |
| | | async function loadData() { |
| | | const id = String(route.params.id || ''); |
| | | if (!id) { |
| | | router.replace('/tms/record'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | detail.value = await getTmsRecordDetail(id); |
| | | if (detail.value.archiveStatus === 'archived') { |
| | | createMessage.warning('已存档记录不可签到'); |
| | | router.replace(`/tms/record/detail/${id}`); |
| | | } |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载培训记录失败'); |
| | | router.replace('/tms/record'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | 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'); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-record-page"> |
| | | <div class="jnpf-content-wrapper-center tms-record-center"> |
| | | <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> |
| | | <div class="text-base font-medium">培训签到</div> |
| | | <div class="mt-1 text-gray-400 text-sm"> |
| | | {{ detail.recordNo }} · {{ detail.subject }} |
| | | </div> |
| | | </div> |
| | | <a-button @click="goBack">返回</a-button> |
| | | </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> |
| | | |
| | | <div class="section-title">已签到人员</div> |
| | | <ATable |
| | | size="middle" |
| | | bordered |
| | | row-key="id" |
| | | :columns="signColumns" |
| | | :data-source="detail.signList || []" |
| | | :pagination="false" |
| | | :locale="{ emptyText: '暂无签到记录' }" |
| | | /> |
| | | </div> |
| | | </a-spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-record-page, |
| | | .tms-record-center { |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-record-wrap { |
| | | display: flex !important; |
| | | flex-direction: column; |
| | | flex: 1 1 0 !important; |
| | | min-height: 0 !important; |
| | | height: 100%; |
| | | overflow: auto !important; |
| | | background: #fff; |
| | | } |
| | | |
| | | .record-spin { |
| | | display: block; |
| | | min-height: 100%; |
| | | width: 100%; |
| | | } |
| | | |
| | | .record-inner { |
| | | 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> |
| New file |
| | |
| | | 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: '无效' }, |
| | | ]; |
| | | |
| | | export const LIST_MODE_LABEL: Record<RecordListMode, string> = { |
| | | main: '培训记录', |
| | | ready: '可归档列表', |
| | | 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 colorOfArchiveStatus(status?: ArchiveStatus) { |
| | | if (status === 'ready') return '#1677ff'; |
| | | if (status === 'archived') return '#52c41a'; |
| | | if (status === 'invalid') return '#ff4d4f'; |
| | | return undefined; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { RecordListMode, TmsRecordListItem } from './types'; |
| | | |
| | | import { computed, watch } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { Modal } from 'ant-design-vue'; |
| | | |
| | | import { archiveTmsRecords, deleteTmsRecords, getTmsRecordList } from '#/api/x/tms/record'; |
| | | |
| | | import { LIST_MODE_LABEL, RECORD_LIST_PATH } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsRecord' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | function modeFromPath(path: string): RecordListMode { |
| | | if (path.includes('/archived')) return 'archived'; |
| | | if (path.includes('/ready')) return 'ready'; |
| | | return 'main'; |
| | | } |
| | | |
| | | const listMode = computed(() => modeFromPath(route.path)); |
| | | const listModeTitle = computed(() => LIST_MODE_LABEL[listMode.value]); |
| | | const isMainView = computed(() => listMode.value === 'main'); |
| | | const isReadyView = computed(() => listMode.value === 'ready'); |
| | | const isArchivedView = computed(() => listMode.value === 'archived'); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '编号', dataIndex: 'recordNo', width: 140 }, |
| | | { title: '培训分类', dataIndex: 'category', width: 120 }, |
| | | { |
| | | title: '培训主题', |
| | | dataIndex: 'subject', |
| | | minWidth: 280, |
| | | slots: { default: 'subject' }, |
| | | }, |
| | | { title: '培训师', dataIndex: 'trainerName', width: 100 }, |
| | | { title: '培训类型', dataIndex: 'trainType', width: 110 }, |
| | | { title: '培训开始时间', dataIndex: 'startTime', width: 160 }, |
| | | { title: '考核方式', dataIndex: 'evalMode', width: 110 }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload, getSelectRows }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | rowKey: 'id', |
| | | rowSelection: { type: 'checkbox' }, |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | showAdvancedButton: true, |
| | | autoAdvancedLine: 1, |
| | | schemas: [ |
| | | { |
| | | field: 'keyword', |
| | | label: '关键词', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '编号/主题/培训师', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'recordNo', |
| | | label: '编号', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入编号', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'category', |
| | | label: '培训分类', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入培训分类', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'trainerName', |
| | | label: '培训师', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入培训师', submitOnPressEnter: true }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 100, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | watch( |
| | | () => listMode.value, |
| | | () => reload(), |
| | | ); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { |
| | | data: await getTmsRecordList({ |
| | | ...params, |
| | | listMode: listMode.value, |
| | | }), |
| | | }; |
| | | } |
| | | |
| | | function getSelectedRows(): TmsRecordListItem[] { |
| | | return (getSelectRows?.() || []) as TmsRecordListItem[]; |
| | | } |
| | | |
| | | function requireOneRow(): TmsRecordListItem | null { |
| | | const rows = getSelectedRows(); |
| | | if (!rows.length) { |
| | | createMessage.warning('请先勾选一条记录'); |
| | | return null; |
| | | } |
| | | if (rows.length > 1) { |
| | | createMessage.warning('请只勾选一条记录'); |
| | | return null; |
| | | } |
| | | return rows[0]; |
| | | } |
| | | |
| | | function requireRows(): TmsRecordListItem[] | null { |
| | | const rows = getSelectedRows(); |
| | | if (!rows.length) { |
| | | createMessage.warning('请先勾选记录'); |
| | | return null; |
| | | } |
| | | return rows; |
| | | } |
| | | |
| | | function goList(mode: RecordListMode) { |
| | | 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; |
| | | router.push(`/tms/record/edit/${row.id}`); |
| | | } |
| | | |
| | | function handleView(record?: TmsRecordListItem) { |
| | | const row = record || requireOneRow(); |
| | | if (!row) return; |
| | | router.push({ |
| | | path: `/tms/record/detail/${row.id}`, |
| | | query: { from: listMode.value }, |
| | | }); |
| | | } |
| | | |
| | | function handleSign() { |
| | | const row = requireOneRow(); |
| | | if (!row) return; |
| | | router.push(`/tms/record/sign/${row.id}`); |
| | | } |
| | | |
| | | function handleDelete() { |
| | | const rows = requireRows(); |
| | | if (!rows) return; |
| | | Modal.confirm({ |
| | | title: '确认删除', |
| | | content: `确定删除选中的 ${rows.length} 条培训记录吗?`, |
| | | onOk: async () => { |
| | | try { |
| | | await deleteTmsRecords(rows.map((x) => x.id)); |
| | | createMessage.success('删除成功'); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '删除失败'); |
| | | } |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | function handleArchive() { |
| | | const rows = requireRows(); |
| | | if (!rows) return; |
| | | Modal.confirm({ |
| | | title: '确认归档', |
| | | content: `确定将选中的 ${rows.length} 条记录归档吗?`, |
| | | onOk: async () => { |
| | | try { |
| | | const count = await archiveTmsRecords(rows.map((x) => x.id)); |
| | | createMessage.success(`已归档 ${count} 条`); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '归档失败'); |
| | | } |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | 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; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <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-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> |
| | | </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-file-text" @click="goList('archived')">已归档列表</a-button> |
| | | <span class="text-gray-400 text-sm">当前:{{ 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-space> |
| | | </template> |
| | | <template #subject="{ record }"> |
| | | <div class="subject-cell">{{ record.subject }}</div> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .subject-cell { |
| | | white-space: pre-wrap; |
| | | line-height: 1.45; |
| | | word-break: break-word; |
| | | } |
| | | </style> |
| New file |
| | |
| | | import type { |
| | | RecordListMode, |
| | | TmsRecordDetail, |
| | | TmsRecordListItem, |
| | | TmsRecordPageQuery, |
| | | } from './types'; |
| | | |
| | | function delay<T>(data: T, ms = 220): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | const now = Date.now(); |
| | | const day = 24 * 60 * 60 * 1000; |
| | | |
| | | function fmt(ts: number, withSec = false) { |
| | | const d = new Date(ts); |
| | | const p = (n: number) => String(n).padStart(2, '0'); |
| | | const base = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; |
| | | return withSec ? `${base}:00` : base; |
| | | } |
| | | |
| | | /** mock 数据:培训记录(pending) + 可归档(ready) + 已归档(archived) */ |
| | | const store: TmsRecordListItem[] = [ |
| | | { |
| | | id: 'rc0a', |
| | | recordNo: 'TT2026091801', |
| | | category: '临时培训', |
| | | subject: '本周质量专题宣贯', |
| | | trainerName: '王敏', |
| | | trainType: '集中授课', |
| | | startTime: fmt(now + 1 * day + 9 * 60 * 60 * 1000), |
| | | evalMode: '提问', |
| | | archiveStatus: 'pending', |
| | | }, |
| | | { |
| | | id: 'rc0b', |
| | | recordNo: 'TT2026091702', |
| | | category: '文件生效培训', |
| | | subject: '(SOP-PR-0008) 批记录填写规范 Rev.02', |
| | | trainerName: '陈刚', |
| | | trainType: '在线学习', |
| | | startTime: fmt(now - 0.5 * day + 10 * 60 * 60 * 1000), |
| | | evalMode: '无需考核', |
| | | archiveStatus: 'pending', |
| | | signRate: 60, |
| | | }, |
| | | { |
| | | id: 'rc0c', |
| | | recordNo: 'TT2026091603', |
| | | category: '岗位培训计划', |
| | | subject: '取样员岗位操作再培训', |
| | | trainerName: '赵丽', |
| | | trainType: '操作培训', |
| | | startTime: fmt(now - 2 * day + 14 * 60 * 60 * 1000), |
| | | evalMode: '实操考核', |
| | | archiveStatus: 'pending', |
| | | signRate: 70, |
| | | }, |
| | | { |
| | | id: 'rc1', |
| | | recordNo: 'TT2026090525', |
| | | category: '文件生效培训', |
| | | subject: '(JY-03-0042-B2449) 文件发放与回收管理规程 Rev.03', |
| | | trainerName: '梁波', |
| | | trainType: '在线学习', |
| | | startTime: fmt(now - 5 * day + 9.8 * 60 * 60 * 1000), |
| | | evalMode: '无需考核', |
| | | archiveStatus: 'ready', |
| | | signRate: 100, |
| | | passRate: 100, |
| | | }, |
| | | { |
| | | id: 'rc2', |
| | | recordNo: 'TT2026090339', |
| | | category: '岗位培训计划', |
| | | subject: '分析实验室岗位再培训(HPLC 操作)', |
| | | trainerName: '孔德敏', |
| | | trainType: '集中授课', |
| | | startTime: fmt(now - 8 * day + 14 * 60 * 60 * 1000), |
| | | evalMode: '提问', |
| | | archiveStatus: 'ready', |
| | | signRate: 95, |
| | | passRate: 90, |
| | | }, |
| | | { |
| | | id: 'rc3', |
| | | recordNo: 'TT2026090201', |
| | | category: '临时培训', |
| | | subject: '偏差调查流程宣贯', |
| | | trainerName: '季远达', |
| | | trainType: '在线学习', |
| | | startTime: fmt(now - 12 * day + 10 * 60 * 60 * 1000), |
| | | evalMode: '无需考核', |
| | | archiveStatus: 'ready', |
| | | signRate: 88, |
| | | passRate: 88, |
| | | }, |
| | | { |
| | | id: 'rc4', |
| | | recordNo: 'TT2026082812', |
| | | category: '文件生效培训', |
| | | subject: '(SOP-QA-0012) 变更控制管理程序 Rev.05', |
| | | trainerName: '梁波', |
| | | trainType: '在线学习', |
| | | startTime: fmt(now - 20 * day + 9 * 60 * 60 * 1000), |
| | | evalMode: '提问', |
| | | archiveStatus: 'ready', |
| | | signRate: 100, |
| | | passRate: 96, |
| | | }, |
| | | { |
| | | id: 'rc5', |
| | | recordNo: 'TT2026090638', |
| | | category: '临时培训', |
| | | subject: '硝酸奥司他韦干混悬剂稳定性标准培训', |
| | | trainerName: '周莉华', |
| | | trainType: '在线学习', |
| | | startTime: '2020-09-17 16:00', |
| | | endTime: '2020-09-17 17:30', |
| | | evalMode: '无需考核', |
| | | archiveStatus: 'archived', |
| | | trainMode: '在线学习', |
| | | signRate: 100, |
| | | passRate: 100, |
| | | }, |
| | | { |
| | | id: 'rc6', |
| | | recordNo: 'TT2026090610', |
| | | category: '文件生效培训', |
| | | subject: '(JY-05-0011) 稳定性考察管理规程 Rev.01', |
| | | trainerName: '黄援花', |
| | | trainType: '集中授课', |
| | | startTime: fmt(now - 3 * day + 9.5 * 60 * 60 * 1000), |
| | | evalMode: '无需考核', |
| | | archiveStatus: 'archived', |
| | | signRate: 100, |
| | | passRate: 100, |
| | | }, |
| | | { |
| | | id: 'rc7', |
| | | recordNo: 'TT2026081508', |
| | | category: '岗位培训计划', |
| | | subject: '实验室安全再培训', |
| | | trainerName: '李四', |
| | | trainType: '在线学习', |
| | | startTime: fmt(now - 40 * day + 10 * 60 * 60 * 1000), |
| | | evalMode: '在线考试', |
| | | archiveStatus: 'archived', |
| | | signRate: 100, |
| | | passRate: 92, |
| | | }, |
| | | { |
| | | id: 'rc8', |
| | | recordNo: 'TT2026072203', |
| | | category: '临时培训', |
| | | subject: '测试在线考试0415', |
| | | trainerName: 'mkj', |
| | | trainType: '在线学习', |
| | | startTime: fmt(now - 60 * day), |
| | | evalMode: '在线考试', |
| | | archiveStatus: 'archived', |
| | | signRate: 80, |
| | | passRate: 75, |
| | | }, |
| | | ]; |
| | | |
| | | const signStore: Record<string, TmsRecordDetail['signList']> = { |
| | | rc0b: [ |
| | | { id: 's0', userName: '周杰', deptName: '生产部', signTime: fmt(now - 0.5 * day, true), signMode: 'APP扫码' }, |
| | | ], |
| | | rc1: [ |
| | | { id: 's1', userName: '张三', deptName: '质量管理部', signTime: fmt(now - 5 * day, true), signMode: 'APP扫码' }, |
| | | { id: 's2', userName: '李四', deptName: '分析实验室', signTime: fmt(now - 5 * day, true), signMode: '微信扫码' }, |
| | | ], |
| | | rc5: [ |
| | | { id: 's3', userName: '王五', deptName: '生产部', signTime: '2020-09-17 16:05:00', signMode: '在线签到' }, |
| | | ], |
| | | }; |
| | | |
| | | type Participant = NonNullable<TmsRecordDetail['participants']>[number]; |
| | | |
| | | const participantStore: Record<string, Participant[]> = { |
| | | rc0a: [ |
| | | { id: 'p0a1', userName: '陈晓', deptName: '质量管理部', signDate: '', examScore: '', passFlag: '' }, |
| | | { id: 'p0a2', userName: '林娜', deptName: '生产一部', signDate: '', examScore: '', passFlag: '' }, |
| | | { id: 'p0a3', userName: '黄伟', deptName: '生产二部', signDate: '', examScore: '', passFlag: '' }, |
| | | ], |
| | | rc0b: [ |
| | | { id: 'p0b1', userName: '周杰', deptName: '生产部', signDate: fmt(now - 0.5 * day, true), examScore: '', passFlag: '' }, |
| | | { id: 'p0b2', userName: '何静', deptName: '质量保证部', signDate: fmt(now - 0.5 * day, true), examScore: '', passFlag: '' }, |
| | | { id: 'p0b3', userName: '罗明', deptName: '工程部', signDate: fmt(now - 0.5 * day + 8 * 60 * 1000, true), examScore: '', passFlag: '' }, |
| | | { id: 'p0b4', userName: '邓丽', deptName: '仓储部', signDate: '', examScore: '', passFlag: '' }, |
| | | ], |
| | | rc0c: [ |
| | | { id: 'p0c1', userName: '赵丽', deptName: '分析实验室', signDate: fmt(now - 2 * day + 14 * 60 * 60 * 1000, true), examScore: 92, passFlag: '1' }, |
| | | { id: 'p0c2', userName: '孙浩', deptName: '取样组', signDate: fmt(now - 2 * day + 14.1 * 60 * 60 * 1000, true), examScore: 88, passFlag: '1' }, |
| | | { id: 'p0c3', userName: '钱芳', deptName: '取样组', signDate: fmt(now - 2 * day + 14.2 * 60 * 60 * 1000, true), examScore: 76, passFlag: '1' }, |
| | | { id: 'p0c4', userName: '吴磊', deptName: 'QC实验室', signDate: fmt(now - 2 * day + 14.3 * 60 * 60 * 1000, true), examScore: 58, passFlag: '0' }, |
| | | { id: 'p0c5', userName: '郑雪', deptName: '取样组', signDate: fmt(now - 2 * day + 14.4 * 60 * 60 * 1000, true), examScore: 85, passFlag: '1' }, |
| | | { id: 'p0c6', userName: '冯涛', deptName: '质量保证部', signDate: '', examScore: '', passFlag: '' }, |
| | | ], |
| | | rc1: [ |
| | | { id: 'p11', userName: '张三', deptName: '质量管理部', signDate: fmt(now - 5 * day, true), examScore: '', passFlag: '' }, |
| | | { id: 'p12', userName: '李四', deptName: '分析实验室', signDate: fmt(now - 5 * day, true), examScore: '', passFlag: '' }, |
| | | { id: 'p13', userName: '王芳', deptName: '文件管理组', signDate: fmt(now - 5 * day + 6 * 60 * 1000, true), examScore: '', passFlag: '' }, |
| | | { id: 'p14', userName: '刘洋', deptName: '质量保证部', signDate: fmt(now - 5 * day + 12 * 60 * 1000, true), examScore: '', passFlag: '' }, |
| | | { id: 'p15', userName: '陈明', deptName: '生产一部', signDate: fmt(now - 5 * day + 15 * 60 * 1000, true), examScore: '', passFlag: '' }, |
| | | ], |
| | | rc2: [ |
| | | { id: 'p21', userName: '孔德敏', deptName: '分析实验室', signDate: fmt(now - 8 * day + 14 * 60 * 60 * 1000, true), examScore: 90, passFlag: '1' }, |
| | | { id: 'p22', userName: '马超', deptName: '分析实验室', signDate: fmt(now - 8 * day + 14.1 * 60 * 60 * 1000, true), examScore: 82, passFlag: '1' }, |
| | | { id: 'p23', userName: '徐倩', deptName: '方法开发组', signDate: fmt(now - 8 * day + 14.2 * 60 * 60 * 1000, true), examScore: 65, passFlag: '0' }, |
| | | { id: 'p24', userName: '蔡磊', deptName: '分析实验室', signDate: fmt(now - 8 * day + 14.3 * 60 * 60 * 1000, true), examScore: 95, passFlag: '1' }, |
| | | ], |
| | | rc3: [ |
| | | { id: 'p31', userName: '季远达', deptName: '质量保证部', signDate: fmt(now - 12 * day + 10 * 60 * 60 * 1000, true), examScore: '', passFlag: '' }, |
| | | { id: 'p32', userName: '唐敏', deptName: '偏差调查组', signDate: fmt(now - 12 * day + 10.1 * 60 * 60 * 1000, true), examScore: '', passFlag: '' }, |
| | | { id: 'p33', userName: '潘杰', deptName: '生产一部', signDate: fmt(now - 12 * day + 10.2 * 60 * 60 * 1000, true), examScore: '', passFlag: '' }, |
| | | ], |
| | | rc4: [ |
| | | { id: 'p41', userName: '梁波', deptName: '质量保证部', signDate: fmt(now - 20 * day + 9 * 60 * 60 * 1000, true), examScore: 88, passFlag: '1' }, |
| | | { id: 'p42', userName: '谢娜', deptName: '变更控制组', signDate: fmt(now - 20 * day + 9.1 * 60 * 60 * 1000, true), examScore: 91, passFlag: '1' }, |
| | | { id: 'p43', userName: '韩冬', deptName: '生产二部', signDate: fmt(now - 20 * day + 9.2 * 60 * 60 * 1000, true), examScore: 70, passFlag: '1' }, |
| | | { id: 'p44', userName: '曹颖', deptName: '文件管理组', signDate: fmt(now - 20 * day + 9.3 * 60 * 60 * 1000, true), examScore: 55, passFlag: '0' }, |
| | | ], |
| | | rc5: [ |
| | | { id: 'p51', userName: '李俊佳', deptName: '稳定性组', signDate: '2020-09-17 16:02:00', examScore: '', passFlag: '' }, |
| | | { id: 'p52', userName: '周莉', deptName: '分析实验室', signDate: '2020-09-17 16:03:00', examScore: '', passFlag: '' }, |
| | | { id: 'p53', userName: '梁伟星', deptName: '质量保证部', signDate: '2020-09-17 16:05:00', examScore: '', passFlag: '' }, |
| | | { id: 'p54', userName: '陈晓明', deptName: '研发中心', signDate: '2020-09-17 16:08:00', examScore: '', passFlag: '' }, |
| | | { id: 'p55', userName: '王芳', deptName: '稳定性组', signDate: '2020-09-17 16:10:00', examScore: '', passFlag: '' }, |
| | | { id: 'p56', userName: '张敏', deptName: 'QC实验室', signDate: '2020-09-17 16:12:00', examScore: '', passFlag: '' }, |
| | | { id: 'p57', userName: '刘洋', deptName: '生产一部', signDate: '2020-09-17 16:15:00', examScore: '', passFlag: '' }, |
| | | { id: 'p58', userName: '赵强', deptName: '仓储部', signDate: '', examScore: '', passFlag: '' }, |
| | | ], |
| | | rc6: [ |
| | | { id: 'p61', userName: '黄援花', deptName: '稳定性组', signDate: fmt(now - 3 * day + 9.5 * 60 * 60 * 1000, true), examScore: '', passFlag: '' }, |
| | | { id: 'p62', userName: '宋佳', deptName: '稳定性组', signDate: fmt(now - 3 * day + 9.6 * 60 * 60 * 1000, true), examScore: '', passFlag: '' }, |
| | | { id: 'p63', userName: '姜涛', deptName: '质量保证部', signDate: fmt(now - 3 * day + 9.7 * 60 * 60 * 1000, true), examScore: '', passFlag: '' }, |
| | | ], |
| | | rc7: [ |
| | | { id: 'p71', userName: '李四', deptName: '分析实验室', signDate: fmt(now - 40 * day + 10 * 60 * 60 * 1000, true), examScore: 86, passFlag: '1' }, |
| | | { id: 'p72', userName: '周强', deptName: 'EHS', signDate: fmt(now - 40 * day + 10.1 * 60 * 60 * 1000, true), examScore: 78, passFlag: '1' }, |
| | | { id: 'p73', userName: '吴倩', deptName: '生产一部', signDate: fmt(now - 40 * day + 10.2 * 60 * 60 * 1000, true), examScore: 62, passFlag: '0' }, |
| | | { id: 'p74', userName: '郑浩', deptName: '工程部', signDate: fmt(now - 40 * day + 10.3 * 60 * 60 * 1000, true), examScore: 90, passFlag: '1' }, |
| | | { id: 'p75', userName: '冯蕾', deptName: '仓储部', signDate: fmt(now - 40 * day + 10.4 * 60 * 60 * 1000, true), examScore: 84, passFlag: '1' }, |
| | | ], |
| | | rc8: [ |
| | | { id: 'p81', userName: 'mkj', deptName: '测试组', signDate: fmt(now - 60 * day, true), examScore: 0, passFlag: '0' }, |
| | | { id: 'p82', userName: '测试员A', deptName: '测试组', signDate: fmt(now - 60 * day, true), examScore: 80, passFlag: '1' }, |
| | | { id: 'p83', userName: '测试员B', deptName: '质量管理部', signDate: fmt(now - 60 * day + 5 * 60 * 1000, true), examScore: 72, passFlag: '1' }, |
| | | ], |
| | | }; |
| | | |
| | | /** 查看页扩展明细(按 id) */ |
| | | const detailExtra: Record<string, Partial<TmsRecordDetail>> = { |
| | | rc0c: { |
| | | closeTime: fmt(now - 1 * day + 18 * 60 * 60 * 1000), |
| | | keyPoints: '取样操作规范、样品标识、取样记录填写要点', |
| | | placeName: '取样室 / 在线学习平台', |
| | | trainees: '赵丽,孙浩,钱芳,吴磊,郑雪,冯涛', |
| | | remark: '', |
| | | attachments: [ |
| | | { id: 'a0c1', name: '取样员岗位操作规程.pdf', url: '#' }, |
| | | { id: 'a0c2', name: '取样记录填写示例.pdf', url: '#' }, |
| | | ], |
| | | }, |
| | | rc5: { |
| | | subject: |
| | | '硝酸奥司他韦干混悬剂, 6mg/ml (360mg/瓶) 稳定性标准 (ST1921B6m360ct, Rev01)\n硝酸奥司他韦干混悬剂, 6mg/ml (15mg/袋) 稳定性标准 (ST1921B6m15ct, Rev01)', |
| | | startTime: '2020-09-17 16:00', |
| | | endTime: '2020-09-17 17:30', |
| | | closeTime: '2020-09-24 17:30', |
| | | category: '临时培训', |
| | | trainMode: '在线学习', |
| | | trainType: '在线学习', |
| | | trainerName: '周莉华', |
| | | evalMode: '无需考核', |
| | | keyPoints: '', |
| | | placeName: '', |
| | | trainees: |
| | | '李俊佳,周莉,梁伟星,陈晓明,王芳,张敏,刘洋,赵强,孙丽,周杰,吴倩,郑浩,冯蕾,曹阳,彭静,徐磊', |
| | | remark: '', |
| | | attachments: [ |
| | | { |
| | | id: 'a1', |
| | | name: '硝酸奥司他韦干混悬剂, 6mg/ml (360mg/瓶) 稳定性标准 (ST1921B6m360ct, Rev01).pdf', |
| | | url: '#', |
| | | }, |
| | | { |
| | | id: 'a2', |
| | | name: '硝酸奥司他韦干混悬剂, 6mg/ml (15mg/袋) 稳定性标准 (ST1921B6m15ct, Rev01).pdf', |
| | | url: '#', |
| | | }, |
| | | ], |
| | | }, |
| | | }; |
| | | |
| | | function matchMode(item: TmsRecordListItem, mode?: RecordListMode) { |
| | | if (mode === 'ready') return item.archiveStatus === 'ready'; |
| | | if (mode === 'archived') return item.archiveStatus === 'archived'; |
| | | // 培训记录主列表:进行中 / 待归档 |
| | | return item.archiveStatus === 'pending'; |
| | | } |
| | | |
| | | export function mockQueryRecords(params: TmsRecordPageQuery = {}) { |
| | | const { |
| | | currentPage = 1, |
| | | pageSize = 20, |
| | | keyword = '', |
| | | recordNo = '', |
| | | category = '', |
| | | trainerName = '', |
| | | listMode = 'main', |
| | | archiveStatus, |
| | | } = params; |
| | | |
| | | let list = store.filter((x) => matchMode(x, listMode)); |
| | | |
| | | if (archiveStatus) list = list.filter((x) => x.archiveStatus === archiveStatus); |
| | | if (recordNo) list = list.filter((x) => x.recordNo.includes(recordNo)); |
| | | if (category) list = list.filter((x) => x.category.includes(category)); |
| | | if (trainerName) list = list.filter((x) => x.trainerName.includes(trainerName)); |
| | | if (keyword) { |
| | | const k = keyword.trim().toLowerCase(); |
| | | list = list.filter( |
| | | (x) => |
| | | x.recordNo.toLowerCase().includes(k) || |
| | | x.subject.toLowerCase().includes(k) || |
| | | x.trainerName.toLowerCase().includes(k) || |
| | | x.category.toLowerCase().includes(k), |
| | | ); |
| | | } |
| | | |
| | | const start = (currentPage - 1) * pageSize; |
| | | const pageList = list.slice(start, start + pageSize); |
| | | return delay({ |
| | | list: pageList, |
| | | pagination: { total: list.length, currentPage, pageSize }, |
| | | }); |
| | | } |
| | | |
| | | export async function mockGetRecordDetail(id: string) { |
| | | await delay(null); |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) throw new Error('培训记录不存在'); |
| | | const extra = detailExtra[id] || {}; |
| | | const signs = signStore[id] || []; |
| | | const participants = participantStore[id] || []; |
| | | const trainees = |
| | | extra.trainees || participants.map((p) => p.userName).join(',') || ''; |
| | | |
| | | return { |
| | | ...row, |
| | | taskId: `task-${id}`, |
| | | taskNo: row.recordNo, |
| | | closeTime: row.endTime || '', |
| | | keyPoints: '', |
| | | placeName: '', |
| | | remark: '', |
| | | attachments: [], |
| | | trainMode: row.trainMode || row.trainType, |
| | | signList: signs, |
| | | ...extra, |
| | | // 名单与培训对象以 store 为准,避免被 extra 空值覆盖 |
| | | trainees, |
| | | participants, |
| | | } as TmsRecordDetail; |
| | | } |
| | | |
| | | export async function mockUpdateRecord(id: string, data: Partial<TmsRecordListItem>) { |
| | | await delay(null); |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) throw new Error('培训记录不存在'); |
| | | Object.assign(row, data); |
| | | return true; |
| | | } |
| | | |
| | | export async function mockArchiveRecords(ids: string[]) { |
| | | await delay(null); |
| | | let count = 0; |
| | | for (const id of ids) { |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) continue; |
| | | if (row.archiveStatus === 'archived') continue; |
| | | row.archiveStatus = 'archived'; |
| | | count += 1; |
| | | } |
| | | if (!count) throw new Error('没有可归档的记录'); |
| | | return count; |
| | | } |
| | | |
| | | export async function mockDeleteRecords(ids: string[]) { |
| | | await delay(null); |
| | | let count = 0; |
| | | for (const id of ids) { |
| | | const idx = store.findIndex((x) => x.id === id); |
| | | if (idx < 0) continue; |
| | | if (store[idx].archiveStatus === 'archived') { |
| | | throw new Error(`已归档记录「${store[idx].recordNo}」不可删除`); |
| | | } |
| | | store.splice(idx, 1); |
| | | count += 1; |
| | | } |
| | | if (!count) throw new Error('请选择要删除的记录'); |
| | | return count; |
| | | } |
| New file |
| | |
| | | /** 三个列表页:培训记录 / 可归档 / 已归档 */ |
| | | export type RecordListMode = 'main' | 'ready' | 'archived'; |
| | | |
| | | /** 归档状态:pending待归档 / ready可归档 / archived已归档 / invalid无效 */ |
| | | export type ArchiveStatus = 'pending' | 'ready' | 'archived' | 'invalid'; |
| | | |
| | | /** 培训记录列表行 */ |
| | | export interface TmsRecordListItem { |
| | | id: string; |
| | | recordNo: string; |
| | | category: string; |
| | | subject: string; |
| | | trainerName: string; |
| | | /** 列表「培训类型」列,如在线学习/集中授课 */ |
| | | trainType: string; |
| | | startTime: string; |
| | | endTime?: string; |
| | | evalMode: string; |
| | | archiveStatus: ArchiveStatus; |
| | | trainMode?: string; |
| | | signRate?: number; |
| | | passRate?: number; |
| | | } |
| | | |
| | | export interface TmsRecordPageQuery { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | keyword?: string; |
| | | recordNo?: string; |
| | | category?: string; |
| | | trainerName?: string; |
| | | listMode?: RecordListMode; |
| | | archiveStatus?: ArchiveStatus; |
| | | } |
| | | |
| | | /** 附件 */ |
| | | export interface TmsRecordAttachment { |
| | | id: string; |
| | | name: string; |
| | | url?: string; |
| | | } |
| | | |
| | | /** 参加人员(查看页名单) */ |
| | | export interface TmsRecordParticipant { |
| | | id: string; |
| | | userName: string; |
| | | deptName?: string; |
| | | /** 签到日期 */ |
| | | signDate?: string; |
| | | /** 考试成绩 */ |
| | | examScore?: string | number; |
| | | /** 是否合格 1是 0否 空未评 */ |
| | | passFlag?: '0' | '1' | ''; |
| | | } |
| | | |
| | | export interface TmsRecordSignItem { |
| | | id: string; |
| | | userName: string; |
| | | deptName?: string; |
| | | signTime?: string; |
| | | signMode?: string; |
| | | guestFlag?: '0' | '1'; |
| | | } |
| | | |
| | | export interface TmsRecordDetail extends TmsRecordListItem { |
| | | taskId?: string; |
| | | taskNo?: string; |
| | | /** 任务关闭时间 */ |
| | | closeTime?: string; |
| | | /** 培训要点 */ |
| | | keyPoints?: string; |
| | | /** 培训地点 */ |
| | | placeName?: string; |
| | | /** 培训对象(逗号分隔姓名) */ |
| | | trainees?: string; |
| | | remark?: string; |
| | | attachments?: TmsRecordAttachment[]; |
| | | /** 参加人员名单 */ |
| | | participants?: TmsRecordParticipant[]; |
| | | signList: TmsRecordSignItem[]; |
| | | } |
| New file |
| | |
| | | /** 自我检测:题目数量(0 / 5 / 10 / 15 …) */ |
| | | export const COUNT_OPTIONS = Array.from({ length: 11 }, (_, i) => { |
| | | const n = i * 5; |
| | | return { id: n, fullName: `${n}条` }; |
| | | }); |
| | | |
| | | /** 自我检测难度(与老系统一致,带颜色) */ |
| | | export type SelfTestDifficulty = 'veryEasy' | 'easier' | 'normal' | 'harder' | 'veryHard'; |
| | | |
| | | export const SELF_TEST_DIFFICULTY_OPTIONS: { |
| | | id: SelfTestDifficulty; |
| | | fullName: string; |
| | | color: string; |
| | | }[] = [ |
| | | { id: 'veryEasy', fullName: '很容易', color: '#52c41a' }, |
| | | { id: 'easier', fullName: '较容易', color: '#1890ff' }, |
| | | { id: 'normal', fullName: '一般', color: '#000000' }, |
| | | { id: 'harder', fullName: '较难', color: '#fa8c16' }, |
| | | { id: 'veryHard', fullName: '非常难', color: '#f5222d' }, |
| | | ]; |
| | | |
| | | /** 自我检测难度 → 试题 difficulty 字典值(easy/normal/hard) */ |
| | | export const SELF_TEST_DIFFICULTY_TO_QUESTION: Record<SelfTestDifficulty, string[]> = { |
| | | veryEasy: ['easy', 'veryEasy'], |
| | | easier: ['easy', 'easier'], |
| | | normal: ['normal'], |
| | | harder: ['hard', 'harder'], |
| | | veryHard: ['hard', 'veryHard'], |
| | | }; |
| | | |
| | | export function colorOfSelfTestDifficulty(v?: string) { |
| | | return SELF_TEST_DIFFICULTY_OPTIONS.find((x) => x.id === v)?.color ?? '#000'; |
| | | } |
| | | |
| | | export function labelOfSelfTestDifficulty(v?: string) { |
| | | return SELF_TEST_DIFFICULTY_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-'; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { SelfTestPaper, SelfTestQuestionItem } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | |
| | | import { labelOfType } from '#/views/x/tms/question/constants'; |
| | | |
| | | defineOptions({ name: 'TmsSelfTestExam' }); |
| | | |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | 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 scoreText = ref(''); |
| | | |
| | | const current = computed(() => paper.value?.questions?.[currentIndex.value]); |
| | | const total = computed(() => paper.value?.questions?.length || 0); |
| | | |
| | | onMounted(() => { |
| | | const raw = sessionStorage.getItem('tms_self_test_paper'); |
| | | if (!raw) { |
| | | createMessage.warning('请先设置抽题条件'); |
| | | router.replace('/tms/selfTest'); |
| | | return; |
| | | } |
| | | try { |
| | | paper.value = JSON.parse(raw); |
| | | } catch { |
| | | router.replace('/tms/selfTest'); |
| | | } |
| | | }); |
| | | |
| | | 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/selfTest'); |
| | | } |
| | | |
| | | function isCorrect(q: SelfTestQuestionItem): 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(','); |
| | | } |
| | | 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} 题`); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content tms-exam-page" v-if="paper"> |
| | | <div class="tms-exam-header"> |
| | | <div> |
| | | <div class="text-base font-medium">自我检测 · {{ paper.bankName }}</div> |
| | | <div class="mt-1 text-gray-400 text-sm"> |
| | | 第 {{ currentIndex + 1 }} / {{ total }} 题 |
| | | <span v-if="submitted" class="ml-3 text-primary">得分:{{ scoreText }}</span> |
| | | </div> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="goBack">返回设置</a-button> |
| | | <a-button type="primary" :disabled="submitted" @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> |
| | | <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]" |
| | | class="!flex !flex-col gap-3" |
| | | :disabled="submitted" |
| | | > |
| | | <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'" |
| | | v-model:value="answers[current.id]" |
| | | class="!flex !flex-col gap-3" |
| | | :disabled="submitted" |
| | | > |
| | | <a-checkbox v-for="opt in current.options" :key="opt.optionLabel" :value="opt.optionLabel"> |
| | | {{ opt.optionLabel }}. {{ opt.optionContent }} |
| | | </a-checkbox> |
| | | </a-checkbox-group> |
| | | |
| | | <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('、') }} |
| | | </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> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-exam-page { |
| | | background: #fff; |
| | | padding: 20px 24px; |
| | | height: 100%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-exam-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | padding-bottom: 12px; |
| | | margin-bottom: 16px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .tms-exam-body { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .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> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { QuestionBankOption } from '#/views/x/tms/question/types'; |
| | | import type { SelfTestFormModel } from './types'; |
| | | |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | |
| | | import { getSelfTestBanks, startSelfTest } from '#/api/x/tms/selfTest'; |
| | | |
| | | import { |
| | | COUNT_OPTIONS, |
| | | SELF_TEST_DIFFICULTY_OPTIONS, |
| | | colorOfSelfTestDifficulty, |
| | | type SelfTestDifficulty, |
| | | } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsSelfTest' }); |
| | | |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const banks = ref<QuestionBankOption[]>([]); |
| | | const loading = ref(false); |
| | | const formRef = ref(); |
| | | |
| | | function defaultSetting() { |
| | | return { count: 0, difficulty: 'normal' as SelfTestDifficulty }; |
| | | } |
| | | |
| | | const dataForm = reactive<SelfTestFormModel>({ |
| | | bankId: undefined, |
| | | single: defaultSetting(), |
| | | multi: defaultSetting(), |
| | | judge: defaultSetting(), |
| | | }); |
| | | |
| | | const rules = { |
| | | bankId: [{ required: true, message: '请选择题库', trigger: 'change' }], |
| | | }; |
| | | |
| | | const totalCount = computed( |
| | | () => 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; |
| | | } |
| | | }); |
| | | |
| | | function handleReset() { |
| | | dataForm.bankId = banks.value.length === 1 ? banks.value[0].id : undefined; |
| | | dataForm.single = defaultSetting(); |
| | | dataForm.multi = defaultSetting(); |
| | | dataForm.judge = defaultSetting(); |
| | | formRef.value?.clearValidate?.(); |
| | | } |
| | | |
| | | async function handleStart() { |
| | | try { |
| | | await formRef.value?.validate(); |
| | | } catch { |
| | | return; |
| | | } |
| | | if (totalCount.value <= 0) { |
| | | createMessage.warning('请至少设置一种题型的抽题数量'); |
| | | return; |
| | | } |
| | | |
| | | loading.value = true; |
| | | try { |
| | | const bank = banks.value.find((b) => b.id === dataForm.bankId); |
| | | const paper = await startSelfTest({ |
| | | bankId: dataForm.bankId!, |
| | | bankName: bank?.fullName, |
| | | single: { ...dataForm.single }, |
| | | multi: { ...dataForm.multi }, |
| | | judge: { ...dataForm.judge }, |
| | | }); |
| | | if (!paper.questions?.length) { |
| | | createMessage.warning('当前条件下没有可抽题目,请调整数量或难度'); |
| | | return; |
| | | } |
| | | sessionStorage.setItem('tms_self_test_paper', JSON.stringify(paper)); |
| | | router.push('/tms/selfTest/exam'); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '开始自我检测失败'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <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> |
| | | |
| | | <a-form |
| | | ref="formRef" |
| | | :model="dataForm" |
| | | :rules="rules" |
| | | :label-col="{ style: { width: '120px' } }" |
| | | class="tms-self-test-form" |
| | | > |
| | | <a-form-item label="选择题库" name="bankId"> |
| | | <jnpf-select |
| | | v-model:value="dataForm.bankId" |
| | | :options="banks" |
| | | show-search |
| | | placeholder="请选择题库" |
| | | class="!w-[420px]" |
| | | /> |
| | | </a-form-item> |
| | | |
| | | <a-form-item label="单选题设置"> |
| | | <div class="setting-row"> |
| | | <jnpf-select |
| | | v-model:value="dataForm.single.count" |
| | | :options="COUNT_OPTIONS" |
| | | :allow-clear="false" |
| | | class="!w-[160px]" |
| | | /> |
| | | <a-select |
| | | v-model:value="dataForm.single.difficulty" |
| | | class="!w-[160px] difficulty-select" |
| | | :style="{ color: colorOfSelfTestDifficulty(dataForm.single.difficulty) }" |
| | | > |
| | | <a-select-option |
| | | v-for="opt in SELF_TEST_DIFFICULTY_OPTIONS" |
| | | :key="opt.id" |
| | | :value="opt.id" |
| | | > |
| | | <span :style="{ color: opt.color }">{{ opt.fullName }}</span> |
| | | </a-select-option> |
| | | </a-select> |
| | | </div> |
| | | </a-form-item> |
| | | |
| | | <a-form-item label="多选题设置"> |
| | | <div class="setting-row"> |
| | | <jnpf-select |
| | | v-model:value="dataForm.multi.count" |
| | | :options="COUNT_OPTIONS" |
| | | :allow-clear="false" |
| | | class="!w-[160px]" |
| | | /> |
| | | <a-select |
| | | v-model:value="dataForm.multi.difficulty" |
| | | class="!w-[160px] difficulty-select" |
| | | :style="{ color: colorOfSelfTestDifficulty(dataForm.multi.difficulty) }" |
| | | > |
| | | <a-select-option |
| | | v-for="opt in SELF_TEST_DIFFICULTY_OPTIONS" |
| | | :key="opt.id" |
| | | :value="opt.id" |
| | | > |
| | | <span :style="{ color: opt.color }">{{ opt.fullName }}</span> |
| | | </a-select-option> |
| | | </a-select> |
| | | </div> |
| | | </a-form-item> |
| | | |
| | | <a-form-item label="判断题设置"> |
| | | <div class="setting-row"> |
| | | <jnpf-select |
| | | v-model:value="dataForm.judge.count" |
| | | :options="COUNT_OPTIONS" |
| | | :allow-clear="false" |
| | | class="!w-[160px]" |
| | | /> |
| | | <a-select |
| | | v-model:value="dataForm.judge.difficulty" |
| | | class="!w-[160px] difficulty-select" |
| | | :style="{ color: colorOfSelfTestDifficulty(dataForm.judge.difficulty) }" |
| | | > |
| | | <a-select-option |
| | | v-for="opt in SELF_TEST_DIFFICULTY_OPTIONS" |
| | | :key="opt.id" |
| | | :value="opt.id" |
| | | > |
| | | <span :style="{ color: opt.color }">{{ opt.fullName }}</span> |
| | | </a-select-option> |
| | | </a-select> |
| | | </div> |
| | | </a-form-item> |
| | | |
| | | <a-form-item :wrapper-col="{ style: { marginLeft: '120px' } }"> |
| | | <a-space> |
| | | <a-button type="primary" :loading="loading" @click="handleStart">开始自我检测</a-button> |
| | | <a-button @click="handleReset">重置</a-button> |
| | | </a-space> |
| | | </a-form-item> |
| | | </a-form> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-self-test-page { |
| | | background: #fff; |
| | | padding: 20px 24px; |
| | | height: 100%; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .tms-self-test-header { |
| | | margin-bottom: 24px; |
| | | padding-bottom: 12px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | } |
| | | |
| | | .tms-self-test-form { |
| | | max-width: 720px; |
| | | padding-top: 8px; |
| | | } |
| | | |
| | | .setting-row { |
| | | display: flex; |
| | | gap: 16px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .difficulty-select :deep(.ant-select-selection-item) { |
| | | color: inherit; |
| | | } |
| | | </style> |
| New file |
| | |
| | | import type { SelfTestPaper, SelfTestQuestionItem, SelfTestStartPayload } from './types'; |
| | | |
| | | import { mockGetQuestion, mockQueryQuestions } from '#/views/x/tms/question/mock'; |
| | | |
| | | import { SELF_TEST_DIFFICULTY_TO_QUESTION, type SelfTestDifficulty } from './constants'; |
| | | |
| | | function delay<T>(data: T, ms = 250): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | /** 按条件从 mock 题库抽题(不够则放宽难度) */ |
| | | export async function mockStartSelfTest(payload: SelfTestStartPayload): Promise<SelfTestPaper> { |
| | | const all = await mockQueryQuestions({ bankId: payload.bankId, pageSize: 500, currentPage: 1 }); |
| | | const list = all.list || []; |
| | | |
| | | const pick = async (type: 'single' | 'multi' | 'judge', count: number, difficulty: SelfTestDifficulty) => { |
| | | if (count <= 0) return []; |
| | | const mapped = SELF_TEST_DIFFICULTY_TO_QUESTION[difficulty] || [difficulty]; |
| | | let pool = list.filter((q) => q.questionType === type); |
| | | if (difficulty) { |
| | | pool = pool.filter((q) => !q.difficulty || mapped.includes(q.difficulty)); |
| | | } |
| | | // 不够则放宽难度 |
| | | if (pool.length < count) { |
| | | pool = list.filter((q) => q.questionType === type); |
| | | } |
| | | const sliced = pool.slice(0, count); |
| | | const detailed: SelfTestQuestionItem[] = []; |
| | | for (const q of sliced) { |
| | | const full = await mockGetQuestion(q.id!); |
| | | detailed.push({ |
| | | id: full.id!, |
| | | questionNo: full.questionNo, |
| | | questionType: full.questionType as 'single' | 'multi' | 'judge', |
| | | difficulty: full.difficulty, |
| | | stem: full.stem, |
| | | options: (full.options || []).map((o) => ({ |
| | | optionLabel: o.optionLabel, |
| | | optionContent: o.optionContent, |
| | | isCorrect: o.isCorrect, |
| | | })), |
| | | }); |
| | | } |
| | | return detailed; |
| | | }; |
| | | |
| | | const questions = [ |
| | | ...(await pick('single', payload.single.count, payload.single.difficulty)), |
| | | ...(await pick('multi', payload.multi.count, payload.multi.difficulty)), |
| | | ...(await pick('judge', payload.judge.count, payload.judge.difficulty)), |
| | | ]; |
| | | |
| | | return delay({ |
| | | paperId: `self_${Date.now()}`, |
| | | bankId: payload.bankId, |
| | | bankName: payload.bankName || '', |
| | | questions, |
| | | }); |
| | | } |
| New file |
| | |
| | | import type { Difficulty } from '#/views/x/tms/question/types'; |
| | | import type { SelfTestDifficulty } from './constants'; |
| | | |
| | | /** 自我检测抽题条件 */ |
| | | export interface SelfTestTypeSetting { |
| | | count: number; |
| | | difficulty: SelfTestDifficulty; |
| | | } |
| | | |
| | | export interface SelfTestFormModel { |
| | | bankId?: string; |
| | | single: SelfTestTypeSetting; |
| | | multi: SelfTestTypeSetting; |
| | | judge: SelfTestTypeSetting; |
| | | } |
| | | |
| | | export interface SelfTestStartPayload { |
| | | bankId: string; |
| | | bankName?: string; |
| | | single: SelfTestTypeSetting; |
| | | multi: SelfTestTypeSetting; |
| | | judge: SelfTestTypeSetting; |
| | | } |
| | | |
| | | /** 抽题结果(考试页) */ |
| | | export interface SelfTestPaper { |
| | | paperId: string; |
| | | bankId: string; |
| | | bankName: string; |
| | | questions: SelfTestQuestionItem[]; |
| | | } |
| | | |
| | | export interface SelfTestQuestionItem { |
| | | id: string; |
| | | questionNo?: string; |
| | | questionType: 'single' | 'multi' | 'judge'; |
| | | difficulty?: Difficulty; |
| | | stem: string; |
| | | options: { optionLabel: string; optionContent: string; isCorrect: '0' | '1' }[]; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, reactive, toRefs, unref } from 'vue'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicForm, useForm } from '@jnpf/ui/form'; |
| | | import { BasicModal, useModalInner } from '@jnpf/ui/modal'; |
| | | |
| | | import { createSignMode, getSignModeInfo, updateSignMode } from '#/api/x/tms/signMode'; |
| | | |
| | | import { ENABLE_OPTIONS, YES_NO_OPTIONS } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsSignModeForm' }); |
| | | |
| | | const emit = defineEmits(['register', 'reload']); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const state = reactive({ id: '' }); |
| | | const { id } = toRefs(state); |
| | | const getTitle = computed(() => (unref(id) ? '编辑签到方式' : '新增签到方式')); |
| | | |
| | | const [registerForm, { setFieldsValue, resetFields, validate, updateSchema }] = useForm({ |
| | | labelWidth: 130, |
| | | schemas: [ |
| | | { |
| | | field: 'modeCode', |
| | | label: '方式编码', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '如 app_scan / wx_scan / online', maxlength: 50 }, |
| | | rules: [{ required: true, message: '必填', trigger: 'blur' }], |
| | | }, |
| | | { |
| | | field: 'modeName', |
| | | label: '方式名称', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入方式名称', maxlength: 100 }, |
| | | rules: [{ required: true, message: '必填', trigger: 'blur' }], |
| | | }, |
| | | { |
| | | field: 'tipSeconds', |
| | | label: '要点阅读秒数', |
| | | component: 'InputNumber', |
| | | defaultValue: 30, |
| | | componentProps: { min: 0, max: 3600, style: { width: '100%' }, addonAfter: '秒' }, |
| | | rules: [{ required: true, message: '必填', trigger: 'blur', type: 'number' }], |
| | | }, |
| | | { |
| | | field: 'matchRoster', |
| | | label: '须匹配名单', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | label: '启用状态', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'remark', |
| | | label: '备注', |
| | | component: 'Textarea', |
| | | componentProps: { placeholder: '请输入备注', rows: 3, maxlength: 500 }, |
| | | }, |
| | | ], |
| | | }); |
| | | |
| | | const [registerModal, { closeModal, changeLoading, changeOkLoading }] = useModalInner(init); |
| | | |
| | | async function init(data: { id?: string }) { |
| | | changeLoading(true); |
| | | changeOkLoading(false); |
| | | resetFields(); |
| | | state.id = data?.id || ''; |
| | | updateSchema({ |
| | | field: 'modeCode', |
| | | componentProps: { |
| | | placeholder: '如 app_scan / wx_scan / online', |
| | | maxlength: 50, |
| | | disabled: !!state.id, |
| | | }, |
| | | }); |
| | | try { |
| | | if (state.id) { |
| | | setFieldsValue(await getSignModeInfo(state.id)); |
| | | } |
| | | } finally { |
| | | changeLoading(false); |
| | | } |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | const values = await validate(); |
| | | if (!values) return; |
| | | changeOkLoading(true); |
| | | try { |
| | | if (state.id) await updateSignMode({ ...values, id: state.id }); |
| | | else await createSignMode(values); |
| | | createMessage.success('保存成功'); |
| | | closeModal(); |
| | | emit('reload'); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '保存失败'); |
| | | } finally { |
| | | changeOkLoading(false); |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <BasicModal v-bind="$attrs" :title="getTitle" @register="registerModal" @ok="handleSubmit"> |
| | | <BasicForm @register="registerForm" /> |
| | | </BasicModal> |
| | | </template> |
| New file |
| | |
| | | export const YES_NO_OPTIONS = [ |
| | | { id: '1', fullName: '是' }, |
| | | { id: '0', fullName: '否' }, |
| | | ]; |
| | | |
| | | export const ENABLE_OPTIONS = [ |
| | | { id: '1', fullName: '启用' }, |
| | | { id: '0', fullName: '停用' }, |
| | | ]; |
| | | |
| | | export function labelOfYesNo(v?: string) { |
| | | return YES_NO_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| | | |
| | | export function labelOfEnabled(v?: string) { |
| | | return ENABLE_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { SignModeItem } from './types'; |
| | | |
| | | 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 Form from './Form.vue'; |
| | | import { ENABLE_OPTIONS, labelOfEnabled, labelOfYesNo } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsSignMode' }); |
| | | |
| | | const { createMessage } = useMessage(); |
| | | const [registerForm, { openModal: openFormModal }] = useModal(); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '方式编码', dataIndex: 'modeCode', width: 140 }, |
| | | { title: '方式名称', dataIndex: 'modeName', width: 140 }, |
| | | { |
| | | title: '要点阅读秒数', |
| | | dataIndex: 'tipSeconds', |
| | | width: 130, |
| | | align: 'center', |
| | | customRender: ({ record }) => `${(record as SignModeItem).tipSeconds ?? 0} 秒`, |
| | | }, |
| | | { |
| | | title: '须匹配名单', |
| | | dataIndex: 'matchRoster', |
| | | width: 120, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as SignModeItem).matchRoster), |
| | | }, |
| | | { |
| | | title: '状态', |
| | | dataIndex: 'enabled', |
| | | width: 90, |
| | | align: 'center', |
| | | slots: { default: 'enabled' }, |
| | | }, |
| | | { title: '备注', dataIndex: 'remark', minWidth: 220 }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | schemas: [ |
| | | { |
| | | field: 'keyword', |
| | | label: '关键词', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '编码/名称', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | label: '状态', |
| | | component: 'Select', |
| | | componentProps: { allowClear: true, placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 180, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getSignModeList(params) }; |
| | | } |
| | | |
| | | function handleAdd() { |
| | | openFormModal(true, {}); |
| | | } |
| | | |
| | | function handleEdit(record: SignModeItem) { |
| | | openFormModal(true, { id: record.id }); |
| | | } |
| | | |
| | | async function handleToggleEnabled(record: SignModeItem) { |
| | | const next = record.enabled === '1' ? '0' : '1'; |
| | | const action = next === '1' ? '启用' : '停用'; |
| | | try { |
| | | await setSignModeEnabled(record.id, next); |
| | | createMessage.success(`已${action}`); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || `${action}失败`); |
| | | } |
| | | } |
| | | |
| | | async function handleDelete(record: SignModeItem) { |
| | | try { |
| | | await deleteSignMode(record.id); |
| | | createMessage.success('删除成功'); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '删除失败'); |
| | | } |
| | | } |
| | | |
| | | function getTableActions(record: SignModeItem): ActionItem[] { |
| | | const enableLabel = record.enabled === '1' ? '停用' : '启用'; |
| | | return [ |
| | | { label: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { |
| | | label: enableLabel, |
| | | modelConfirm: { |
| | | content: `确定${enableLabel}签到方式「${record.modeName}」吗?`, |
| | | onOk: handleToggleEnabled.bind(null, record), |
| | | }, |
| | | }, |
| | | { |
| | | label: '删除', |
| | | color: 'error', |
| | | modelConfirm: { |
| | | content: `确定删除签到方式「${record.modeName}」吗?`, |
| | | onOk: handleDelete.bind(null, record), |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleAdd">新增</a-button> |
| | | </template> |
| | | <template #enabled="{ record }"> |
| | | <a-tag :color="record.enabled === '1' ? 'success' : 'default'"> |
| | | {{ labelOfEnabled(record.enabled) }} |
| | | </a-tag> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | <Form @register="registerForm" @reload="reload" /> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | import type { SignModeItem, SignModePageQuery } from './types'; |
| | | |
| | | function delay<T>(data: T, ms = 180): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | const store: SignModeItem[] = [ |
| | | { |
| | | id: 'tms_sign_app', |
| | | modeCode: 'app_scan', |
| | | modeName: 'APP扫码', |
| | | tipSeconds: 30, |
| | | matchRoster: '1', |
| | | enabled: '1', |
| | | remark: '系统 APP 扫码(图片识别 / 摄像头 / 长按识码)', |
| | | }, |
| | | { |
| | | id: 'tms_sign_wx', |
| | | modeCode: 'wx_scan', |
| | | modeName: '微信扫码', |
| | | tipSeconds: 30, |
| | | matchRoster: '1', |
| | | enabled: '1', |
| | | remark: '微信扫码签到', |
| | | }, |
| | | { |
| | | id: 'tms_sign_online', |
| | | modeCode: 'online', |
| | | modeName: '在线签到', |
| | | tipSeconds: 30, |
| | | matchRoster: '1', |
| | | enabled: '1', |
| | | remark: '登录系统在线签到', |
| | | }, |
| | | ]; |
| | | |
| | | export function mockQuerySignModes(params: SignModePageQuery = {}) { |
| | | const { currentPage = 1, pageSize = 20, keyword = '', enabled } = params; |
| | | let list = [...store]; |
| | | if (enabled) list = list.filter((x) => x.enabled === enabled); |
| | | if (keyword) { |
| | | const k = keyword.trim().toLowerCase(); |
| | | list = list.filter( |
| | | (x) => x.modeCode.toLowerCase().includes(k) || x.modeName.toLowerCase().includes(k), |
| | | ); |
| | | } |
| | | const start = (currentPage - 1) * pageSize; |
| | | return delay({ |
| | | list: list.slice(start, start + pageSize), |
| | | pagination: { total: list.length, currentPage, pageSize }, |
| | | }); |
| | | } |
| | | |
| | | export async function mockGetSignMode(id: string) { |
| | | await delay(null); |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) throw new Error('签到方式不存在'); |
| | | return { ...row }; |
| | | } |
| | | |
| | | export async function mockSaveSignMode(data: Partial<SignModeItem> & { id?: string }) { |
| | | await delay(null); |
| | | if (data.id) { |
| | | const row = store.find((x) => x.id === data.id); |
| | | if (!row) throw new Error('签到方式不存在'); |
| | | if (data.modeCode && data.modeCode !== row.modeCode) { |
| | | if (store.some((x) => x.modeCode === data.modeCode && x.id !== data.id)) { |
| | | throw new Error('方式编码已存在'); |
| | | } |
| | | } |
| | | Object.assign(row, data); |
| | | return row; |
| | | } |
| | | if (!data.modeCode) throw new Error('请填写方式编码'); |
| | | if (store.some((x) => x.modeCode === data.modeCode)) throw new Error('方式编码已存在'); |
| | | const row: SignModeItem = { |
| | | id: `sm_${Date.now()}`, |
| | | modeCode: data.modeCode, |
| | | modeName: data.modeName || data.modeCode, |
| | | tipSeconds: data.tipSeconds ?? 30, |
| | | matchRoster: (data.matchRoster as any) || '1', |
| | | enabled: (data.enabled as any) || '1', |
| | | remark: data.remark || '', |
| | | }; |
| | | store.push(row); |
| | | return row; |
| | | } |
| | | |
| | | export async function mockSetSignModeEnabled(id: string, enabled: '0' | '1') { |
| | | await delay(null); |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) throw new Error('签到方式不存在'); |
| | | row.enabled = enabled; |
| | | return row; |
| | | } |
| | | |
| | | export async function mockDeleteSignMode(id: string) { |
| | | await delay(null); |
| | | const idx = store.findIndex((x) => x.id === id); |
| | | if (idx < 0) throw new Error('签到方式不存在'); |
| | | store.splice(idx, 1); |
| | | return true; |
| | | } |
| New file |
| | |
| | | /** 签到方式配置 */ |
| | | export interface SignModeItem { |
| | | id: string; |
| | | modeCode: string; |
| | | modeName: string; |
| | | /** 要点阅读秒数,默认 30 */ |
| | | tipSeconds: number; |
| | | /** 须匹配培训对象名单 */ |
| | | matchRoster: '0' | '1'; |
| | | enabled: '0' | '1'; |
| | | remark?: string; |
| | | } |
| | | |
| | | export interface SignModePageQuery { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | keyword?: string; |
| | | enabled?: string; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { TrainArchiveDetail } from './types'; |
| | | |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { useUserStore } from '@vben/stores'; |
| | | import { |
| | | Descriptions as ADescriptions, |
| | | DescriptionsItem as ADescriptionsItem, |
| | | Table as ATable, |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { getMyTrainArchive } from '#/api/x/tms/trainArchive'; |
| | | |
| | | 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 }, |
| | | { title: '入职时间', dataIndex: 'hireDate', key: 'hireDate', width: 140 }, |
| | | { title: '离职时间', dataIndex: 'leaveDate', key: 'leaveDate', width: 140 }, |
| | | { title: '公司名称', dataIndex: 'companyName', key: 'companyName' }, |
| | | { title: '任职岗位', dataIndex: 'postName', key: 'postName', width: 180 }, |
| | | ]; |
| | | |
| | | const catalogColumns = [ |
| | | { title: '序号', key: 'index', width: 70, align: 'center', customRender: ({ index }: any) => index + 1 }, |
| | | { title: '培训记录编号', dataIndex: 'recordNo', key: 'recordNo', width: 150 }, |
| | | { title: '培训类型', dataIndex: 'trainType', key: 'trainType', width: 110 }, |
| | | { title: '培训内容', dataIndex: 'trainContent', key: 'trainContent', ellipsis: true }, |
| | | { title: '培训方式', dataIndex: 'trainMode', key: 'trainMode', width: 110 }, |
| | | { title: '培训师', dataIndex: 'trainerName', key: 'trainerName', width: 100 }, |
| | | { title: '考核方式', dataIndex: 'evalMode', key: 'evalMode', width: 110 }, |
| | | { title: '是否合格', dataIndex: 'passFlag', key: 'passFlag', width: 100, align: 'center' }, |
| | | { title: '培训开始时间', dataIndex: 'trainStart', key: 'trainStart', width: 160 }, |
| | | { title: '培训结束时间', dataIndex: 'trainEnd', key: 'trainEnd', width: 160 }, |
| | | ]; |
| | | |
| | | onMounted(() => { |
| | | 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(), |
| | | }); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载培训档案失败'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-archive-page"> |
| | | <div class="jnpf-content-wrapper-center tms-archive-center"> |
| | | <div class="jnpf-content-wrapper-content tms-archive-wrap"> |
| | | <a-spin :spinning="loading" class="archive-spin"> |
| | | <div v-if="detail" class="archive-inner"> |
| | | <section class="archive-block"> |
| | | <div class="section-title">个人培训档案</div> |
| | | <ADescriptions |
| | | bordered |
| | | :column="{ xs: 1, sm: 2, md: 3 }" |
| | | size="middle" |
| | | class="archive-desc" |
| | | > |
| | | <ADescriptionsItem label="档案编号">{{ detail.profile.archiveNo }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="建档时间">{{ detail.profile.archiveDate }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="员工姓名">{{ detail.profile.userName }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="所属部门">{{ detail.profile.deptName || '无' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="主要岗位">{{ detail.profile.mainPostName || '无' }}</ADescriptionsItem> |
| | | <ADescriptionsItem label="兼职岗位">{{ detail.profile.partPostName || '无' }}</ADescriptionsItem> |
| | | </ADescriptions> |
| | | </section> |
| | | |
| | | <section class="archive-block"> |
| | | <div class="section-title">工作履历</div> |
| | | <ATable |
| | | size="middle" |
| | | bordered |
| | | row-key="id" |
| | | :columns="workColumns" |
| | | :data-source="detail.workList" |
| | | :pagination="false" |
| | | :locale="{ emptyText: '暂无工作履历' }" |
| | | /> |
| | | </section> |
| | | |
| | | <section class="archive-block archive-block-grow"> |
| | | <div class="section-title"> |
| | | 培训目录 |
| | | <span class="section-extra">共 {{ detail.catalogList.length }} 条</span> |
| | | </div> |
| | | <div class="catalog-table-wrap"> |
| | | <ATable |
| | | size="middle" |
| | | bordered |
| | | row-key="id" |
| | | :columns="catalogColumns" |
| | | :data-source="detail.catalogList" |
| | | :pagination="false" |
| | | :scroll="{ x: 1280 }" |
| | | :locale="{ emptyText: '暂无培训记录' }" |
| | | > |
| | | <template #bodyCell="{ column, record }"> |
| | | <template v-if="column.key === 'passFlag'"> |
| | | <span v-if="record.passFlag === '1'" class="pass-yes">合格</span> |
| | | <span v-else-if="record.passFlag === '0'" class="pass-no">不合格</span> |
| | | <span v-else>-</span> |
| | | </template> |
| | | </template> |
| | | </ATable> |
| | | </div> |
| | | </section> |
| | | </div> |
| | | </a-spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-archive-page { |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-archive-center { |
| | | height: 100% !important; |
| | | min-height: 0 !important; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .tms-archive-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; |
| | | } |
| | | |
| | | .archive-spin { |
| | | display: block; |
| | | flex: 1; |
| | | min-height: 100%; |
| | | width: 100%; |
| | | } |
| | | |
| | | .archive-spin :deep(.ant-spin-container) { |
| | | min-height: 100%; |
| | | width: 100%; |
| | | } |
| | | |
| | | .archive-inner { |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 100%; |
| | | width: 100%; |
| | | box-sizing: border-box; |
| | | padding: 16px 20px 20px; |
| | | gap: 16px; |
| | | } |
| | | |
| | | .archive-block { |
| | | flex-shrink: 0; |
| | | width: 100%; |
| | | } |
| | | |
| | | .archive-block-grow { |
| | | flex: 1 1 auto; |
| | | width: 100%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .section-title { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | margin-bottom: 10px; |
| | | padding-left: 10px; |
| | | border-left: 3px solid var(--primary-color, #1890ff); |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .section-extra { |
| | | font-size: 12px; |
| | | font-weight: 400; |
| | | color: rgba(0, 0, 0, 0.45); |
| | | } |
| | | |
| | | .archive-desc { |
| | | width: 100%; |
| | | } |
| | | |
| | | .archive-desc :deep(.ant-descriptions-view) { |
| | | width: 100%; |
| | | table-layout: fixed; |
| | | } |
| | | |
| | | .archive-desc :deep(.ant-descriptions-item-label) { |
| | | width: 110px; |
| | | background: #fafafa; |
| | | color: rgba(0, 0, 0, 0.65); |
| | | } |
| | | |
| | | .archive-desc :deep(.ant-descriptions-item-content) { |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .catalog-table-wrap { |
| | | width: 100%; |
| | | flex: 1; |
| | | } |
| | | |
| | | .catalog-table-wrap :deep(.ant-table) { |
| | | width: 100%; |
| | | } |
| | | |
| | | .pass-yes { |
| | | color: #52c41a; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .pass-no { |
| | | color: #ff4d4f; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | :deep(.ant-table-thead > tr > th) { |
| | | background: #fafafa !important; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | :deep(.ant-table-wrapper) { |
| | | width: 100%; |
| | | } |
| | | </style> |
| New file |
| | |
| | | import type { TrainArchiveDetail } from './types'; |
| | | |
| | | function delay<T>(data: T, ms = 250): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | /** |
| | | * 按当前登录用户生成个人培训档案 mock。 |
| | | * 姓名/部门优先取登录用户信息。 |
| | | */ |
| | | export function mockGetMyTrainArchive(user?: { |
| | | userId?: string; |
| | | userName?: string; |
| | | userAccount?: string; |
| | | organizeName?: string; |
| | | positionName?: string; |
| | | }): Promise<TrainArchiveDetail> { |
| | | const userId = user?.userId || 'current'; |
| | | const userName = user?.userName || user?.userAccount || '当前用户'; |
| | | const deptName = user?.organizeName || '测试组'; |
| | | const mainPost = user?.positionName || '无'; |
| | | |
| | | // 用 userId 生成稳定档案号,便于同一账号每次一致 |
| | | const suffix = String(userId).replace(/\D/g, '').slice(-6) || '0184'; |
| | | const archiveNo = `AR2018${suffix.padStart(4, '0').slice(-4)}`; |
| | | |
| | | return delay({ |
| | | profile: { |
| | | id: `pf_${userId}`, |
| | | archiveNo, |
| | | archiveDate: '2018-12-24', |
| | | userId, |
| | | userName, |
| | | deptName, |
| | | mainPostName: mainPost || '无', |
| | | partPostName: '无', |
| | | }, |
| | | workList: [], |
| | | catalogList: [ |
| | | { |
| | | id: 'c1', |
| | | recordNo: 'TT2018120019', |
| | | trainType: '临时培训', |
| | | trainContent: '发布任务测试', |
| | | trainMode: '在线学习', |
| | | trainerName: userName, |
| | | evalMode: '无需考核', |
| | | passFlag: '1', |
| | | trainStart: '2018-12-26 13:00', |
| | | trainEnd: '2018-12-26 13:30', |
| | | }, |
| | | { |
| | | id: 'c2', |
| | | recordNo: 'TT2019010101', |
| | | trainType: '临时培训', |
| | | trainContent: '熟悉玻思韬', |
| | | trainMode: '在线学习', |
| | | trainerName: '王代丰', |
| | | evalMode: '无需考核', |
| | | passFlag: '1', |
| | | trainStart: '2019-01-18 08:30', |
| | | trainEnd: '2019-01-18 09:00', |
| | | }, |
| | | { |
| | | id: 'c3', |
| | | recordNo: 'TT2026090101', |
| | | trainType: '年度培训', |
| | | trainContent: '2026-药物警戒年度培训', |
| | | trainMode: '在线学习', |
| | | trainerName: '潘志通', |
| | | evalMode: '在线考试', |
| | | passFlag: '1', |
| | | trainStart: '2026-09-01 09:00', |
| | | trainEnd: '2026-09-01 17:00', |
| | | }, |
| | | { |
| | | id: 'c4', |
| | | recordNo: 'TT2025120575', |
| | | trainType: '临时培训', |
| | | trainContent: '再确认测试', |
| | | trainMode: '课堂教学', |
| | | trainerName: 'mkj', |
| | | evalMode: '在线考试', |
| | | passFlag: '0', |
| | | trainStart: '2025-12-22 09:00', |
| | | trainEnd: '2025-12-22 09:30', |
| | | }, |
| | | ], |
| | | }); |
| | | } |
| New file |
| | |
| | | /** 个人培训档案-基本信息 */ |
| | | export interface TrainArchiveProfile { |
| | | id: string; |
| | | archiveNo: string; |
| | | archiveDate: string; |
| | | userId: string; |
| | | userName: string; |
| | | deptName: string; |
| | | mainPostName: string; |
| | | partPostName: string; |
| | | } |
| | | |
| | | /** 工作履历 */ |
| | | export interface TrainArchiveWorkItem { |
| | | id: string; |
| | | hireDate?: string; |
| | | leaveDate?: string; |
| | | companyName?: string; |
| | | postName?: string; |
| | | } |
| | | |
| | | /** 培训目录 */ |
| | | export interface TrainArchiveCatalogItem { |
| | | id: string; |
| | | recordNo: string; |
| | | trainType: string; |
| | | trainContent: string; |
| | | trainMode: string; |
| | | trainerName: string; |
| | | evalMode: string; |
| | | passFlag: '0' | '1' | ''; |
| | | trainStart?: string; |
| | | trainEnd?: string; |
| | | } |
| | | |
| | | export interface TrainArchiveDetail { |
| | | profile: TrainArchiveProfile; |
| | | workList: TrainArchiveWorkItem[]; |
| | | catalogList: TrainArchiveCatalogItem[]; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, reactive, toRefs, unref } from 'vue'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicForm, useForm } from '@jnpf/ui/form'; |
| | | 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'; |
| | | |
| | | defineOptions({ name: 'TmsTrainModeForm' }); |
| | | |
| | | const emit = defineEmits(['register', 'reload']); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const state = reactive({ id: '' }); |
| | | const { id } = toRefs(state); |
| | | |
| | | const getTitle = computed(() => (unref(id) ? '编辑培训方式' : '新增培训方式')); |
| | | |
| | | const [registerForm, { setFieldsValue, resetFields, validate, updateSchema }] = useForm({ |
| | | labelWidth: 120, |
| | | schemas: [ |
| | | { |
| | | field: 'modeCode', |
| | | label: '方式编码', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '如 onsite / practice / online', maxlength: 50 }, |
| | | rules: [{ required: true, message: '必填', trigger: 'blur' }], |
| | | }, |
| | | { |
| | | field: 'modeName', |
| | | label: '方式名称', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入方式名称', maxlength: 100 }, |
| | | rules: [{ required: true, message: '必填', trigger: 'blur' }], |
| | | }, |
| | | { |
| | | field: 'signRule', |
| | | label: '签到规则', |
| | | component: 'Select', |
| | | defaultValue: 'both', |
| | | componentProps: { placeholder: '请选择', options: SIGN_RULE_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'allowGuestSign', |
| | | label: '允许名单外签到', |
| | | component: 'Select', |
| | | defaultValue: '0', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'sameDayRequired', |
| | | label: '起止须同一天', |
| | | component: 'Select', |
| | | defaultValue: '0', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'placeRequired', |
| | | label: '地点必填', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'sortNo', |
| | | label: '排序', |
| | | component: 'InputNumber', |
| | | defaultValue: 0, |
| | | componentProps: { min: 0, max: 999999, style: { width: '100%' } }, |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | label: '启用状态', |
| | | component: 'Select', |
| | | defaultValue: '1', |
| | | componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS }, |
| | | rules: [{ required: true, message: '必填', trigger: 'change' }], |
| | | }, |
| | | { |
| | | field: 'remark', |
| | | label: '备注', |
| | | component: 'Textarea', |
| | | componentProps: { placeholder: '请输入备注', rows: 3, maxlength: 500 }, |
| | | }, |
| | | ], |
| | | }); |
| | | |
| | | const [registerModal, { closeModal, changeLoading, changeOkLoading }] = useModalInner(init); |
| | | |
| | | async function init(data: { id?: string }) { |
| | | changeLoading(true); |
| | | changeOkLoading(false); |
| | | resetFields(); |
| | | state.id = data?.id || ''; |
| | | updateSchema({ |
| | | field: 'modeCode', |
| | | componentProps: { |
| | | placeholder: '如 onsite / practice / online', |
| | | maxlength: 50, |
| | | disabled: !!state.id, |
| | | }, |
| | | }); |
| | | try { |
| | | if (state.id) { |
| | | const info = await getTrainModeInfo(state.id); |
| | | setFieldsValue(info); |
| | | } |
| | | } finally { |
| | | changeLoading(false); |
| | | } |
| | | } |
| | | |
| | | async function handleSubmit() { |
| | | const values = await validate(); |
| | | if (!values) return; |
| | | changeOkLoading(true); |
| | | try { |
| | | if (state.id) { |
| | | await updateTrainMode({ ...values, id: state.id }); |
| | | } else { |
| | | await createTrainMode(values); |
| | | } |
| | | createMessage.success('保存成功'); |
| | | closeModal(); |
| | | emit('reload'); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '保存失败'); |
| | | } finally { |
| | | changeOkLoading(false); |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <BasicModal v-bind="$attrs" :title="getTitle" @register="registerModal" @ok="handleSubmit"> |
| | | <BasicForm @register="registerForm" /> |
| | | </BasicModal> |
| | | </template> |
| New file |
| | |
| | | export const YES_NO_OPTIONS = [ |
| | | { id: '1', fullName: '是' }, |
| | | { id: '0', fullName: '否' }, |
| | | ]; |
| | | |
| | | export const ENABLE_OPTIONS = [ |
| | | { id: '1', fullName: '启用' }, |
| | | { id: '0', fullName: '停用' }, |
| | | ]; |
| | | |
| | | export const SIGN_RULE_OPTIONS = [ |
| | | { id: 'scan', fullName: '仅扫码' }, |
| | | { id: 'both', fullName: '扫码+在线' }, |
| | | ]; |
| | | |
| | | export function labelOfYesNo(v?: string) { |
| | | return YES_NO_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| | | |
| | | export function labelOfSignRule(v?: string) { |
| | | return SIGN_RULE_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| | | |
| | | export function labelOfEnabled(v?: string) { |
| | | return ENABLE_OPTIONS.find((x) => x.id === v)?.fullName || v || '-'; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { TrainModeItem } from './types'; |
| | | |
| | | 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 Form from './Form.vue'; |
| | | import { |
| | | ENABLE_OPTIONS, |
| | | labelOfEnabled, |
| | | labelOfSignRule, |
| | | labelOfYesNo, |
| | | } from './constants'; |
| | | |
| | | defineOptions({ name: 'TmsTrainMode' }); |
| | | |
| | | const { createMessage } = useMessage(); |
| | | const [registerForm, { openModal: openFormModal }] = useModal(); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '方式编码', dataIndex: 'modeCode', width: 120 }, |
| | | { title: '方式名称', dataIndex: 'modeName', width: 140 }, |
| | | { |
| | | title: '签到规则', |
| | | dataIndex: 'signRule', |
| | | width: 120, |
| | | customRender: ({ record }) => labelOfSignRule((record as TrainModeItem).signRule), |
| | | }, |
| | | { |
| | | title: '允许名单外签到', |
| | | dataIndex: 'allowGuestSign', |
| | | width: 130, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as TrainModeItem).allowGuestSign), |
| | | }, |
| | | { |
| | | title: '起止须同一天', |
| | | dataIndex: 'sameDayRequired', |
| | | width: 120, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as TrainModeItem).sameDayRequired), |
| | | }, |
| | | { |
| | | title: '地点必填', |
| | | dataIndex: 'placeRequired', |
| | | width: 100, |
| | | align: 'center', |
| | | customRender: ({ record }) => labelOfYesNo((record as TrainModeItem).placeRequired), |
| | | }, |
| | | { title: '排序', dataIndex: 'sortNo', width: 80, align: 'center' }, |
| | | { |
| | | title: '状态', |
| | | dataIndex: 'enabled', |
| | | width: 90, |
| | | align: 'center', |
| | | slots: { default: 'enabled' }, |
| | | }, |
| | | { title: '备注', dataIndex: 'remark', minWidth: 180 }, |
| | | ]; |
| | | |
| | | const [registerTable, { reload }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | rowKey: 'id', |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | schemas: [ |
| | | { |
| | | field: 'keyword', |
| | | label: '关键词', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '编码/名称', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'enabled', |
| | | label: '状态', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请选择', |
| | | options: ENABLE_OPTIONS, |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 180, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getTrainModeList(params) }; |
| | | } |
| | | |
| | | function handleAdd() { |
| | | openFormModal(true, {}); |
| | | } |
| | | |
| | | function handleEdit(record: TrainModeItem) { |
| | | openFormModal(true, { id: record.id }); |
| | | } |
| | | |
| | | async function handleToggleEnabled(record: TrainModeItem) { |
| | | const next = record.enabled === '1' ? '0' : '1'; |
| | | const action = next === '1' ? '启用' : '停用'; |
| | | try { |
| | | await setTrainModeEnabled(record.id, next); |
| | | createMessage.success(`已${action}`); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || `${action}失败`); |
| | | } |
| | | } |
| | | |
| | | async function handleDelete(record: TrainModeItem) { |
| | | try { |
| | | await deleteTrainMode(record.id); |
| | | createMessage.success('删除成功'); |
| | | reload(); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '删除失败'); |
| | | } |
| | | } |
| | | |
| | | function getTableActions(record: TrainModeItem): ActionItem[] { |
| | | const enableLabel = record.enabled === '1' ? '停用' : '启用'; |
| | | return [ |
| | | { label: '编辑', onClick: handleEdit.bind(null, record) }, |
| | | { |
| | | label: enableLabel, |
| | | modelConfirm: { |
| | | content: `确定${enableLabel}培训方式「${record.modeName}」吗?`, |
| | | onOk: handleToggleEnabled.bind(null, record), |
| | | }, |
| | | }, |
| | | { |
| | | label: '删除', |
| | | color: 'error', |
| | | modelConfirm: { |
| | | content: `确定删除培训方式「${record.modeName}」吗?`, |
| | | onOk: handleDelete.bind(null, record), |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleAdd">新增</a-button> |
| | | </template> |
| | | <template #enabled="{ record }"> |
| | | <a-tag :color="record.enabled === '1' ? 'success' : 'default'"> |
| | | {{ labelOfEnabled(record.enabled) }} |
| | | </a-tag> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | <Form @register="registerForm" @reload="reload" /> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | import type { TrainModeItem, TrainModePageQuery } from './types'; |
| | | |
| | | function delay<T>(data: T, ms = 180): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | const store: TrainModeItem[] = [ |
| | | { |
| | | id: 'tms_mode_onsite', |
| | | modeCode: 'onsite', |
| | | modeName: '集中授课', |
| | | signRule: 'scan', |
| | | allowGuestSign: '1', |
| | | sameDayRequired: '1', |
| | | placeRequired: '1', |
| | | sortNo: 1, |
| | | enabled: '1', |
| | | remark: '仅扫码;允许名单外签到;起止同一天;地点必填', |
| | | }, |
| | | { |
| | | id: 'tms_mode_practice', |
| | | modeCode: 'practice', |
| | | modeName: '操作授课', |
| | | signRule: 'scan', |
| | | allowGuestSign: '0', |
| | | sameDayRequired: '1', |
| | | placeRequired: '1', |
| | | sortNo: 2, |
| | | enabled: '1', |
| | | remark: '仅扫码;不允许名单外;起止同一天;地点必填', |
| | | }, |
| | | { |
| | | id: 'tms_mode_online', |
| | | modeCode: 'online', |
| | | modeName: '在线学习', |
| | | signRule: 'both', |
| | | allowGuestSign: '0', |
| | | sameDayRequired: '0', |
| | | placeRequired: '0', |
| | | sortNo: 3, |
| | | enabled: '1', |
| | | remark: '扫码+在线;地点非必填', |
| | | }, |
| | | ]; |
| | | |
| | | export function mockQueryTrainModes(params: TrainModePageQuery = {}) { |
| | | const { currentPage = 1, pageSize = 20, keyword = '', enabled } = params; |
| | | let list = [...store]; |
| | | if (enabled) list = list.filter((x) => x.enabled === enabled); |
| | | if (keyword) { |
| | | const k = keyword.trim().toLowerCase(); |
| | | list = list.filter( |
| | | (x) => x.modeCode.toLowerCase().includes(k) || x.modeName.toLowerCase().includes(k), |
| | | ); |
| | | } |
| | | list.sort((a, b) => (a.sortNo || 0) - (b.sortNo || 0)); |
| | | const start = (currentPage - 1) * pageSize; |
| | | return delay({ |
| | | list: list.slice(start, start + pageSize), |
| | | pagination: { total: list.length, currentPage, pageSize }, |
| | | }); |
| | | } |
| | | |
| | | export async function mockGetTrainMode(id: string) { |
| | | await delay(null); |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) throw new Error('培训方式不存在'); |
| | | return { ...row }; |
| | | } |
| | | |
| | | export async function mockSaveTrainMode(data: Partial<TrainModeItem> & { id?: string }) { |
| | | await delay(null); |
| | | if (data.id) { |
| | | const row = store.find((x) => x.id === data.id); |
| | | if (!row) throw new Error('培训方式不存在'); |
| | | if (data.modeCode && data.modeCode !== row.modeCode) { |
| | | if (store.some((x) => x.modeCode === data.modeCode && x.id !== data.id)) { |
| | | throw new Error('方式编码已存在'); |
| | | } |
| | | } |
| | | Object.assign(row, data); |
| | | return row; |
| | | } |
| | | if (!data.modeCode) throw new Error('请填写方式编码'); |
| | | if (store.some((x) => x.modeCode === data.modeCode)) throw new Error('方式编码已存在'); |
| | | const row: TrainModeItem = { |
| | | id: `tm_${Date.now()}`, |
| | | modeCode: data.modeCode, |
| | | modeName: data.modeName || data.modeCode, |
| | | signRule: (data.signRule as any) || 'both', |
| | | allowGuestSign: (data.allowGuestSign as any) || '0', |
| | | sameDayRequired: (data.sameDayRequired as any) || '0', |
| | | placeRequired: (data.placeRequired as any) || '0', |
| | | sortNo: data.sortNo ?? store.length + 1, |
| | | enabled: (data.enabled as any) || '1', |
| | | remark: data.remark || '', |
| | | }; |
| | | store.push(row); |
| | | return row; |
| | | } |
| | | |
| | | export async function mockSetTrainModeEnabled(id: string, enabled: '0' | '1') { |
| | | await delay(null); |
| | | const row = store.find((x) => x.id === id); |
| | | if (!row) throw new Error('培训方式不存在'); |
| | | row.enabled = enabled; |
| | | return row; |
| | | } |
| | | |
| | | export async function mockDeleteTrainMode(id: string) { |
| | | await delay(null); |
| | | const idx = store.findIndex((x) => x.id === id); |
| | | if (idx < 0) throw new Error('培训方式不存在'); |
| | | store.splice(idx, 1); |
| | | return true; |
| | | } |
| New file |
| | |
| | | /** 培训方式配置 */ |
| | | export interface TrainModeItem { |
| | | id: string; |
| | | modeCode: string; |
| | | modeName: string; |
| | | /** scan仅扫码 / both扫码+在线 */ |
| | | signRule: 'scan' | 'both'; |
| | | allowGuestSign: '0' | '1'; |
| | | sameDayRequired: '0' | '1'; |
| | | placeRequired: '0' | '1'; |
| | | sortNo?: number; |
| | | enabled: '0' | '1'; |
| | | remark?: string; |
| | | } |
| | | |
| | | export interface TrainModePageQuery { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | keyword?: string; |
| | | enabled?: string; |
| | | } |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { TrainRecordCatalogDetail, TrainRecordCatalogItem } from './types'; |
| | | |
| | | import { computed, 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 { getTrainRecordCatalog } from '#/api/x/tms/trainRecord'; |
| | | |
| | | defineOptions({ name: 'TmsTrainRecordCatalog' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const loading = ref(false); |
| | | const detail = ref<TrainRecordCatalogDetail | null>(null); |
| | | /** jnpf-date-range 值为时间戳数组 */ |
| | | const dateRange = ref<number[] | undefined>(undefined); |
| | | |
| | | const queryDates = computed(() => { |
| | | const range = dateRange.value; |
| | | if (!range || range.length < 2) return { startDate: undefined, endDate: undefined }; |
| | | return { |
| | | startDate: dayjs(range[0]).format('YYYY-MM-DD'), |
| | | endDate: dayjs(range[1]).format('YYYY-MM-DD'), |
| | | }; |
| | | }); |
| | | |
| | | const columns = [ |
| | | { title: '序号', key: 'index', width: 70, align: 'center', customRender: ({ index }: any) => index + 1 }, |
| | | { title: '培训记录编号', dataIndex: 'recordNo', key: 'recordNo', width: 150 }, |
| | | { title: '培训类型', dataIndex: 'trainType', key: 'trainType', width: 110 }, |
| | | { title: '培训内容', dataIndex: 'trainContent', key: 'trainContent', ellipsis: true }, |
| | | { title: '培训方式', dataIndex: 'trainMode', key: 'trainMode', width: 110 }, |
| | | { title: '培训师', dataIndex: 'trainerName', key: 'trainerName', width: 120 }, |
| | | { title: '考核方式', dataIndex: 'evalMode', key: 'evalMode', width: 110 }, |
| | | { title: '培训结果', dataIndex: 'trainResult', key: 'trainResult', width: 100, align: 'center' }, |
| | | { title: '是否合格', dataIndex: 'passFlag', key: 'passFlag', width: 100, align: 'center' }, |
| | | { title: '具体培训时间', dataIndex: 'trainDate', key: 'trainDate', width: 130 }, |
| | | ]; |
| | | |
| | | onMounted(() => { |
| | | loadData(); |
| | | }); |
| | | |
| | | async function loadData() { |
| | | const id = String(route.params.id || ''); |
| | | if (!id) { |
| | | router.replace('/tms/trainRecord'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | const { startDate, endDate } = queryDates.value; |
| | | detail.value = await getTrainRecordCatalog({ |
| | | recordId: id, |
| | | startDate, |
| | | endDate, |
| | | }); |
| | | } catch (e: any) { |
| | | createMessage.error(e?.message || '加载培训目录失败'); |
| | | router.replace('/tms/trainRecord'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function handleSearch() { |
| | | loadData(); |
| | | } |
| | | |
| | | function handleReset() { |
| | | dateRange.value = undefined; |
| | | loadData(); |
| | | } |
| | | |
| | | function handleExport() { |
| | | const list = detail.value?.list || []; |
| | | if (!list.length) { |
| | | createMessage.warning('暂无数据可导出'); |
| | | return; |
| | | } |
| | | createMessage.success(`已准备导出 ${list.length} 条(导出接口联调后生效)`); |
| | | } |
| | | |
| | | function goBack() { |
| | | router.push('/tms/trainRecord'); |
| | | } |
| | | |
| | | /** 弹出层挂到 body,避免被 jnpf-content-wrapper overflow:hidden 裁切 */ |
| | | function popupContainer() { |
| | | return document.body; |
| | | } |
| | | |
| | | function resultText(row: TrainRecordCatalogItem) { |
| | | if (row.trainResult === '' || row.trainResult === undefined || row.trainResult === null) return ''; |
| | | return String(row.trainResult); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper tms-record-catalog-page"> |
| | | <div class="jnpf-content-wrapper-center tms-record-catalog-center"> |
| | | <div class="jnpf-content-wrapper-content tms-record-catalog-wrap"> |
| | | <a-spin :spinning="loading" class="catalog-spin"> |
| | | <div class="catalog-inner"> |
| | | <div class="catalog-toolbar"> |
| | | <div class="catalog-filter"> |
| | | <span class="filter-label">查询日期</span> |
| | | <jnpf-date-range |
| | | v-model:value="dateRange" |
| | | allow-clear |
| | | format="YYYY-MM-DD" |
| | | class="date-range" |
| | | :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> |
| | | </div> |
| | | <a-space> |
| | | <a-button @click="handleExport">导出</a-button> |
| | | <a-button @click="goBack">关闭</a-button> |
| | | </a-space> |
| | | </div> |
| | | |
| | | <div class="catalog-heading"> |
| | | <div class="user-name">姓名:{{ detail?.userName || '-' }}</div> |
| | | <div class="catalog-title">培训目录</div> |
| | | </div> |
| | | |
| | | <div class="catalog-table-wrap"> |
| | | <ATable |
| | | size="middle" |
| | | bordered |
| | | row-key="id" |
| | | :columns="columns" |
| | | :data-source="detail?.list || []" |
| | | :pagination="false" |
| | | :scroll="{ x: 1200 }" |
| | | :locale="{ emptyText: '暂无培训记录' }" |
| | | > |
| | | <template #bodyCell="{ column, record }"> |
| | | <template v-if="column.key === 'trainResult'"> |
| | | {{ resultText(record) }} |
| | | </template> |
| | | <template v-else-if="column.key === 'passFlag'"> |
| | | <span v-if="record.passFlag === '1'" class="pass-yes">合格</span> |
| | | <span v-else-if="record.passFlag === '0'" class="pass-no">不合格</span> |
| | | <span v-else>-</span> |
| | | </template> |
| | | </template> |
| | | </ATable> |
| | | </div> |
| | | </div> |
| | | </a-spin> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tms-record-catalog-page { |
| | | height: 100%; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .tms-record-catalog-center { |
| | | height: 100% !important; |
| | | min-height: 0 !important; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .tms-record-catalog-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; |
| | | } |
| | | |
| | | .catalog-spin { |
| | | display: block; |
| | | min-height: 100%; |
| | | width: 100%; |
| | | } |
| | | |
| | | .catalog-spin :deep(.ant-spin-container) { |
| | | min-height: 100%; |
| | | width: 100%; |
| | | } |
| | | |
| | | .catalog-inner { |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 100%; |
| | | width: 100%; |
| | | box-sizing: border-box; |
| | | padding: 16px 20px 20px; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .catalog-toolbar { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | flex-wrap: wrap; |
| | | gap: 12px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .catalog-filter { |
| | | display: flex; |
| | | align-items: center; |
| | | flex-wrap: wrap; |
| | | gap: 4px; |
| | | flex: 1; |
| | | padding: 10px 12px; |
| | | background: #fafafa; |
| | | border-radius: 6px; |
| | | } |
| | | |
| | | .filter-label { |
| | | margin-right: 8px; |
| | | color: rgba(0, 0, 0, 0.65); |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .date-range { |
| | | width: 280px; |
| | | } |
| | | |
| | | .catalog-heading { |
| | | text-align: center; |
| | | flex-shrink: 0; |
| | | padding: 8px 0 4px; |
| | | } |
| | | |
| | | .user-name { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | margin-bottom: 6px; |
| | | } |
| | | |
| | | .catalog-title { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .catalog-table-wrap { |
| | | flex: 1; |
| | | width: 100%; |
| | | } |
| | | |
| | | .catalog-table-wrap :deep(.ant-table-wrapper) { |
| | | width: 100%; |
| | | } |
| | | |
| | | .catalog-table-wrap :deep(.ant-table-thead > tr > th) { |
| | | background: #fafafa !important; |
| | | font-weight: 600; |
| | | text-align: center; |
| | | } |
| | | |
| | | .pass-yes { |
| | | color: #52c41a; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .pass-no { |
| | | color: #ff4d4f; |
| | | font-weight: 500; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <script lang="ts" setup> |
| | | import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import type { TrainRecordListItem } from './types'; |
| | | |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { useMessage } from '@jnpf/hooks'; |
| | | import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable'; |
| | | |
| | | import { getTrainRecordList } from '#/api/x/tms/trainRecord'; |
| | | |
| | | defineOptions({ name: 'TmsTrainRecord' }); |
| | | |
| | | const router = useRouter(); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | const columns: BasicColumn[] = [ |
| | | { title: '档案编号', dataIndex: 'archiveNo', width: 130 }, |
| | | { title: '进岗时间', dataIndex: 'postDate', width: 120 }, |
| | | { title: '姓名', dataIndex: 'userName', width: 100 }, |
| | | { title: '部门', dataIndex: 'deptName', minWidth: 120 }, |
| | | { title: '岗位', dataIndex: 'postName', width: 120 }, |
| | | { title: '兼职岗位', dataIndex: 'partPostName', width: 120 }, |
| | | { title: '专业', dataIndex: 'major', width: 100 }, |
| | | { title: '学历', dataIndex: 'education', width: 90 }, |
| | | { title: '技术职称', dataIndex: 'techTitle', width: 110 }, |
| | | { title: '入职时间', dataIndex: 'hireDate', width: 120 }, |
| | | { title: '离职日期', dataIndex: 'leaveDate', width: 120 }, |
| | | ]; |
| | | |
| | | const [registerTable, { getSelectRows }] = useVxeTable({ |
| | | api: fetchList, |
| | | columns, |
| | | immediate: true, |
| | | rowKey: 'id', |
| | | rowSelection: { type: 'checkbox' }, |
| | | useSearchForm: true, |
| | | formConfig: { |
| | | baseColProps: { span: 6 }, |
| | | compact: true, |
| | | schemas: [ |
| | | { |
| | | field: 'keyword', |
| | | label: '关键词', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '姓名/部门/档案编号', submitOnPressEnter: true }, |
| | | }, |
| | | { |
| | | field: 'archiveNo', |
| | | label: '档案编号', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请输入档案编号', submitOnPressEnter: true }, |
| | | }, |
| | | ], |
| | | }, |
| | | actionColumn: { |
| | | width: 100, |
| | | title: '操作', |
| | | dataIndex: 'action', |
| | | fixed: 'right', |
| | | }, |
| | | }); |
| | | |
| | | async function fetchList(params: Record<string, any>) { |
| | | return { data: await getTrainRecordList(params) }; |
| | | } |
| | | |
| | | function handleView(record: TrainRecordListItem) { |
| | | router.push(`/tms/trainRecord/catalog/${record.id}`); |
| | | } |
| | | |
| | | function handleViewSelected() { |
| | | const rows = (getSelectRows?.() || []) as TrainRecordListItem[]; |
| | | if (!rows.length) { |
| | | createMessage.warning('请先勾选一条记录'); |
| | | return; |
| | | } |
| | | if (rows.length > 1) { |
| | | createMessage.warning('请只勾选一条记录查看'); |
| | | return; |
| | | } |
| | | handleView(rows[0]); |
| | | } |
| | | |
| | | function getTableActions(record: TrainRecordListItem): ActionItem[] { |
| | | return [{ label: '查看', onClick: handleView.bind(null, record) }]; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="jnpf-content-wrapper"> |
| | | <div class="jnpf-content-wrapper-center"> |
| | | <div class="jnpf-content-wrapper-content"> |
| | | <BasicVxeTable @register="registerTable"> |
| | | <template #tableTitle> |
| | | <a-space> |
| | | <a-button type="primary" @click="handleViewSelected">查看</a-button> |
| | | <span class="text-gray-400 text-sm">个人培训记录表,选择人员查看培训目录。</span> |
| | | </a-space> |
| | | </template> |
| | | <template #action="{ record }"> |
| | | <TableAction :actions="getTableActions(record)" /> |
| | | </template> |
| | | </BasicVxeTable> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| New file |
| | |
| | | import type { |
| | | TrainRecordCatalogDetail, |
| | | TrainRecordCatalogItem, |
| | | TrainRecordCatalogQuery, |
| | | TrainRecordListItem, |
| | | TrainRecordPageQuery, |
| | | } from './types'; |
| | | |
| | | function delay<T>(data: T, ms = 220): Promise<T> { |
| | | return new Promise((resolve) => setTimeout(() => resolve(data), ms)); |
| | | } |
| | | |
| | | const listStore: TrainRecordListItem[] = [ |
| | | { |
| | | id: 'tr1', |
| | | archiveNo: 'AR20180184', |
| | | postDate: '2018-12-24', |
| | | userName: 'mkj', |
| | | deptName: '测试组', |
| | | postName: '', |
| | | partPostName: '', |
| | | major: '', |
| | | education: '', |
| | | techTitle: '', |
| | | hireDate: '', |
| | | leaveDate: '', |
| | | }, |
| | | { |
| | | id: 'tr2', |
| | | archiveNo: 'AR20190522', |
| | | postDate: '2019-05-22', |
| | | userName: '张三', |
| | | deptName: '质量管理部', |
| | | postName: 'QA专员', |
| | | partPostName: '', |
| | | major: '药学', |
| | | education: '本科', |
| | | techTitle: '工程师', |
| | | hireDate: '2019-05-22', |
| | | leaveDate: '', |
| | | }, |
| | | { |
| | | id: 'tr3', |
| | | archiveNo: 'AR20200311', |
| | | postDate: '2020-03-11', |
| | | userName: '李四', |
| | | deptName: '分析实验室', |
| | | postName: '分析员', |
| | | partPostName: '安全员', |
| | | major: '化学', |
| | | education: '硕士', |
| | | techTitle: '助理工程师', |
| | | hireDate: '2020-03-11', |
| | | leaveDate: '', |
| | | }, |
| | | ]; |
| | | |
| | | const catalogStore: Record<string, TrainRecordCatalogItem[]> = { |
| | | tr1: [ |
| | | { |
| | | id: 'c1', |
| | | recordNo: 'TT2019040134', |
| | | trainType: '临时培训', |
| | | trainContent: '测试在线考试0415', |
| | | trainMode: '在线学习', |
| | | trainerName: 'mkj', |
| | | evalMode: '在线考试', |
| | | trainResult: 0, |
| | | passFlag: '0', |
| | | trainDate: '2019-04-15', |
| | | }, |
| | | { |
| | | id: 'c2', |
| | | recordNo: 'TT2019040135', |
| | | trainType: '临时培训', |
| | | trainContent: 'test001', |
| | | trainMode: '在线学习', |
| | | trainerName: 'LIMS系统管理员', |
| | | evalMode: '在线考试', |
| | | trainResult: 80, |
| | | passFlag: '1', |
| | | trainDate: '2019-04-15', |
| | | }, |
| | | { |
| | | id: 'c3', |
| | | recordNo: 'TT2018120019', |
| | | trainType: '临时培训', |
| | | trainContent: '发布任务测试', |
| | | trainMode: '在线学习', |
| | | trainerName: 'mkj', |
| | | evalMode: '无需考核', |
| | | trainResult: '', |
| | | passFlag: '1', |
| | | trainDate: '2018-12-26', |
| | | }, |
| | | { |
| | | id: 'c4', |
| | | recordNo: 'TT2019010101', |
| | | trainType: '临时培训', |
| | | trainContent: '熟悉玻思韬', |
| | | trainMode: '在线学习', |
| | | trainerName: '王代丰', |
| | | evalMode: '无需考核', |
| | | trainResult: '', |
| | | passFlag: '1', |
| | | trainDate: '2019-01-18', |
| | | }, |
| | | { |
| | | id: 'c5', |
| | | recordNo: 'TT2019060208', |
| | | trainType: '临时培训', |
| | | trainContent: 'BLS0004 物料发放规范 02版', |
| | | trainMode: '集中授课', |
| | | trainerName: '梁兆丰', |
| | | evalMode: '在线考试', |
| | | trainResult: 80, |
| | | passFlag: '1', |
| | | trainDate: '2019-06-20', |
| | | }, |
| | | { |
| | | id: 'c6', |
| | | recordNo: 'TT2026090101', |
| | | trainType: '年度培训', |
| | | trainContent: '2026-药物警戒年度培训', |
| | | trainMode: '在线学习', |
| | | trainerName: '潘志通', |
| | | evalMode: '在线考试', |
| | | trainResult: 86, |
| | | passFlag: '1', |
| | | trainDate: '2026-09-01', |
| | | }, |
| | | ], |
| | | tr2: [ |
| | | { |
| | | id: 'c7', |
| | | recordNo: 'TT2025120575', |
| | | trainType: '临时培训', |
| | | trainContent: '再确认测试', |
| | | trainMode: '课堂教学', |
| | | trainerName: 'mkj', |
| | | evalMode: '在线考试', |
| | | trainResult: 100, |
| | | passFlag: '1', |
| | | trainDate: '2025-12-22', |
| | | }, |
| | | ], |
| | | tr3: [ |
| | | { |
| | | id: 'c8', |
| | | recordNo: 'TT2026060155', |
| | | trainType: '年度培训', |
| | | trainContent: 'GMP基础知识培训', |
| | | trainMode: '集中授课', |
| | | trainerName: '王代丰', |
| | | evalMode: '在线考试', |
| | | trainResult: 72, |
| | | passFlag: '0', |
| | | trainDate: '2026-06-15', |
| | | }, |
| | | ], |
| | | }; |
| | | |
| | | export function mockQueryTrainRecords(params: TrainRecordPageQuery) { |
| | | let list = [...listStore]; |
| | | if (params.archiveNo && params.archiveNo !== 'null') { |
| | | const kw = params.archiveNo.trim().toLowerCase(); |
| | | list = list.filter((x) => x.archiveNo.toLowerCase().includes(kw)); |
| | | } |
| | | if (params.keyword && params.keyword !== 'null') { |
| | | const kw = params.keyword.trim().toLowerCase(); |
| | | list = list.filter( |
| | | (x) => |
| | | (x.userName || '').toLowerCase().includes(kw) || |
| | | (x.deptName || '').toLowerCase().includes(kw) || |
| | | (x.archiveNo || '').toLowerCase().includes(kw), |
| | | ); |
| | | } |
| | | const currentPage = Number(params.currentPage || 1); |
| | | const pageSize = Number(params.pageSize || 20); |
| | | const start = (currentPage - 1) * pageSize; |
| | | return delay({ |
| | | list: list.slice(start, start + pageSize), |
| | | pagination: { total: list.length, currentPage, pageSize }, |
| | | }); |
| | | } |
| | | |
| | | export function mockGetTrainRecordCatalog(query: TrainRecordCatalogQuery): Promise<TrainRecordCatalogDetail> { |
| | | const person = listStore.find((x) => x.id === query.recordId); |
| | | if (!person) return Promise.reject(new Error('培训记录不存在')); |
| | | let list = [...(catalogStore[query.recordId] || [])]; |
| | | if (query.startDate) { |
| | | list = list.filter((x) => !x.trainDate || x.trainDate >= query.startDate!); |
| | | } |
| | | if (query.endDate) { |
| | | list = list.filter((x) => !x.trainDate || x.trainDate <= query.endDate!); |
| | | } |
| | | return delay({ |
| | | id: person.id, |
| | | userName: person.userName, |
| | | archiveNo: person.archiveNo, |
| | | list, |
| | | }); |
| | | } |
| New file |
| | |
| | | /** 个人培训记录表行 */ |
| | | export interface TrainRecordListItem { |
| | | id: string; |
| | | archiveNo: string; |
| | | postDate?: string; |
| | | userName: string; |
| | | deptName?: string; |
| | | postName?: string; |
| | | partPostName?: string; |
| | | major?: string; |
| | | education?: string; |
| | | techTitle?: string; |
| | | hireDate?: string; |
| | | leaveDate?: string; |
| | | } |
| | | |
| | | export interface TrainRecordPageQuery { |
| | | currentPage?: number; |
| | | pageSize?: number; |
| | | keyword?: string; |
| | | archiveNo?: string; |
| | | } |
| | | |
| | | /** 培训目录行 */ |
| | | export interface TrainRecordCatalogItem { |
| | | id: string; |
| | | recordNo: string; |
| | | trainType: string; |
| | | trainContent: string; |
| | | trainMode: string; |
| | | trainerName: string; |
| | | evalMode: string; |
| | | /** 培训结果(成绩) */ |
| | | trainResult?: string | number; |
| | | passFlag: '0' | '1' | ''; |
| | | trainDate?: string; |
| | | } |
| | | |
| | | export interface TrainRecordCatalogQuery { |
| | | recordId: string; |
| | | startDate?: string; |
| | | endDate?: string; |
| | | } |
| | | |
| | | export interface TrainRecordCatalogDetail { |
| | | id: string; |
| | | userName: string; |
| | | archiveNo: string; |
| | | list: TrainRecordCatalogItem[]; |
| | | } |