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(/&nbsp;/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(/&nbsp;/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(/&nbsp;/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(/&nbsp;/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(/&nbsp;/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