From 82a74ba0402ab546ba524d23ce62198c4d00d2f7 Mon Sep 17 00:00:00 2001
From: liuyu <yu.liu@cqgbkj.com>
Date: 星期五, 18 九月 2026 14:32:02 +0800
Subject: [PATCH] Feat: 前端定制化开发页面代码提交
---
apps/jnpf-web-apps-main/src/api/x/tms/evalMode.ts | 43
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/Detail.vue | 135
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue | 214 +
apps/jnpf-web-apps-main/src/views/x/tms/record/index.vue | 276 +
apps/jnpf-web-apps-main/src/api/x/tms/question.ts | 55
apps/jnpf-web-apps-main/src/views/x/tms/record/Edit.vue | 179 +
apps/jnpf-web-apps-main/src/api/x/tms/examScore.ts | 27
apps/jnpf-web-apps-main/src/router/routes/modules/tmsSelfTest.ts | 11
apps/jnpf-web-apps-main/src/views/x/tms/course/index.vue | 220 +
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/types.ts | 76
apps/jnpf-web-apps-main/src/views/x/tms/course/constants.ts | 49
apps/jnpf-web-apps-main/src/api/x/tms/signMode.ts | 43
apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamScore.ts | 11
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/Form.vue | 145
apps/jnpf-web-apps-main/src/api/x/tms/course.ts | 49
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/types.ts | 63
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/types.ts | 50
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/index.vue | 123
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/mock.ts | 156
apps/jnpf-web-apps-main/src/views/x/tms/question/Form.vue | 295 +
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/constants.ts | 19
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/mock.ts | 356 ++
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Mark.vue | 286 +
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/constants.ts | 17
apps/jnpf-web-apps-main/src/views/x/tms/examScore/mock.ts | 386 ++
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/exam.vue | 184 +
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainArchive.ts | 21
apps/jnpf-web-apps-main/src/views/x/tms/record/Detail.vue | 252 +
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/mock.ts | 203 +
apps/jnpf-web-apps-main/src/router/routes/modules/tmsEvalMode.ts | 17
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/Form.vue | 123
apps/jnpf-web-apps-main/src/router/routes/modules/tmsSignMode.ts | 17
apps/jnpf-web-apps-main/src/api/x/tms/trainRecord.ts | 22
apps/jnpf-web-apps-main/src/views/x/tms/question/index.vue | 208 +
apps/jnpf-web-apps-main/src/views/x/tms/course/mock.ts | 202 +
apps/jnpf-web-apps-main/src/router/routes/modules/tmsDemo.ts | 19
apps/jnpf-web-apps-main/src/router/routes/modules/tmsRecord.ts | 21
apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamGrade.ts | 11
apps/jnpf-web-apps-main/src/views/x/tms/examScore/index.vue | 125
apps/jnpf-web-apps-main/src/views/x/tms/record/Sign.vue | 196 +
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/constants.ts | 37
apps/jnpf-web-apps-main/src/views/x/tms/examScore/Paper.vue | 193 +
apps/jnpf-web-apps-main/src/views/x/tms/record/constants.ts | 32
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainRecord.ts | 21
apps/jnpf-web-apps-main/src/views/x/tms/record/types.ts | 81
apps/jnpf-web-apps-main/src/views/x/tms/examScore/Detail.vue | 177 +
apps/jnpf-web-apps-main/src/views/x/tms/examScore/types.ts | 92
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/mock.ts | 117
apps/jnpf-web-apps-main/src/views/x/tms/signMode/types.ts | 19
apps/jnpf-web-apps-main/src/api/x/tms/trainArchive.ts | 19
apps/jnpf-web-apps-main/src/router/routes/modules/tmsCourse.ts | 17
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Session.vue | 138
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/index.vue | 180 +
apps/jnpf-web-apps-main/src/api/x/tms/record.ts | 39
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/types.ts | 21
apps/jnpf-web-apps-main/src/views/x/tms/question/types.ts | 72
apps/jnpf-web-apps-main/src/api/x/tms/examGrade.ts | 44
apps/jnpf-web-apps-main/src/views/x/tms/signMode/index.vue | 157
apps/jnpf-web-apps-main/src/views/x/tms/record/mock.ts | 407 ++
apps/jnpf-web-apps-main/src/views/x/tms/signMode/mock.ts | 103
apps/jnpf-web-apps-main/src/router/routes/basic.ts | 235 +
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/constants.ts | 26
apps/jnpf-web-apps-main/src/views/x/tms/question/components/AnswerSettings.vue | 135
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/index.vue | 109
apps/jnpf-web-apps-main/src/views/x/tms/course/Form.vue | 195 +
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/mock.ts | 116
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/index.vue | 269 +
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/types.ts | 40
apps/jnpf-web-apps-main/src/views/x/tms/signMode/Form.vue | 117
apps/jnpf-web-apps-main/src/router/routes/modules/tmsOnlineExam.ts | 11
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/constants.ts | 15
apps/jnpf-web-apps-main/src/views/x/tms/demo/index.vue | 26
apps/jnpf-web-apps-main/src/api/x/tms/selfTest.ts | 19
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/index.vue | 164 +
apps/jnpf-web-apps-main/src/views/x/tms/question/mock.ts | 148
apps/jnpf-web-apps-main/src/views/x/tms/question/parseAnswer.ts | 141
apps/jnpf-web-apps-main/src/api/x/tms/onlineExam.ts | 37
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainMode.ts | 17
apps/jnpf-web-apps-main/src/views/x/tms/question/constants.ts | 45
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/types.ts | 40
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/mock.ts | 59
apps/jnpf-web-apps-main/src/router/routes/modules/tmsQuestion.ts | 9
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/index.vue | 235 +
apps/jnpf-web-apps-main/src/api/x/tms/ping.ts | 6
apps/jnpf-web-apps-main/src/api/x/tms/trainMode.ts | 43
apps/jnpf-web-apps-main/src/views/x/tms/course/types.ts | 29
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/Catalog.vue | 282 +
apps/jnpf-web-apps-main/src/views/x/tms/signMode/constants.ts | 17
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/index.vue | 139
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/mock.ts | 90
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/types.ts | 18
91 files changed, 9,703 insertions(+), 0 deletions(-)
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/course.ts b/apps/jnpf-web-apps-main/src/api/x/tms/course.ts
new file mode 100644
index 0000000..cb540e4
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/course.ts
@@ -0,0 +1,49 @@
+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}` });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/evalMode.ts b/apps/jnpf-web-apps-main/src/api/x/tms/evalMode.ts
new file mode 100644
index 0000000..a559a54
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/evalMode.ts
@@ -0,0 +1,43 @@
+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}` });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/examGrade.ts b/apps/jnpf-web-apps-main/src/api/x/tms/examGrade.ts
new file mode 100644
index 0000000..dd555c7
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/examGrade.ts
@@ -0,0 +1,44 @@
+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 });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/examScore.ts b/apps/jnpf-web-apps-main/src/api/x/tms/examScore.ts
new file mode 100644
index 0000000..3b5a8da
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/examScore.ts
@@ -0,0 +1,27 @@
+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}` });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/onlineExam.ts b/apps/jnpf-web-apps-main/src/api/x/tms/onlineExam.ts
new file mode 100644
index 0000000..64ac990
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/onlineExam.ts
@@ -0,0 +1,37 @@
+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 });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/ping.ts b/apps/jnpf-web-apps-main/src/api/x/tms/ping.ts
new file mode 100644
index 0000000..112fb58
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/ping.ts
@@ -0,0 +1,6 @@
+import { defHttp } from '#/api/request';
+
+/** TMS 鏈嶅姟鎺㈡椿锛氱綉鍏� /api/tms/ping 鈫� jnpf-tms /ping */
+export function pingTms() {
+ return defHttp.get({ url: '/api/tms/ping' });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/question.ts b/apps/jnpf-web-apps-main/src/api/x/tms/question.ts
new file mode 100644
index 0000000..10fedd7
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/question.ts
@@ -0,0 +1,55 @@
+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銆�
+ * 姝e紡璺緞绾﹀畾锛�/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}` });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/record.ts b/apps/jnpf-web-apps-main/src/api/x/tms/record.ts
new file mode 100644
index 0000000..2398a8a
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/record.ts
@@ -0,0 +1,39 @@
+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 } });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/selfTest.ts b/apps/jnpf-web-apps-main/src/api/x/tms/selfTest.ts
new file mode 100644
index 0000000..0667daf
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/selfTest.ts
@@ -0,0 +1,19 @@
+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 });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/signMode.ts b/apps/jnpf-web-apps-main/src/api/x/tms/signMode.ts
new file mode 100644
index 0000000..5543aea
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/signMode.ts
@@ -0,0 +1,43 @@
+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}` });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/trainArchive.ts b/apps/jnpf-web-apps-main/src/api/x/tms/trainArchive.ts
new file mode 100644
index 0000000..b946e5f
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/trainArchive.ts
@@ -0,0 +1,19 @@
+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` });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/trainMode.ts b/apps/jnpf-web-apps-main/src/api/x/tms/trainMode.ts
new file mode 100644
index 0000000..0cd19ef
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/trainMode.ts
@@ -0,0 +1,43 @@
+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}` });
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/trainRecord.ts b/apps/jnpf-web-apps-main/src/api/x/tms/trainRecord.ts
new file mode 100644
index 0000000..ef11e81
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/trainRecord.ts
@@ -0,0 +1,22 @@
+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 });
+}
diff --git a/apps/jnpf-web-apps-main/src/router/routes/basic.ts b/apps/jnpf-web-apps-main/src/router/routes/basic.ts
index 2854d63..8f14ec8 100644
--- a/apps/jnpf-web-apps-main/src/router/routes/basic.ts
+++ b/apps/jnpf-web-apps-main/src/router/routes/basic.ts
@@ -101,6 +101,241 @@
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: '涓汉鍩硅妗f',
+ 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: '鍙綊妗e垪琛�',
+ hideInMenu: true,
+ ignoreAccess: true,
+ currentActiveMenu: '/tms/record',
+ },
+ },
+ {
+ path: '/tms/record/archived',
+ name: 'TmsRecordArchived',
+ component: () => import('#/views/x/tms/record/index.vue'),
+ meta: {
+ title: '宸插綊妗e垪琛�',
+ 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,
+ },
+ },
],
},
{
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsCourse.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsCourse.ts
new file mode 100644
index 0000000..6b97c22
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsCourse.ts
@@ -0,0 +1,17 @@
+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;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsDemo.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsDemo.ts
new file mode 100644
index 0000000..1909c86
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsDemo.ts
@@ -0,0 +1,19 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+/** 鏈湴鑱旇皟鍏ュ彛锛堝厤鑿滃崟锛夛紱姝e紡鐜璇峰湪鍚庡彴鑿滃崟鎸� 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;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsEvalMode.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsEvalMode.ts
new file mode 100644
index 0000000..e2942a1
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsEvalMode.ts
@@ -0,0 +1,17 @@
+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;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamGrade.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamGrade.ts
new file mode 100644
index 0000000..b62106c
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamGrade.ts
@@ -0,0 +1,11 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+/**
+ * 鑰冭瘯闃呭嵎鍙敱鍚庡彴鑿滃崟鎸傝浇锛�
+ * 璺敱鍦板潃 /tms/examGrade
+ * 椤甸潰鍦板潃 x/tms/examGrade/index
+ * 绛斿嵎鍒楄〃 / 闃呭嵎椤靛凡鎸� basicRoutes
+ */
+const tmsExamGradeRoutes: RouteRecordRaw[] = [];
+
+export default tmsExamGradeRoutes;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamScore.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamScore.ts
new file mode 100644
index 0000000..c7a282c
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamScore.ts
@@ -0,0 +1,11 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+/**
+ * 鑰冭瘯鎴愮哗鍙敱鍚庡彴鑿滃崟鎸傝浇锛�
+ * 璺敱鍦板潃 /tms/examScore
+ * 椤甸潰鍦板潃 x/tms/examScore/index
+ * 璇︽儏锛堣�冪敓璇曞嵎鍒楄〃锛�/ 鏌ョ湅璇曞嵎 宸叉寕 basicRoutes
+ */
+const tmsExamScoreRoutes: RouteRecordRaw[] = [];
+
+export default tmsExamScoreRoutes;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsOnlineExam.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsOnlineExam.ts
new file mode 100644
index 0000000..2857b97
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsOnlineExam.ts
@@ -0,0 +1,11 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+/**
+ * 鍦ㄧ嚎鑰冭瘯锛堟垜鐨勮瘯鍗凤級鍙敱鍚庡彴鑿滃崟鎸傝浇锛�
+ * 璺敱鍦板潃 /tms/onlineExam
+ * 椤甸潰鍦板潃 x/tms/onlineExam/index
+ * 绛旈椤� / 璇︽儏椤靛凡鎸� basicRoutes
+ */
+const tmsOnlineExamRoutes: RouteRecordRaw[] = [];
+
+export default tmsOnlineExamRoutes;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsQuestion.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsQuestion.ts
new file mode 100644
index 0000000..de3ab6d
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsQuestion.ts
@@ -0,0 +1,9 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+/**
+ * 鍒楄〃鍙敱鍚庡彴鑿滃崟鎸傝浇锛歱ageAddress = x/tms/question/index锛岃矾鐢� /tms/question
+ * 鍒涘缓/缂栬緫宸叉寕鍒� basicRoutes锛堜笉渚濊禆鑿滃崟锛�
+ */
+const tmsQuestionRoutes: RouteRecordRaw[] = [];
+
+export default tmsQuestionRoutes;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsRecord.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsRecord.ts
new file mode 100644
index 0000000..0a92ab7
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsRecord.ts
@@ -0,0 +1,21 @@
+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;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsSelfTest.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsSelfTest.ts
new file mode 100644
index 0000000..1f9e582
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsSelfTest.ts
@@ -0,0 +1,11 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+/**
+ * 鑷垜妫�娴嬪彲鐢卞悗鍙拌彍鍗曟寕杞斤細
+ * 璺敱鍦板潃 /tms/selfTest
+ * 椤甸潰鍦板潃 x/tms/selfTest/index
+ * 绛旈椤靛凡鎸� basicRoutes锛�/tms/selfTest/exam
+ */
+const tmsSelfTestRoutes: RouteRecordRaw[] = [];
+
+export default tmsSelfTestRoutes;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsSignMode.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsSignMode.ts
new file mode 100644
index 0000000..d5449b6
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsSignMode.ts
@@ -0,0 +1,17 @@
+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;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainArchive.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainArchive.ts
new file mode 100644
index 0000000..0247da8
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainArchive.ts
@@ -0,0 +1,21 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+/**
+ * 涓汉鍩硅妗f
+ * 鍚庡彴鑿滃崟锛氳矾鐢� /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: '涓汉鍩硅妗f',
+ hideInMenu: true,
+ ignoreAccess: true,
+ },
+ },
+];
+
+export default tmsTrainArchiveRoutes;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainMode.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainMode.ts
new file mode 100644
index 0000000..6e43d51
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainMode.ts
@@ -0,0 +1,17 @@
+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;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainRecord.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainRecord.ts
new file mode 100644
index 0000000..bc29507
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainRecord.ts
@@ -0,0 +1,21 @@
+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;
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/course/Form.vue b/apps/jnpf-web-apps-main/src/views/x/tms/course/Form.vue
new file mode 100644
index 0000000..2dc15ba
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/course/Form.vue
@@ -0,0 +1,195 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/course/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/course/constants.ts
new file mode 100644
index 0000000..0450dc5
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/course/constants.ts
@@ -0,0 +1,49 @@
+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';
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/course/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/course/index.vue
new file mode 100644
index 0000000..53d819e
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/course/index.vue
@@ -0,0 +1,220 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/course/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/course/mock.ts
new file mode 100644
index 0000000..69de8c8
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/course/mock.ts
@@ -0,0 +1,202 @@
+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鍩虹鐭ヨ瘑鍩硅锛圦C銆佸垎鏋愶級' },
+ { 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鍩虹鐭ヨ瘑鍩硅锛圦C銆佸垎鏋愶級',
+ 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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/course/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/course/types.ts
new file mode 100644
index 0000000..8323d66
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/course/types.ts
@@ -0,0 +1,29 @@
+/** 鍩硅璇剧▼ */
+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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/demo/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/demo/index.vue
new file mode 100644
index 0000000..2cd47b5
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/demo/index.vue
@@ -0,0 +1,26 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/Form.vue b/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/Form.vue
new file mode 100644
index 0000000..4250257
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/Form.vue
@@ -0,0 +1,123 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/constants.ts
new file mode 100644
index 0000000..64da652
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/constants.ts
@@ -0,0 +1,17 @@
+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 || '-';
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/index.vue
new file mode 100644
index 0000000..be2a3fc
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/index.vue
@@ -0,0 +1,164 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/mock.ts
new file mode 100644
index 0000000..115641c
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/mock.ts
@@ -0,0 +1,117 @@
+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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/types.ts
new file mode 100644
index 0000000..e78825a
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/evalMode/types.ts
@@ -0,0 +1,18 @@
+/** 鑰冩牳鏂瑰紡锛堣瘎浼版柟寮忥級閰嶇疆 */
+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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Mark.vue b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Mark.vue
new file mode 100644
index 0000000..9c30b38
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Mark.vue
@@ -0,0 +1,286 @@
+<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'">姝g‘绛旀锛歿{ it.correctAnswer || '-' }}</div>
+ <div v-if="it.analysis" class="text-gray-400">瑙f瀽锛歿{ 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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Session.vue b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Session.vue
new file mode 100644
index 0000000..09c3022
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Session.vue
@@ -0,0 +1,138 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/constants.ts
new file mode 100644
index 0000000..715308e
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/constants.ts
@@ -0,0 +1,15 @@
+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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/index.vue
new file mode 100644
index 0000000..f5b4455
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/index.vue
@@ -0,0 +1,123 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/mock.ts
new file mode 100644
index 0000000..33bd569
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/mock.ts
@@ -0,0 +1,356 @@
+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鍩虹鐭ヨ瘑鍩硅锛圦C銆佸垎鏋愶級',
+ 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: '姝g‘',
+ userAnswer: '姝g‘',
+ score: 30,
+ gotScore: 30,
+ isSubjective: '0',
+ },
+ {
+ id: 'ei4',
+ questionType: 'essay',
+ stem: '璇风畝杩板疄楠屽瀹夊叏鎿嶄綔瑕佺偣銆�',
+ userAnswer: '绌挎埓闃叉姢鐢ㄥ搧锛岄伒瀹圫OP锛屽紓甯稿強鏃朵笂鎶ャ��',
+ score: 20,
+ gotScore: undefined,
+ isSubjective: '1',
+ analysis: '闇�瑕嗙洊闃叉姢銆丼OP銆佸簲鎬ュ鐞嗙瓑瑕佺偣銆�',
+ },
+ ],
+ },
+};
+
+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: '闃呭嵎鎻愪氦鎴愬姛' });
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/types.ts
new file mode 100644
index 0000000..8890ddf
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examGrade/types.ts
@@ -0,0 +1,76 @@
+/** 闃呭嵎鍒楄〃琛岋紙鎸夊煿璁换鍔�+璇曞嵎鑱氬悎锛� */
+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 }[];
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examScore/Detail.vue b/apps/jnpf-web-apps-main/src/views/x/tms/examScore/Detail.vue
new file mode 100644
index 0000000..a3605d4
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examScore/Detail.vue
@@ -0,0 +1,177 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examScore/Paper.vue b/apps/jnpf-web-apps-main/src/views/x/tms/examScore/Paper.vue
new file mode 100644
index 0000000..655ec4f
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examScore/Paper.vue
@@ -0,0 +1,193 @@
+<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 ? '姝g‘' : '閿欒' }}
+ </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">鍙傝��/姝g‘绛旀锛歿{ 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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examScore/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/examScore/index.vue
new file mode 100644
index 0000000..341ed2c
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examScore/index.vue
@@ -0,0 +1,125 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examScore/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/examScore/mock.ts
new file mode 100644
index 0000000..2759217
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examScore/mock.ts
@@ -0,0 +1,386 @@
+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 璁板綍瑕佹眰锛孯ev 02',
+ taskNo: 'TT2020060534',
+ paperId: 'p1',
+ paperName: '(宸插け鏁�)BQA0002 璁板綍瑕佹眰锛孯ev 02',
+ paperInvalid: true,
+ participantCount: 1,
+ failCount: 0,
+ maxScore: 100,
+ minScore: 100,
+ avgScore: 100,
+ passScore: 80,
+ totalScore: 100,
+ },
+ {
+ id: 'es2',
+ taskSubject: 'BAL0003 鐣欐牱绠$悊绋嬪簭锛孯ev 03',
+ taskNo: 'TT2020060535',
+ paperId: 'p2',
+ paperName: '(宸插け鏁�)BAL0003 鐣欐牱绠$悊绋嬪簭锛孯ev 03',
+ paperInvalid: true,
+ participantCount: 1,
+ failCount: 1,
+ maxScore: 70,
+ minScore: 70,
+ avgScore: 70,
+ passScore: 80,
+ totalScore: 100,
+ },
+ {
+ id: 'es3',
+ taskSubject: 'SOP-QA-012 鍋忓樊绠$悊锛汼OP-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鍩虹鐭ヨ瘑鍩硅锛圦C銆佸垎鏋愶級',
+ 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: '姝g‘',
+ userAnswer: '姝g‘',
+ 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),
+ });
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/examScore/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/examScore/types.ts
new file mode 100644
index 0000000..f5fb012
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/examScore/types.ts
@@ -0,0 +1,92 @@
+/** 鑰冭瘯鎴愮哗姹囨�昏 */
+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';
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/Detail.vue b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/Detail.vue
new file mode 100644
index 0000000..737a0f5
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/Detail.vue
@@ -0,0 +1,135 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/constants.ts
new file mode 100644
index 0000000..f2dab78
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/constants.ts
@@ -0,0 +1,19 @@
+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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue
new file mode 100644
index 0000000..68f3126
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue
@@ -0,0 +1,214 @@
+<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) ? '鍥炵瓟姝g‘' : '鍥炵瓟閿欒' }}
+ 路 姝g‘绛旀锛�
+ {{ 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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/index.vue
new file mode 100644
index 0000000..8fe379c
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/index.vue
@@ -0,0 +1,139 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/mock.ts
new file mode 100644
index 0000000..d35d515
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/mock.ts
@@ -0,0 +1,156 @@
+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: '姝g‘', 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: '浜ゅ嵎鎴愬姛' });
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/types.ts
new file mode 100644
index 0000000..26d2778
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/types.ts
@@ -0,0 +1,63 @@
+/** 鎴戠殑璇曞嵎 / 鍦ㄧ嚎鑰冭瘯鐘舵�侊紙鍒楄〃灞曠ず锛� */
+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';
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/question/Form.vue b/apps/jnpf-web-apps-main/src/views/x/tms/question/Form.vue
new file mode 100644
index 0000000..334f2aa
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/question/Form.vue
@@ -0,0 +1,295 @@
+<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="璇曢瑙f瀽" 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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/question/components/AnswerSettings.vue b/apps/jnpf-web-apps-main/src/views/x/tms/question/components/AnswerSettings.vue
new file mode 100644
index 0000000..141e156
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/question/components/AnswerSettings.vue
@@ -0,0 +1,135 @@
+<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">姝g‘</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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/question/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/question/constants.ts
new file mode 100644
index 0000000..2b54ca1
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/question/constants.ts
@@ -0,0 +1,45 @@
+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 ?? '-';
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/question/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/question/index.vue
new file mode 100644
index 0000000..f098095
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/question/index.vue
@@ -0,0 +1,208 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/question/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/question/mock.ts
new file mode 100644
index 0000000..1472202
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/question/mock.ts
@@ -0,0 +1,148 @@
+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: '姝g‘', 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: '鍒犻櫎鎴愬姛' });
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/question/parseAnswer.ts b/apps/jnpf-web-apps-main/src/views/x/tms/question/parseAnswer.ts
new file mode 100644
index 0000000..d807da8
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/question/parseAnswer.ts
@@ -0,0 +1,141 @@
+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: '姝g‘', 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' }];
+}
+
+/** 琛ㄥ崟 鈫� 钀藉簱瀹炰綋锛堝悇棰樺瀷瑙f瀽锛� */
+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: '姝g‘', 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),
+ }));
+}
+
+/** 瀹炰綋 鈫� 琛ㄥ崟鍥炲~锛堝悇棰樺瀷瑙f瀽锛� */
+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 === '姝g‘';
+ } 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,
+ };
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/question/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/question/types.ts
new file mode 100644
index 0000000..c05e4b7
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/question/types.ts
@@ -0,0 +1,72 @@
+/** 棰樺瀷缂栫爜锛堝瓧鍏� 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' 姝g‘ / '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=姝g‘ false=閿欒 */
+ judgeAnswer?: boolean;
+ /** 闂瓟棰樺弬鑰冪瓟妗� */
+ essayAnswer?: string;
+ /** 濉┖娣锋潅鎵规敼 */
+ blankMixMode: boolean;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/record/Detail.vue b/apps/jnpf-web-apps-main/src/views/x/tms/record/Detail.vue
new file mode 100644
index 0000000..6f12ac0
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/record/Detail.vue
@@ -0,0 +1,252 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/record/Edit.vue b/apps/jnpf-web-apps-main/src/views/x/tms/record/Edit.vue
new file mode 100644
index 0000000..88f735d
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/record/Edit.vue
@@ -0,0 +1,179 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/record/Sign.vue b/apps/jnpf-web-apps-main/src/views/x/tms/record/Sign.vue
new file mode 100644
index 0000000..ad80e52
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/record/Sign.vue
@@ -0,0 +1,196 @@
+<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('宸插瓨妗h褰曚笉鍙鍒�');
+ 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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/record/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/record/constants.ts
new file mode 100644
index 0000000..536fc03
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/record/constants.ts
@@ -0,0 +1,32 @@
+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: '鍙綊妗e垪琛�',
+ archived: '宸插綊妗e垪琛�',
+};
+
+/** 鍚勫垪琛ㄩ〉璺敱 */
+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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/record/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/record/index.vue
new file mode 100644
index 0000000..cdae71f
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/record/index.vue
@@ -0,0 +1,276 @@
+<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}銆嶇鍒颁簩缁寸爜锛堟帴鍙h仈璋冨悗鐢熸晥锛塦);
+}
+
+function handleAdd() {
+ createMessage.info('鏂板鍩硅璁板綍锛堣仈璋冧换鍔″彂甯冨悗鐢辩郴缁熻嚜鍔ㄧ敓鎴愶紝鎵嬪伐鏂板寰呭悗绔帴鍙o級');
+}
+
+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} 鏉¤褰曞綊妗e悧锛焋,
+ 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')">鍙綊妗e垪琛�</a-button>
+ <a-button pre-icon="icon-ym icon-ym-extend-folder" @click="goList('archived')">宸插綊妗e垪琛�</a-button>
+ <span class="text-gray-400 text-sm">褰撳墠锛歿{ listModeTitle }}</span>
+ </a-space>
+
+ <!-- 2. 鍙綊妗e垪琛� -->
+ <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')">宸插綊妗e垪琛�</a-button>
+ <span class="text-gray-400 text-sm">褰撳墠锛歿{ listModeTitle }}</span>
+ </a-space>
+
+ <!-- 3. 宸插綊妗e垪琛� -->
+ <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')">鍙綊妗e垪琛�</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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/record/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/record/mock.ts
new file mode 100644
index 0000000..d8cafaf
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/record/mock.ts
@@ -0,0 +1,407 @@
+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: '鏈懆璐ㄩ噺涓撻瀹h疮',
+ 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: '鍒嗘瀽瀹為獙瀹ゅ矖浣嶅啀鍩硅锛圚PLC 鎿嶄綔锛�',
+ trainerName: '瀛斿痉鏁�',
+ trainType: '闆嗕腑鎺堣',
+ startTime: fmt(now - 8 * day + 14 * 60 * 60 * 1000),
+ evalMode: '鎻愰棶',
+ archiveStatus: 'ready',
+ signRate: 95,
+ passRate: 90,
+ },
+ {
+ id: 'rc3',
+ recordNo: 'TT2026090201',
+ category: '涓存椂鍩硅',
+ subject: '鍋忓樊璋冩煡娴佺▼瀹h疮',
+ trainerName: '瀛h繙杈�',
+ 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: '瀛h繙杈�', 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: '娴嬭瘯鍛楢', deptName: '娴嬭瘯缁�', signDate: fmt(now - 60 * day, true), examScore: 80, passFlag: '1' },
+ { id: 'p83', userName: '娴嬭瘯鍛楤', 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('娌℃湁鍙綊妗g殑璁板綍');
+ 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(`宸插綊妗h褰曘��${store[idx].recordNo}銆嶄笉鍙垹闄);
+ }
+ store.splice(idx, 1);
+ count += 1;
+ }
+ if (!count) throw new Error('璇烽�夋嫨瑕佸垹闄ょ殑璁板綍');
+ return count;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/record/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/record/types.ts
new file mode 100644
index 0000000..11b95dc
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/record/types.ts
@@ -0,0 +1,81 @@
+/** 涓変釜鍒楄〃椤碉細鍩硅璁板綍 / 鍙綊妗� / 宸插綊妗� */
+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[];
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/constants.ts
new file mode 100644
index 0000000..2fdeccd
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/constants.ts
@@ -0,0 +1,37 @@
+/** 鑷垜妫�娴嬶細棰樼洰鏁伴噺锛�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 ?? '-';
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/exam.vue b/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/exam.vue
new file mode 100644
index 0000000..6911352
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/exam.vue
@@ -0,0 +1,184 @@
+<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锛沵ulti 瀛� 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(`妫�娴嬪畬鎴愶紝姝g‘ ${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) ? '鍥炵瓟姝g‘' : '鍥炵瓟閿欒' }}
+ 路 姝g‘绛旀锛�
+ {{ 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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/index.vue
new file mode 100644
index 0000000..9912477
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/index.vue
@@ -0,0 +1,235 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/mock.ts
new file mode 100644
index 0000000..989e2bb
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/mock.ts
@@ -0,0 +1,59 @@
+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,
+ });
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/types.ts
new file mode 100644
index 0000000..2fce373
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/selfTest/types.ts
@@ -0,0 +1,40 @@
+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' }[];
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/signMode/Form.vue b/apps/jnpf-web-apps-main/src/views/x/tms/signMode/Form.vue
new file mode 100644
index 0000000..85d3092
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/signMode/Form.vue
@@ -0,0 +1,117 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/signMode/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/signMode/constants.ts
new file mode 100644
index 0000000..64da652
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/signMode/constants.ts
@@ -0,0 +1,17 @@
+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 || '-';
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/signMode/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/signMode/index.vue
new file mode 100644
index 0000000..b532908
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/signMode/index.vue
@@ -0,0 +1,157 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/signMode/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/signMode/mock.ts
new file mode 100644
index 0000000..705ca37
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/signMode/mock.ts
@@ -0,0 +1,103 @@
+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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/signMode/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/signMode/types.ts
new file mode 100644
index 0000000..ea80736
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/signMode/types.ts
@@ -0,0 +1,19 @@
+/** 绛惧埌鏂瑰紡閰嶇疆 */
+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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/index.vue
new file mode 100644
index 0000000..c83a3af
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/index.vue
@@ -0,0 +1,269 @@
+<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 || '鍔犺浇鍩硅妗f澶辫触');
+ } 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">涓汉鍩硅妗f</div>
+ <ADescriptions
+ bordered
+ :column="{ xs: 1, sm: 2, md: 3 }"
+ size="middle"
+ class="archive-desc"
+ >
+ <ADescriptionsItem label="妗f缂栧彿">{{ 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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/mock.ts
new file mode 100644
index 0000000..a728618
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/mock.ts
@@ -0,0 +1,90 @@
+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 鐢熸垚绋冲畾妗f鍙凤紝渚夸簬鍚屼竴璐﹀彿姣忔涓�鑷�
+ 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',
+ },
+ ],
+ });
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/types.ts
new file mode 100644
index 0000000..f75d32e
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/types.ts
@@ -0,0 +1,40 @@
+/** 涓汉鍩硅妗f-鍩烘湰淇℃伅 */
+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[];
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/Form.vue b/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/Form.vue
new file mode 100644
index 0000000..990201f
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/Form.vue
@@ -0,0 +1,145 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/constants.ts
new file mode 100644
index 0000000..889fb58
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/constants.ts
@@ -0,0 +1,26 @@
+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 || '-';
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/index.vue
new file mode 100644
index 0000000..ed76a4a
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/index.vue
@@ -0,0 +1,180 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/mock.ts
new file mode 100644
index 0000000..13e7c0b
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/mock.ts
@@ -0,0 +1,116 @@
+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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/types.ts
new file mode 100644
index 0000000..cfc14ec
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainMode/types.ts
@@ -0,0 +1,21 @@
+/** 鍩硅鏂瑰紡閰嶇疆 */
+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;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/Catalog.vue b/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/Catalog.vue
new file mode 100644
index 0000000..86d0cf8
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/Catalog.vue
@@ -0,0 +1,282 @@
+<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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/index.vue
new file mode 100644
index 0000000..06f049d
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/index.vue
@@ -0,0 +1,109 @@
+<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: '妗f缂栧彿', 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: '濮撳悕/閮ㄩ棬/妗f缂栧彿', submitOnPressEnter: true },
+ },
+ {
+ field: 'archiveNo',
+ label: '妗f缂栧彿',
+ 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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/mock.ts b/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/mock.ts
new file mode 100644
index 0000000..8e9bc5b
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/mock.ts
@@ -0,0 +1,203 @@
+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,
+ });
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/types.ts
new file mode 100644
index 0000000..6893d3b
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/types.ts
@@ -0,0 +1,50 @@
+/** 涓汉鍩硅璁板綍琛ㄨ */
+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[];
+}
--
Gitblit v1.8.0