liuyu
4 小时以前 82a74ba0402ab546ba524d23ce62198c4d00d2f7
Feat: 前端定制化开发页面代码提交
90个文件已添加
1个文件已修改
9703 ■■■■■ 已修改文件
apps/jnpf-web-apps-main/src/api/x/tms/course.ts 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/evalMode.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/examGrade.ts 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/examScore.ts 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/onlineExam.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/ping.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/question.ts 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/record.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/selfTest.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/signMode.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/trainArchive.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/trainMode.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/trainRecord.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/basic.ts 235 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsCourse.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsDemo.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsEvalMode.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamGrade.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamScore.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsOnlineExam.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsQuestion.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsRecord.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsSelfTest.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsSignMode.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainArchive.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainMode.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainRecord.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/course/Form.vue 195 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/course/constants.ts 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/course/index.vue 220 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/course/mock.ts 202 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/course/types.ts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/demo/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/Form.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/constants.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/index.vue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/mock.ts 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/types.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Mark.vue 286 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Session.vue 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/constants.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/index.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/mock.ts 356 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/types.ts 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examScore/Detail.vue 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examScore/Paper.vue 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examScore/index.vue 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examScore/mock.ts 386 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examScore/types.ts 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/Detail.vue 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/constants.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue 214 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/index.vue 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/mock.ts 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/types.ts 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/Form.vue 295 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/components/AnswerSettings.vue 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/constants.ts 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/index.vue 208 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/mock.ts 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/parseAnswer.ts 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/types.ts 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/Detail.vue 252 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/Edit.vue 179 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/Sign.vue 196 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/constants.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/index.vue 276 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/mock.ts 407 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/types.ts 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/constants.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/exam.vue 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/index.vue 235 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/mock.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/types.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/signMode/Form.vue 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/signMode/constants.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/signMode/index.vue 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/signMode/mock.ts 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/signMode/types.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/index.vue 269 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/mock.ts 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/types.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/Form.vue 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/constants.ts 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/index.vue 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/mock.ts 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/types.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/Catalog.vue 282 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/index.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/mock.ts 203 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/types.ts 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/course.ts
New file
@@ -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}` });
}
apps/jnpf-web-apps-main/src/api/x/tms/evalMode.ts
New file
@@ -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}` });
}
apps/jnpf-web-apps-main/src/api/x/tms/examGrade.ts
New file
@@ -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 });
}
apps/jnpf-web-apps-main/src/api/x/tms/examScore.ts
New file
@@ -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}` });
}
apps/jnpf-web-apps-main/src/api/x/tms/onlineExam.ts
New file
@@ -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 });
}
apps/jnpf-web-apps-main/src/api/x/tms/ping.ts
New file
@@ -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' });
}
apps/jnpf-web-apps-main/src/api/x/tms/question.ts
New file
@@ -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。
 * 正式路径约定:/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}` });
}
apps/jnpf-web-apps-main/src/api/x/tms/record.ts
New file
@@ -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 } });
}
apps/jnpf-web-apps-main/src/api/x/tms/selfTest.ts
New file
@@ -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 });
}
apps/jnpf-web-apps-main/src/api/x/tms/signMode.ts
New file
@@ -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}` });
}
apps/jnpf-web-apps-main/src/api/x/tms/trainArchive.ts
New file
@@ -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` });
}
apps/jnpf-web-apps-main/src/api/x/tms/trainMode.ts
New file
@@ -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}` });
}
apps/jnpf-web-apps-main/src/api/x/tms/trainRecord.ts
New file
@@ -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 });
}
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: '个人培训档案',
          hideInMenu: true,
          ignoreAccess: true,
        },
      },
      {
        path: '/tms/trainRecord',
        name: 'TmsTrainRecordBasic',
        component: () => import('#/views/x/tms/trainRecord/index.vue'),
        meta: {
          title: '个人培训记录',
          hideInMenu: true,
          ignoreAccess: true,
        },
      },
      {
        path: '/tms/trainRecord/catalog/:id',
        name: 'TmsTrainRecordCatalog',
        component: () => import('#/views/x/tms/trainRecord/Catalog.vue'),
        meta: {
          title: '培训目录',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/trainRecord',
        },
      },
      {
        path: '/tms/record',
        name: 'TmsRecordBasic',
        component: () => import('#/views/x/tms/record/index.vue'),
        meta: {
          title: '培训记录',
          hideInMenu: true,
          ignoreAccess: true,
        },
      },
      {
        path: '/tms/record/ready',
        name: 'TmsRecordReady',
        component: () => import('#/views/x/tms/record/index.vue'),
        meta: {
          title: '可归档列表',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/record',
        },
      },
      {
        path: '/tms/record/archived',
        name: 'TmsRecordArchived',
        component: () => import('#/views/x/tms/record/index.vue'),
        meta: {
          title: '已归档列表',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/record',
        },
      },
      {
        path: '/tms/record/detail/:id',
        name: 'TmsRecordDetail',
        component: () => import('#/views/x/tms/record/Detail.vue'),
        meta: {
          title: '培训记录详情',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/record',
        },
      },
      {
        path: '/tms/record/sign/:id',
        name: 'TmsRecordSign',
        component: () => import('#/views/x/tms/record/Sign.vue'),
        meta: {
          title: '培训签到',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/record',
        },
      },
      {
        path: '/tms/record/edit/:id',
        name: 'TmsRecordEdit',
        component: () => import('#/views/x/tms/record/Edit.vue'),
        meta: {
          title: '修改培训记录',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/record',
        },
      },
      {
        path: '/tms/trainMode',
        name: 'TmsTrainModeBasic',
        component: () => import('#/views/x/tms/trainMode/index.vue'),
        meta: {
          title: '培训方式',
          hideInMenu: true,
          ignoreAccess: true,
        },
      },
      {
        path: '/tms/signMode',
        name: 'TmsSignModeBasic',
        component: () => import('#/views/x/tms/signMode/index.vue'),
        meta: {
          title: '签到方式',
          hideInMenu: true,
          ignoreAccess: true,
        },
      },
      {
        path: '/tms/evalMode',
        name: 'TmsEvalModeBasic',
        component: () => import('#/views/x/tms/evalMode/index.vue'),
        meta: {
          title: '考核方式',
          hideInMenu: true,
          ignoreAccess: true,
        },
      },
      {
        path: '/tms/course',
        name: 'TmsCourseBasic',
        component: () => import('#/views/x/tms/course/index.vue'),
        meta: {
          title: '培训课程',
          hideInMenu: true,
          ignoreAccess: true,
        },
      },
    ],
  },
  {
apps/jnpf-web-apps-main/src/router/routes/modules/tmsCourse.ts
New file
@@ -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;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsDemo.ts
New file
@@ -0,0 +1,19 @@
import type { RouteRecordRaw } from 'vue-router';
/** 本地联调入口(免菜单);正式环境请在后台菜单挂 pageAddress: x/tms/demo/index */
const tmsDemoRoutes: RouteRecordRaw[] = [
  {
    path: '/tms-demo',
    name: 'TmsDemo',
    component: () => import('#/views/x/tms/demo/index.vue'),
    meta: {
      hideInBreadcrumb: true,
      hideInMenu: true,
      hideInTab: false,
      ignoreAccess: true,
      title: 'TMS Demo',
    },
  },
];
export default tmsDemoRoutes;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsEvalMode.ts
New file
@@ -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;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamGrade.ts
New file
@@ -0,0 +1,11 @@
import type { RouteRecordRaw } from 'vue-router';
/**
 * 考试阅卷可由后台菜单挂载:
 *   路由地址 /tms/examGrade
 *   页面地址 x/tms/examGrade/index
 * 答卷列表 / 阅卷页已挂 basicRoutes
 */
const tmsExamGradeRoutes: RouteRecordRaw[] = [];
export default tmsExamGradeRoutes;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsExamScore.ts
New file
@@ -0,0 +1,11 @@
import type { RouteRecordRaw } from 'vue-router';
/**
 * 考试成绩可由后台菜单挂载:
 *   路由地址 /tms/examScore
 *   页面地址 x/tms/examScore/index
 * 详情(考生试卷列表)/ 查看试卷 已挂 basicRoutes
 */
const tmsExamScoreRoutes: RouteRecordRaw[] = [];
export default tmsExamScoreRoutes;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsOnlineExam.ts
New file
@@ -0,0 +1,11 @@
import type { RouteRecordRaw } from 'vue-router';
/**
 * 在线考试(我的试卷)可由后台菜单挂载:
 *   路由地址 /tms/onlineExam
 *   页面地址 x/tms/onlineExam/index
 * 答题页 / 详情页已挂 basicRoutes
 */
const tmsOnlineExamRoutes: RouteRecordRaw[] = [];
export default tmsOnlineExamRoutes;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsQuestion.ts
New file
@@ -0,0 +1,9 @@
import type { RouteRecordRaw } from 'vue-router';
/**
 * 列表可由后台菜单挂载:pageAddress = x/tms/question/index,路由 /tms/question
 * 创建/编辑已挂到 basicRoutes(不依赖菜单)
 */
const tmsQuestionRoutes: RouteRecordRaw[] = [];
export default tmsQuestionRoutes;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsRecord.ts
New file
@@ -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;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsSelfTest.ts
New file
@@ -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;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsSignMode.ts
New file
@@ -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;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainArchive.ts
New file
@@ -0,0 +1,21 @@
import type { RouteRecordRaw } from 'vue-router';
/**
 * 个人培训档案
 * 后台菜单:路由 /tms/trainArchive ,页面地址 x/tms/trainArchive/index
 * 同时挂本地路由,避免菜单 pageAddress 配错导致 404
 */
const tmsTrainArchiveRoutes: RouteRecordRaw[] = [
  {
    path: '/tms/trainArchive',
    name: 'TmsTrainArchive',
    component: () => import('#/views/x/tms/trainArchive/index.vue'),
    meta: {
      title: '个人培训档案',
      hideInMenu: true,
      ignoreAccess: true,
    },
  },
];
export default tmsTrainArchiveRoutes;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainMode.ts
New file
@@ -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;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTrainRecord.ts
New file
@@ -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;
apps/jnpf-web-apps-main/src/views/x/tms/course/Form.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/course/constants.ts
New file
@@ -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';
}
apps/jnpf-web-apps-main/src/views/x/tms/course/index.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/course/mock.ts
New file
@@ -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基础知识培训(QC、分析)' },
  { id: 'p6', fullName: '再确认测试' },
  { id: 'p7', fullName: 'SOP-偏差与变更综合考核' },
  { id: 'p8', fullName: '安全生产月专项考试' },
];
const store: CourseItem[] = [
  {
    id: 'course_001',
    courseNo: 'KC2026030001',
    courseName: 'GMP基础知识培训',
    category: 'gmp',
    trainMode: 'onsite',
    evalMode: 'exam',
    paperId: 'p5',
    paperName: 'GMP基础知识培训(QC、分析)',
    hours: 4,
    enabled: '1',
    remark: '新人入职必修',
  },
  {
    id: 'course_002',
    courseNo: 'KC2026030002',
    courseName: '偏差与变更管理 SOP',
    category: 'sop',
    trainMode: 'onsite',
    evalMode: 'quiz',
    paperId: undefined,
    paperName: undefined,
    hours: 2,
    enabled: '1',
    remark: '',
  },
  {
    id: 'course_003',
    courseNo: 'KC2026030003',
    courseName: '设备操作实操培训',
    category: 'skill',
    trainMode: 'practice',
    evalMode: 'practice',
    hours: 8,
    enabled: '1',
    remark: '需现场实操评分',
  },
  {
    id: 'course_004',
    courseNo: 'KC2026030004',
    courseName: '药物警戒年度培训',
    category: 'gmp',
    trainMode: 'online',
    evalMode: 'exam',
    paperId: 'p4',
    paperName: '2026-药物警戒年度培训试题',
    hours: 3,
    enabled: '1',
    remark: '',
  },
  {
    id: 'course_005',
    courseNo: 'KC2026030005',
    courseName: '安全生产月宣贯',
    category: 'safety',
    trainMode: 'onsite',
    evalMode: 'none',
    hours: 1.5,
    enabled: '1',
    remark: '无需考核',
  },
  {
    id: 'course_006',
    courseNo: 'KC2026020001',
    courseName: '旧版文件培训(停用)',
    category: 'other',
    trainMode: 'online',
    evalMode: 'exam',
    paperId: 'p6',
    paperName: '再确认测试',
    hours: 2,
    enabled: '0',
    remark: '已停用,历史引用保留',
  },
];
function nextCourseNo() {
  const now = new Date();
  const ym = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
  const prefix = `KC${ym}`;
  const seqs = store
    .filter((x) => x.courseNo.startsWith(prefix))
    .map((x) => Number(x.courseNo.slice(prefix.length)) || 0);
  const next = (seqs.length ? Math.max(...seqs) : 0) + 1;
  return `${prefix}${String(next).padStart(4, '0')}`;
}
function resolvePaperName(paperId?: string) {
  if (!paperId) return undefined;
  return papers.find((p) => p.id === paperId)?.fullName;
}
export function mockPaperOptions() {
  return delay([...papers]);
}
export function mockQueryCourses(params: CoursePageQuery = {}) {
  const {
    currentPage = 1,
    pageSize = 20,
    keyword = '',
    category,
    trainMode,
    evalMode,
    enabled,
  } = params;
  let list = [...store];
  if (category) list = list.filter((x) => x.category === category);
  if (trainMode) list = list.filter((x) => x.trainMode === trainMode);
  if (evalMode) list = list.filter((x) => x.evalMode === evalMode);
  if (enabled) list = list.filter((x) => x.enabled === enabled);
  if (keyword) {
    const k = keyword.trim().toLowerCase();
    list = list.filter(
      (x) =>
        x.courseNo.toLowerCase().includes(k) ||
        x.courseName.toLowerCase().includes(k) ||
        (x.paperName || '').toLowerCase().includes(k),
    );
  }
  list.sort((a, b) => b.courseNo.localeCompare(a.courseNo));
  const start = (currentPage - 1) * pageSize;
  return delay({
    list: list.slice(start, start + pageSize),
    pagination: { total: list.length, currentPage, pageSize },
  });
}
export async function mockGetCourse(id: string) {
  await delay(null);
  const row = store.find((x) => x.id === id);
  if (!row) throw new Error('培训课程不存在');
  return { ...row };
}
export async function mockSaveCourse(data: Partial<CourseItem> & { id?: string }) {
  await delay(null);
  if (data.evalMode === 'exam' && !data.paperId) {
    throw new Error('在线考试须关联考核试卷');
  }
  const paperName = resolvePaperName(data.paperId);
  if (data.id) {
    const row = store.find((x) => x.id === data.id);
    if (!row) throw new Error('培训课程不存在');
    Object.assign(row, {
      ...data,
      paperName: data.paperId ? paperName : undefined,
    });
    if (!data.paperId) {
      row.paperId = undefined;
      row.paperName = undefined;
    }
    return row;
  }
  if (!data.courseName?.trim()) throw new Error('请填写课程名称');
  const row: CourseItem = {
    id: `course_${Date.now()}`,
    courseNo: nextCourseNo(),
    courseName: data.courseName.trim(),
    category: data.category,
    trainMode: data.trainMode,
    evalMode: data.evalMode,
    paperId: data.paperId,
    paperName: data.paperId ? paperName : undefined,
    hours: data.hours ?? null,
    enabled: (data.enabled as any) || '1',
    remark: data.remark || '',
  };
  store.unshift(row);
  return row;
}
export async function mockSetCourseEnabled(id: string, enabled: '0' | '1') {
  await delay(null);
  const row = store.find((x) => x.id === id);
  if (!row) throw new Error('培训课程不存在');
  row.enabled = enabled;
  return row;
}
export async function mockDeleteCourse(id: string) {
  await delay(null);
  const idx = store.findIndex((x) => x.id === id);
  if (idx < 0) throw new Error('培训课程不存在');
  store.splice(idx, 1);
  return true;
}
apps/jnpf-web-apps-main/src/views/x/tms/course/types.ts
New file
@@ -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;
}
apps/jnpf-web-apps-main/src/views/x/tms/demo/index.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/Form.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/constants.ts
New file
@@ -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 || '-';
}
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/index.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/mock.ts
New file
@@ -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;
}
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/types.ts
New file
@@ -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;
}
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Mark.vue
New file
@@ -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'">正确答案:{{ it.correctAnswer || '-' }}</div>
                  <div v-if="it.analysis" class="text-gray-400">解析:{{ it.analysis }}</div>
                </div>
                <div class="mt-3 flex items-center gap-2">
                  <template v-if="it.isSubjective === '1'">
                    <span>得分</span>
                    <a-input-number
                      v-model:value="scoreMap[it.id]"
                      :min="0"
                      :max="it.score"
                      :precision="1"
                      :disabled="readonly"
                      class="!w-[120px]"
                    />
                    <span class="text-gray-400">/ {{ it.score }}</span>
                  </template>
                  <template v-else>
                    <span class="text-gray-500">自动得分:{{ it.gotScore ?? 0 }}</span>
                  </template>
                </div>
              </div>
            </template>
          </a-spin>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
/* 全局 jnpf-content-wrapper* 为 overflow:hidden 且无 min-height:0,必须整条链补齐 */
.tms-grade-page {
  min-height: 0;
}
.tms-grade-center {
  min-height: 0 !important;
}
.tms-grade-mark-wrap {
  display: flex !important;
  flex-direction: column;
  flex: 1 1 0 !important;
  min-height: 0 !important;
  height: auto !important;
  overflow: hidden !important;
  background: #fff;
  padding: 0;
}
.mark-top {
  flex-shrink: 0;
  padding: 16px 20px 0;
}
.mark-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-bottom: 12px;
  margin-bottom: 12px;
  border-bottom: 1px solid #f0f0f0;
}
.score-bar {
  background: #fafafa;
  border-radius: 6px;
  padding: 10px 14px;
  margin-bottom: 12px;
  font-size: 14px;
}
.mark-body {
  flex: 1 1 0;
  min-height: 0;
  overflow-y: auto !important;
  overflow-x: hidden;
  padding: 0 20px 32px;
  -webkit-overflow-scrolling: touch;
}
.grade-item {
  border: 1px solid #f0f0f0;
  border-radius: 8px;
  padding: 14px 16px;
  margin-bottom: 12px;
}
.grade-item.subjective {
  border-color: #ffd591;
  background: #fffbe6;
}
.stem {
  font-size: 15px;
  line-height: 1.7;
}
.answer-row {
  display: flex;
  flex-direction: column;
  gap: 4px;
  color: rgba(0, 0, 0, 0.75);
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Session.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/constants.ts
New file
@@ -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;
}
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/index.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/mock.ts
New file
@@ -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基础知识培训(QC、分析)',
    durationMin: 60,
    examStart: '2026-06-15 10:00',
    examEnd: '2026-06-18 18:00',
    totalScore: 100,
    passScore: 100,
    pendingCount: 2,
    submittedCount: 8,
  },
  {
    id: 'gs4',
    taskNo: 'TT2026090101',
    taskSubject: '2026-药物警戒年度培训',
    paperId: 'p4',
    paperName: '2026-药物警戒年度培训试题',
    durationMin: 90,
    examStart: '2026-09-01 09:00',
    examEnd: '2026-09-30 18:00',
    totalScore: 100,
    passScore: 60,
    pendingCount: 4,
    submittedCount: 12,
  },
];
const examsBySession: Record<string, GradeExamListItem[]> = {
  gs1: [
    {
      id: 'ex1',
      sessionId: 'gs1',
      userId: 'u1',
      userName: '张三',
      deptName: '质量管理部',
      submitTime: '2025-12-22 09:25:00',
      objectiveScore: 80,
      subjectiveScore: undefined,
      totalScore: undefined,
      passScore: 100,
      gradeStatus: 'pending',
    },
    {
      id: 'ex2',
      sessionId: 'gs1',
      userId: 'u2',
      userName: '李四',
      deptName: '生产部',
      submitTime: '2025-12-22 09:28:00',
      objectiveScore: 100,
      subjectiveScore: 0,
      totalScore: 100,
      passScore: 100,
      gradeStatus: 'graded',
      passFlag: '1',
    },
  ],
  gs2: [
    {
      id: 'ex3',
      sessionId: 'gs2',
      userId: 'u3',
      userName: '王五',
      deptName: '分析实验室',
      submitTime: '2026-04-08 14:20:00',
      objectiveScore: 70,
      gradeStatus: 'pending',
      passScore: 100,
    },
    {
      id: 'ex4',
      sessionId: 'gs2',
      userId: 'u4',
      userName: '赵六',
      deptName: '分析实验室',
      submitTime: '2026-04-09 10:05:00',
      objectiveScore: 85,
      gradeStatus: 'pending',
      passScore: 100,
    },
  ],
  gs3: [
    {
      id: 'ex5',
      sessionId: 'gs3',
      userId: 'u5',
      userName: '陈七',
      deptName: 'QC',
      submitTime: '2026-06-16 11:00:00',
      objectiveScore: 60,
      gradeStatus: 'pending',
      passScore: 100,
    },
  ],
  gs4: [
    {
      id: 'ex6',
      sessionId: 'gs4',
      userId: 'u6',
      userName: '周八',
      deptName: '药物警戒',
      submitTime: '2026-09-10 16:40:00',
      objectiveScore: 40,
      gradeStatus: 'pending',
      passScore: 60,
    },
    {
      id: 'ex7',
      sessionId: 'gs4',
      userId: 'u7',
      userName: '吴九',
      deptName: '药物警戒',
      submitTime: '2026-09-12 09:15:00',
      objectiveScore: 50,
      subjectiveScore: 20,
      totalScore: 70,
      gradeStatus: 'graded',
      passScore: 60,
      passFlag: '1',
    },
  ],
};
const examDetails: Record<string, GradeExamDetail> = {
  ex1: {
    examId: 'ex1',
    sessionId: 'gs1',
    paperName: '再确认测试',
    userName: '张三',
    deptName: '质量管理部',
    submitTime: '2025-12-22 09:25:00',
    totalScore: 100,
    passScore: 100,
    objectiveScore: 80,
    subjectiveScore: 0,
    gradeStatus: 'pending',
    items: [
      {
        id: 'ei1',
        questionType: 'single',
        stem: 'GMP 的全称是?',
        options: [
          { optionLabel: 'A', optionContent: '药品生产质量管理规范' },
          { optionLabel: 'B', optionContent: '药品经营质量管理规范' },
        ],
        correctAnswer: 'A',
        userAnswer: 'A',
        score: 20,
        gotScore: 20,
        isSubjective: '0',
      },
      {
        id: 'ei2',
        questionType: 'multi',
        stem: '下列哪些属于特种设备?',
        options: [
          { optionLabel: 'A', optionContent: '电梯' },
          { optionLabel: 'B', optionContent: '压力容器' },
          { optionLabel: 'C', optionContent: '普通办公桌' },
        ],
        correctAnswer: 'A,B',
        userAnswer: 'A,B',
        score: 30,
        gotScore: 30,
        isSubjective: '0',
      },
      {
        id: 'ei3',
        questionType: 'judge',
        stem: '特种作业人员必须取得有效资格证书后方可上岗作业。',
        correctAnswer: '正确',
        userAnswer: '正确',
        score: 30,
        gotScore: 30,
        isSubjective: '0',
      },
      {
        id: 'ei4',
        questionType: 'essay',
        stem: '请简述实验室安全操作要点。',
        userAnswer: '穿戴防护用品,遵守SOP,异常及时上报。',
        score: 20,
        gotScore: undefined,
        isSubjective: '1',
        analysis: '需覆盖防护、SOP、应急处理等要点。',
      },
    ],
  },
};
export function mockQueryGradeSessions(params: GradeSessionPageQuery) {
  let list = [...sessions];
  if (params.taskNo && params.taskNo !== 'null') {
    const kw = params.taskNo.trim().toLowerCase();
    list = list.filter((x) => x.taskNo.toLowerCase().includes(kw));
  }
  if (params.keyword && params.keyword !== 'null') {
    const kw = params.keyword.trim().toLowerCase();
    list = list.filter(
      (x) =>
        x.taskSubject.toLowerCase().includes(kw) ||
        x.paperName.toLowerCase().includes(kw) ||
        x.taskNo.toLowerCase().includes(kw),
    );
  }
  const currentPage = Number(params.currentPage || 1);
  const pageSize = Number(params.pageSize || 20);
  const start = (currentPage - 1) * pageSize;
  return delay({
    list: list.slice(start, start + pageSize),
    pagination: { total: list.length, currentPage, pageSize },
  });
}
export function mockGetGradeSession(id: string) {
  const row = sessions.find((x) => x.id === id);
  if (!row) return Promise.reject(new Error('阅卷场次不存在'));
  return delay({ ...row });
}
export function mockQueryGradeExams(sessionId: string) {
  const list = examsBySession[sessionId] || [];
  return delay({
    list,
    pagination: { total: list.length, currentPage: 1, pageSize: 50 },
  });
}
export function mockGetGradeExamDetail(examId: string): Promise<GradeExamDetail> {
  const cached = examDetails[examId];
  if (cached) return delay(JSON.parse(JSON.stringify(cached)));
  // 通用兜底:按列表生成一份可阅卷答卷
  let found: GradeExamListItem | undefined;
  let sessionId = '';
  for (const [sid, list] of Object.entries(examsBySession)) {
    found = list.find((x) => x.id === examId);
    if (found) {
      sessionId = sid;
      break;
    }
  }
  if (!found) return Promise.reject(new Error('答卷不存在'));
  const session = sessions.find((x) => x.id === sessionId);
  return delay({
    examId: found.id,
    sessionId,
    paperName: session?.paperName || '',
    userName: found.userName,
    deptName: found.deptName,
    submitTime: found.submitTime,
    totalScore: session?.totalScore || 100,
    passScore: found.passScore || session?.passScore || 60,
    objectiveScore: found.objectiveScore || 0,
    subjectiveScore: found.subjectiveScore || 0,
    gradeStatus: found.gradeStatus,
    items: [
      {
        id: `${examId}_obj`,
        questionType: 'single',
        stem: '客观题示例(已自动计分)',
        correctAnswer: 'A',
        userAnswer: 'A',
        score: found.objectiveScore || 60,
        gotScore: found.objectiveScore || 60,
        isSubjective: '0',
      },
      {
        id: `${examId}_sub`,
        questionType: 'essay',
        stem: '请简述本次培训的核心要点。',
        userAnswer: '考生作答内容……',
        score: 40,
        gotScore: found.gradeStatus === 'graded' ? found.subjectiveScore : undefined,
        isSubjective: '1',
      },
    ],
  });
}
export function mockSubmitGrade(payload: GradeSubmitPayload) {
  const detail = examDetails[payload.examId];
  let subjective = 0;
  payload.items.forEach((it) => {
    subjective += Number(it.gotScore || 0);
    if (detail) {
      const row = detail.items.find((x) => x.id === it.id);
      if (row) row.gotScore = it.gotScore;
    }
  });
  for (const list of Object.values(examsBySession)) {
    const exam = list.find((x) => x.id === payload.examId);
    if (exam) {
      exam.subjectiveScore = subjective;
      exam.totalScore = Number(exam.objectiveScore || 0) + subjective;
      exam.gradeStatus = 'graded';
      exam.passFlag = (exam.totalScore || 0) >= (exam.passScore || 0) ? '1' : '0';
      break;
    }
  }
  if (detail) {
    detail.subjectiveScore = subjective;
    detail.gradeStatus = 'graded';
    detail.items.forEach((it) => {
      const hit = payload.items.find((p) => p.id === it.id);
      if (hit) it.gotScore = hit.gotScore;
    });
  }
  return delay({ msg: '阅卷提交成功' });
}
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/types.ts
New file
@@ -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 }[];
}
apps/jnpf-web-apps-main/src/views/x/tms/examScore/Detail.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/examScore/Paper.vue
New file
@@ -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 ? '正确' : '错误' }}
                </span>
                <span v-else class="ml-2 text-orange-500">主观题</span>
              </div>
              <div class="stem mb-3">{{ stripHtml(it.stem) }}</div>
              <div v-if="it.options?.length" class="mb-2 text-sm text-gray-600">
                <div v-for="opt in it.options" :key="opt.optionLabel">
                  {{ opt.optionLabel }}. {{ opt.optionContent }}
                </div>
              </div>
              <div class="text-sm answer-row">
                <div>考生答案:{{ it.userAnswer || '(未作答)' }}</div>
                <div v-if="it.correctAnswer">参考/正确答案:{{ it.correctAnswer }}</div>
              </div>
            </div>
          </a-spin>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-paper-page {
  min-height: 0;
}
.tms-paper-center {
  min-height: 0 !important;
}
.tms-paper-wrap {
  display: flex !important;
  flex-direction: column;
  flex: 1 1 0 !important;
  min-height: 0 !important;
  overflow: hidden !important;
  background: #fff;
  padding: 0;
}
.paper-top {
  flex-shrink: 0;
  padding: 16px 20px 0;
}
.paper-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
  padding-bottom: 12px;
  border-bottom: 1px solid #f0f0f0;
}
.paper-body {
  flex: 1 1 0;
  min-height: 0;
  overflow-y: auto !important;
  padding: 0 20px 32px;
}
.paper-item {
  border: 1px solid #f0f0f0;
  border-radius: 8px;
  padding: 14px 16px;
  margin-bottom: 12px;
}
.paper-item.subjective {
  border-color: #ffd591;
  background: #fffbe6;
}
.stem {
  font-size: 15px;
  line-height: 1.7;
}
.answer-row {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/examScore/index.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/examScore/mock.ts
New file
@@ -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 记录要求,Rev 02',
    taskNo: 'TT2020060534',
    paperId: 'p1',
    paperName: '(已失效)BQA0002 记录要求,Rev 02',
    paperInvalid: true,
    participantCount: 1,
    failCount: 0,
    maxScore: 100,
    minScore: 100,
    avgScore: 100,
    passScore: 80,
    totalScore: 100,
  },
  {
    id: 'es2',
    taskSubject: 'BAL0003 留样管理程序,Rev 03',
    taskNo: 'TT2020060535',
    paperId: 'p2',
    paperName: '(已失效)BAL0003 留样管理程序,Rev 03',
    paperInvalid: true,
    participantCount: 1,
    failCount: 1,
    maxScore: 70,
    minScore: 70,
    avgScore: 70,
    passScore: 80,
    totalScore: 100,
  },
  {
    id: 'es3',
    taskSubject: 'SOP-QA-012 偏差管理;SOP-QA-015 变更控制',
    taskNo: 'TT2020070102',
    paperId: 'p3',
    paperName: '(已失效)偏差与变更综合考核',
    paperInvalid: true,
    participantCount: 12,
    failCount: 2,
    maxScore: 98,
    minScore: 55,
    avgScore: 82.5,
    passScore: 80,
    totalScore: 100,
  },
  {
    id: 'es4',
    taskSubject: '2026-药物警戒年度培训',
    taskNo: 'TT2026090101',
    paperId: 'p4',
    paperName: '2026-药物警戒年度培训试题',
    paperInvalid: false,
    participantCount: 28,
    failCount: 3,
    maxScore: 100,
    minScore: 48,
    avgScore: 86.2,
    passScore: 60,
    totalScore: 100,
  },
  {
    id: 'es5',
    taskSubject: 'GMP基础知识培训及案例分析',
    taskNo: 'TT2026060155',
    paperId: 'p5',
    paperName: 'GMP基础知识培训(QC、分析)',
    paperInvalid: false,
    participantCount: 15,
    failCount: 1,
    maxScore: 100,
    minScore: 72,
    avgScore: 91,
    passScore: 100,
    totalScore: 100,
  },
  {
    id: 'es6',
    taskSubject: '再确认测试',
    taskNo: 'TT2025120575',
    paperId: 'p6',
    paperName: '再确认测试',
    paperInvalid: false,
    participantCount: 2,
    failCount: 0,
    maxScore: 100,
    minScore: 80,
    avgScore: 90,
    passScore: 100,
    totalScore: 100,
  },
];
function makeRow(
  partial: Omit<ExamScoreDetailRow, 'taskSubject' | 'taskNo' | 'passScore'> & { summaryId: string },
): ExamScoreDetailRow {
  const summary = store.find((x) => x.id === partial.summaryId)!;
  return {
    ...partial,
    taskSubject: summary.taskSubject,
    taskNo: summary.taskNo,
    passScore: summary.passScore,
  };
}
const detailRows: Record<string, ExamScoreDetailRow[]> = {
  es1: [
    makeRow({
      id: 'r1',
      summaryId: 'es1',
      userId: 'chensj',
      userName: '陈思佳',
      deptName: '质量保证部',
      examStart: '2020-07-15 09:00:00',
      examEnd: '2020-07-15 09:35:00',
      score: 100,
      passFlag: '1',
      sourceIp: '192.168.1.56',
    }),
  ],
  es2: [
    makeRow({
      id: 'r2',
      summaryId: 'es2',
      userId: 'lisi',
      userName: '李四',
      deptName: '分析实验室',
      examStart: '2020-06-12 09:30:00',
      examEnd: '2020-06-12 10:05:00',
      score: 70,
      passFlag: '0',
      sourceIp: '192.168.1.88',
    }),
  ],
  es4: [
    makeRow({
      id: 'r3',
      summaryId: 'es4',
      userId: 'zhouba',
      userName: '周八',
      deptName: '药物警戒',
      examStart: '2026-09-10 16:00:00',
      examEnd: '2026-09-10 16:40:00',
      score: 70,
      passFlag: '1',
      sourceIp: '10.0.12.21',
    }),
    makeRow({
      id: 'r4',
      summaryId: 'es4',
      userId: 'wujiu',
      userName: '吴九',
      deptName: '药物警戒',
      examStart: '2026-09-12 08:40:00',
      examEnd: '2026-09-12 09:15:00',
      score: 48,
      passFlag: '0',
      sourceIp: '10.0.12.33',
    }),
    makeRow({
      id: 'r5',
      summaryId: 'es4',
      userId: 'zhengshi',
      userName: '郑十',
      deptName: '药物警戒',
      examStart: '2026-09-11 13:20:00',
      examEnd: '2026-09-11 14:00:00',
      score: 100,
      passFlag: '1',
      sourceIp: '10.0.12.45',
    }),
  ],
  es6: [
    makeRow({
      id: 'r6',
      summaryId: 'es6',
      userId: 'zhangsan',
      userName: '张三',
      deptName: '质量管理部',
      examStart: '2025-12-22 09:00:00',
      examEnd: '2025-12-22 09:25:00',
      score: 80,
      passFlag: '0',
      sourceIp: '192.168.110.20',
    }),
    makeRow({
      id: 'r7',
      summaryId: 'es6',
      userId: 'lisi',
      userName: '李四',
      deptName: '生产部',
      examStart: '2025-12-22 09:00:00',
      examEnd: '2025-12-22 09:28:00',
      score: 100,
      passFlag: '1',
      sourceIp: '192.168.110.21',
    }),
  ],
};
function findRow(examId: string): ExamScoreDetailRow | undefined {
  for (const list of Object.values(detailRows)) {
    const hit = list.find((x) => x.id === examId);
    if (hit) return hit;
  }
  // 动态兜底行(如 es3_demo)
  const m = /^(.+)_demo$/.exec(examId);
  if (m) {
    const summary = store.find((x) => x.id === m[1]);
    if (summary) {
      return {
        id: examId,
        summaryId: summary.id,
        taskSubject: summary.taskSubject,
        taskNo: summary.taskNo,
        userId: 'demo',
        userName: '示例考生',
        deptName: '示例部门',
        examStart: '2026-01-01 09:00:00',
        examEnd: '2026-01-01 10:00:00',
        score: summary.avgScore,
        passScore: summary.passScore,
        passFlag: summary.avgScore >= summary.passScore ? '1' : '0',
        sourceIp: '127.0.0.1',
      };
    }
  }
  return undefined;
}
export function mockQueryExamScores(params: ExamScorePageQuery) {
  let list = [...store];
  if (params.taskNo && params.taskNo !== 'null') {
    const kw = params.taskNo.trim().toLowerCase();
    list = list.filter((x) => x.taskNo.toLowerCase().includes(kw));
  }
  if (params.keyword && params.keyword !== 'null') {
    const kw = params.keyword.trim().toLowerCase();
    list = list.filter(
      (x) =>
        x.taskSubject.toLowerCase().includes(kw) ||
        x.paperName.toLowerCase().includes(kw) ||
        x.taskNo.toLowerCase().includes(kw),
    );
  }
  const currentPage = Number(params.currentPage || 1);
  const pageSize = Number(params.pageSize || 20);
  const start = (currentPage - 1) * pageSize;
  return delay({
    list: list.slice(start, start + pageSize),
    pagination: { total: list.length, currentPage, pageSize },
  });
}
export function mockGetExamScoreDetail(id: string): Promise<ExamScoreDetail> {
  const row = store.find((x) => x.id === id);
  if (!row) return Promise.reject(new Error('成绩记录不存在'));
  const rows = detailRows[id] || [
    makeRow({
      id: `${id}_demo`,
      summaryId: id,
      userId: 'demo',
      userName: '示例考生',
      deptName: '示例部门',
      examStart: '2026-01-01 09:00:00',
      examEnd: '2026-01-01 10:00:00',
      score: row.avgScore,
      passFlag: row.avgScore >= row.passScore ? '1' : '0',
      sourceIp: '127.0.0.1',
    }),
  ];
  return delay({ ...row, rows });
}
function buildPaperItems(examId: string, score: number): ExamPaperView['items'] {
  return [
    {
      id: `${examId}_q1`,
      questionType: 'single',
      stem: 'GMP 的全称是?',
      options: [
        { optionLabel: 'A', optionContent: '药品生产质量管理规范' },
        { optionLabel: 'B', optionContent: '药品经营质量管理规范' },
        { optionLabel: 'C', optionContent: '实验室管理规范' },
      ],
      correctAnswer: 'A',
      userAnswer: score >= 60 ? 'A' : 'B',
      score: 30,
      gotScore: score >= 60 ? 30 : 0,
      isCorrect: score >= 60,
      isSubjective: '0',
    },
    {
      id: `${examId}_q2`,
      questionType: 'multi',
      stem: '下列哪些属于特种设备?',
      options: [
        { optionLabel: 'A', optionContent: '电梯' },
        { optionLabel: 'B', optionContent: '压力容器' },
        { optionLabel: 'C', optionContent: '普通办公桌' },
        { optionLabel: 'D', optionContent: '锅炉' },
      ],
      correctAnswer: 'A,B,D',
      userAnswer: score >= 80 ? 'A,B,D' : 'A,B',
      score: 30,
      gotScore: score >= 80 ? 30 : 15,
      isCorrect: score >= 80,
      isSubjective: '0',
    },
    {
      id: `${examId}_q3`,
      questionType: 'judge',
      stem: '特种作业人员必须取得有效资格证书后方可上岗作业。',
      correctAnswer: '正确',
      userAnswer: '正确',
      score: 20,
      gotScore: 20,
      isCorrect: true,
      isSubjective: '0',
    },
    {
      id: `${examId}_q4`,
      questionType: 'essay',
      stem: '请简述本次培训的核心要点。',
      userAnswer: '按 SOP 执行,做好记录,异常及时上报。',
      correctAnswer: '参考:覆盖制度要点、操作规范与记录要求。',
      score: 20,
      gotScore: Math.max(0, Math.min(20, score - 80)),
      isSubjective: '1',
    },
  ];
}
export function mockGetExamPaperView(examId: string): Promise<ExamPaperView> {
  let exam = findRow(examId);
  // 再兜底:任意 id 也能打开模拟答卷,避免“没有模拟试卷”
  if (!exam) {
    const summary = store[0];
    exam = {
      id: examId,
      summaryId: summary.id,
      taskSubject: summary.taskSubject,
      taskNo: summary.taskNo,
      userId: 'mock_user',
      userName: '模拟考生',
      deptName: '模拟部门',
      examStart: '2026-09-01 09:00:00',
      examEnd: '2026-09-01 09:45:00',
      score: 85,
      passScore: summary.passScore,
      passFlag: '1',
      sourceIp: '192.168.0.1',
    };
  }
  const summary = store.find((x) => x.id === exam!.summaryId) || store[0];
  return delay({
    examId: exam.id,
    summaryId: exam.summaryId,
    taskSubject: exam.taskSubject,
    taskNo: exam.taskNo,
    paperName: summary.paperName || exam.taskSubject,
    userId: exam.userId,
    userName: exam.userName,
    deptName: exam.deptName,
    examStart: exam.examStart,
    examEnd: exam.examEnd,
    score: exam.score,
    passScore: exam.passScore,
    totalScore: summary.totalScore || 100,
    passFlag: exam.passFlag,
    sourceIp: exam.sourceIp,
    items: buildPaperItems(exam.id, exam.score),
  });
}
apps/jnpf-web-apps-main/src/views/x/tms/examScore/types.ts
New file
@@ -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';
}
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/Detail.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/constants.ts
New file
@@ -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;
}
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue
New file
@@ -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) ? '回答正确' : '回答错误' }}
            · 正确答案:
            {{ 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>
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/index.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/mock.ts
New file
@@ -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: '正确', 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: '交卷成功' });
}
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/types.ts
New file
@@ -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';
}
apps/jnpf-web-apps-main/src/views/x/tms/question/Form.vue
New file
@@ -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="试题解析" 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>
apps/jnpf-web-apps-main/src/views/x/tms/question/components/AnswerSettings.vue
New file
@@ -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">正确</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>
apps/jnpf-web-apps-main/src/views/x/tms/question/constants.ts
New file
@@ -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 ?? '-';
}
apps/jnpf-web-apps-main/src/views/x/tms/question/index.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/question/mock.ts
New file
@@ -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: '正确', 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: '删除成功' });
}
apps/jnpf-web-apps-main/src/views/x/tms/question/parseAnswer.ts
New file
@@ -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: '正确', isCorrect: '1' },
      { sortNo: 2, optionLabel: 'F', optionContent: '错误', isCorrect: '0' },
    ];
  }
  if (type === 'blank') {
    return [{ sortNo: 1, optionLabel: '1', optionContent: '', isCorrect: '1' }];
  }
  // essay:参考答案存在 option[0]
  return [{ sortNo: 1, optionLabel: 'A', optionContent: '', isCorrect: '1' }];
}
/** 表单 → 落库实体(各题型解析) */
export function parseFormToEntity(form: QuestionFormModel): Omit<QuestionEntity, 'bankName' | 'adminUserName' | 'creatorTime'> {
  const options = parseOptionsByType(form);
  return {
    id: form.id,
    bankId: form.bankId || '',
    questionType: form.questionType,
    difficulty: form.difficulty,
    sourceType: form.sourceType,
    stem: form.stem,
    analysis: form.analysis,
    bizStatus: form.bizStatus,
    blankMixMode: form.questionType === 'blank' ? form.blankMixMode : undefined,
    options,
  };
}
export function parseOptionsByType(form: QuestionFormModel): QuestionOption[] {
  const { questionType } = form;
  if (questionType === 'judge') {
    const correct = form.judgeAnswer !== false;
    return [
      { sortNo: 1, optionLabel: 'T', optionContent: '正确', isCorrect: correct ? '1' : '0' },
      { sortNo: 2, optionLabel: 'F', optionContent: '错误', isCorrect: correct ? '0' : '1' },
    ];
  }
  if (questionType === 'essay') {
    return [
      {
        sortNo: 1,
        optionLabel: 'A',
        optionContent: form.essayAnswer ?? '',
        isCorrect: '1',
      },
    ];
  }
  // single / multi / blank:直接用 options,并重排 label
  return (form.options || []).map((opt, i) => ({
    ...opt,
    sortNo: i + 1,
    optionLabel: questionType === 'blank' ? String(i + 1) : OPTION_LABELS[i] || String(i + 1),
  }));
}
/** 实体 → 表单回填(各题型解析) */
export function hydrateFormFromEntity(entity: QuestionEntity): QuestionFormModel {
  const type = entity.questionType;
  const options = entity.options?.length ? [...entity.options] : createDefaultOptions(type);
  const form: QuestionFormModel = {
    id: entity.id,
    questionType: type,
    bankId: entity.bankId,
    difficulty: entity.difficulty || 'normal',
    sourceType: entity.sourceType || 'self',
    bizStatus: entity.bizStatus || 'closed',
    stem: entity.stem || '',
    analysis: entity.analysis || '',
    options,
    blankMixMode: !!entity.blankMixMode,
    judgeAnswer: true,
    essayAnswer: '',
  };
  if (type === 'judge') {
    const correct = options.find((o) => o.isCorrect === '1');
    form.judgeAnswer = !correct || correct.optionLabel === 'T' || correct.optionContent === '正确';
  } else if (type === 'essay') {
    form.essayAnswer = options[0]?.optionContent || '';
  }
  return form;
}
/** 提交前校验(按题型) */
export function validateAnswerByType(form: QuestionFormModel): string | null {
  if (!form.bankId) return '请选择所属题库';
  if (!form.stem?.trim()) return '请填写题干内容';
  switch (form.questionType) {
    case 'single': {
      const filled = form.options.filter((o) => o.optionContent?.trim());
      if (filled.length < 2) return '单选题至少填写 2 个选项';
      if (form.options.filter((o) => o.isCorrect === '1').length !== 1) return '单选题必须且只能选择 1 个正确答案';
      break;
    }
    case 'multi': {
      const filled = form.options.filter((o) => o.optionContent?.trim());
      if (filled.length < 2) return '多选题至少填写 2 个选项';
      if (form.options.filter((o) => o.isCorrect === '1').length < 2) return '多选题至少选择 2 个正确答案';
      break;
    }
    case 'judge':
      if (form.judgeAnswer === undefined) return '请选择判断题答案';
      break;
    case 'blank': {
      if (!form.options.length) return '请至少增加一个填空';
      if (form.options.some((o) => !o.optionContent?.trim())) return '请填写每个填空的答案';
      break;
    }
    case 'essay':
      if (!form.essayAnswer?.trim()) return '请填写问答题参考答案';
      break;
  }
  return null;
}
/** 切换题型时重置答案区 */
export function resetAnswerForType(type: QuestionType): Pick<QuestionFormModel, 'options' | 'judgeAnswer' | 'essayAnswer' | 'blankMixMode'> {
  return {
    options: createDefaultOptions(type),
    judgeAnswer: true,
    essayAnswer: '',
    blankMixMode: false,
  };
}
apps/jnpf-web-apps-main/src/views/x/tms/question/types.ts
New file
@@ -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' 正确 / '0' 错误 */
  isCorrect: '0' | '1';
}
export interface QuestionEntity {
  id?: string;
  questionNo?: string;
  bankId: string;
  bankName?: string;
  questionType: QuestionType;
  difficulty?: Difficulty;
  sourceType?: SourceType;
  stem: string;
  analysis?: string;
  adminUserId?: string;
  adminUserName?: string;
  bizStatus: QuestionStatus;
  creatorTime?: string;
  options?: QuestionOption[];
  /** 填空:混杂模式批改 */
  blankMixMode?: boolean;
}
export interface QuestionPageQuery {
  currentPage?: number;
  pageSize?: number;
  bankId?: string;
  questionType?: QuestionType | '';
  bizStatus?: QuestionStatus | '';
  adminUserId?: string;
  keyword?: string;
}
export interface QuestionBankOption {
  id: string;
  fullName: string;
}
export interface QuestionFormModel {
  id?: string;
  questionType: QuestionType;
  bankId?: string;
  difficulty: Difficulty;
  sourceType: SourceType;
  bizStatus: QuestionStatus;
  stem: string;
  analysis: string;
  options: QuestionOption[];
  /** 判断题答案:true=正确 false=错误 */
  judgeAnswer?: boolean;
  /** 问答题参考答案 */
  essayAnswer?: string;
  /** 填空混杂批改 */
  blankMixMode: boolean;
}
apps/jnpf-web-apps-main/src/views/x/tms/record/Detail.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/record/Edit.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/record/Sign.vue
New file
@@ -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('已存档记录不可签到');
      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>
apps/jnpf-web-apps-main/src/views/x/tms/record/constants.ts
New file
@@ -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: '可归档列表',
  archived: '已归档列表',
};
/** 各列表页路由 */
export const RECORD_LIST_PATH: Record<RecordListMode, string> = {
  main: '/tms/record',
  ready: '/tms/record/ready',
  archived: '/tms/record/archived',
};
export function labelOfArchiveStatus(status?: ArchiveStatus) {
  return ARCHIVE_STATUS_OPTIONS.find((x) => x.id === status)?.fullName || status || '-';
}
export function colorOfArchiveStatus(status?: ArchiveStatus) {
  if (status === 'ready') return '#1677ff';
  if (status === 'archived') return '#52c41a';
  if (status === 'invalid') return '#ff4d4f';
  return undefined;
}
apps/jnpf-web-apps-main/src/views/x/tms/record/index.vue
New file
@@ -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}」签到二维码(接口联调后生效)`);
}
function handleAdd() {
  createMessage.info('新增培训记录(联调任务发布后由系统自动生成,手工新增待后端接口)');
}
function handleEdit() {
  const row = requireOneRow();
  if (!row) return;
  router.push(`/tms/record/edit/${row.id}`);
}
function handleView(record?: TmsRecordListItem) {
  const row = record || requireOneRow();
  if (!row) return;
  router.push({
    path: `/tms/record/detail/${row.id}`,
    query: { from: listMode.value },
  });
}
function handleSign() {
  const row = requireOneRow();
  if (!row) return;
  router.push(`/tms/record/sign/${row.id}`);
}
function handleDelete() {
  const rows = requireRows();
  if (!rows) return;
  Modal.confirm({
    title: '确认删除',
    content: `确定删除选中的 ${rows.length} 条培训记录吗?`,
    onOk: async () => {
      try {
        await deleteTmsRecords(rows.map((x) => x.id));
        createMessage.success('删除成功');
        reload();
      } catch (e: any) {
        createMessage.error(e?.message || '删除失败');
      }
    },
  });
}
function handleArchive() {
  const rows = requireRows();
  if (!rows) return;
  Modal.confirm({
    title: '确认归档',
    content: `确定将选中的 ${rows.length} 条记录归档吗?`,
    onOk: async () => {
      try {
        const count = await archiveTmsRecords(rows.map((x) => x.id));
        createMessage.success(`已归档 ${count} 条`);
        reload();
      } catch (e: any) {
        createMessage.error(e?.message || '归档失败');
      }
    },
  });
}
function getTableActions(record: TmsRecordListItem): ActionItem[] {
  const actions: ActionItem[] = [{ label: '查看', onClick: handleView.bind(null, record) }];
  if (isMainView.value) {
    actions.push({
      label: '签到',
      onClick: () => router.push(`/tms/record/sign/${record.id}`),
    });
  }
  return actions;
}
</script>
<template>
  <div class="jnpf-content-wrapper">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content">
        <BasicVxeTable @register="registerTable">
          <template #tableTitle>
            <!-- 1. 培训记录 -->
            <a-space v-if="isMainView" wrap>
              <a-button pre-icon="icon-ym icon-ym-generator-qrcode" @click="handleQrcode">生成二维码</a-button>
              <a-button pre-icon="icon-ym icon-ym-btn-edit" @click="handleEdit">修改</a-button>
              <a-button pre-icon="icon-ym icon-ym-btn-preview" @click="handleView()">查看</a-button>
              <a-button pre-icon="icon-ym icon-ym-extend-form" @click="handleSign">签到</a-button>
              <a-button pre-icon="icon-ym icon-ym-file-text" @click="goList('ready')">可归档列表</a-button>
              <a-button pre-icon="icon-ym icon-ym-extend-folder" @click="goList('archived')">已归档列表</a-button>
              <span class="text-gray-400 text-sm">当前:{{ listModeTitle }}</span>
            </a-space>
            <!-- 2. 可归档列表 -->
            <a-space v-else-if="isReadyView" wrap>
              <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleAdd">新增</a-button>
              <a-button pre-icon="icon-ym icon-ym-btn-edit" @click="handleEdit">修改</a-button>
              <a-button danger pre-icon="icon-ym icon-ym-btn-clearn" @click="handleDelete">删除</a-button>
              <a-button pre-icon="icon-ym icon-ym-extend-folder" @click="handleArchive">归档</a-button>
              <a-button @click="goList('main')">返回</a-button>
              <a-button pre-icon="icon-ym icon-ym-file-text" @click="goList('archived')">已归档列表</a-button>
              <span class="text-gray-400 text-sm">当前:{{ listModeTitle }}</span>
            </a-space>
            <!-- 3. 已归档列表 -->
            <a-space v-else wrap>
              <a-button @click="handleView()">查看</a-button>
              <a-button @click="handleEdit">修改</a-button>
              <a-button @click="goList('main')">返回</a-button>
              <a-button type="primary" @click="goList('ready')">可归档列表</a-button>
              <span class="text-gray-400 text-sm">当前:{{ listModeTitle }}</span>
            </a-space>
          </template>
          <template #subject="{ record }">
            <div class="subject-cell">{{ record.subject }}</div>
          </template>
          <template #action="{ record }">
            <TableAction :actions="getTableActions(record)" />
          </template>
        </BasicVxeTable>
      </div>
    </div>
  </div>
</template>
<style scoped>
.subject-cell {
  white-space: pre-wrap;
  line-height: 1.45;
  word-break: break-word;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/record/mock.ts
New file
@@ -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: '本周质量专题宣贯',
    trainerName: '王敏',
    trainType: '集中授课',
    startTime: fmt(now + 1 * day + 9 * 60 * 60 * 1000),
    evalMode: '提问',
    archiveStatus: 'pending',
  },
  {
    id: 'rc0b',
    recordNo: 'TT2026091702',
    category: '文件生效培训',
    subject: '(SOP-PR-0008) 批记录填写规范 Rev.02',
    trainerName: '陈刚',
    trainType: '在线学习',
    startTime: fmt(now - 0.5 * day + 10 * 60 * 60 * 1000),
    evalMode: '无需考核',
    archiveStatus: 'pending',
    signRate: 60,
  },
  {
    id: 'rc0c',
    recordNo: 'TT2026091603',
    category: '岗位培训计划',
    subject: '取样员岗位操作再培训',
    trainerName: '赵丽',
    trainType: '操作培训',
    startTime: fmt(now - 2 * day + 14 * 60 * 60 * 1000),
    evalMode: '实操考核',
    archiveStatus: 'pending',
    signRate: 70,
  },
  {
    id: 'rc1',
    recordNo: 'TT2026090525',
    category: '文件生效培训',
    subject: '(JY-03-0042-B2449) 文件发放与回收管理规程 Rev.03',
    trainerName: '梁波',
    trainType: '在线学习',
    startTime: fmt(now - 5 * day + 9.8 * 60 * 60 * 1000),
    evalMode: '无需考核',
    archiveStatus: 'ready',
    signRate: 100,
    passRate: 100,
  },
  {
    id: 'rc2',
    recordNo: 'TT2026090339',
    category: '岗位培训计划',
    subject: '分析实验室岗位再培训(HPLC 操作)',
    trainerName: '孔德敏',
    trainType: '集中授课',
    startTime: fmt(now - 8 * day + 14 * 60 * 60 * 1000),
    evalMode: '提问',
    archiveStatus: 'ready',
    signRate: 95,
    passRate: 90,
  },
  {
    id: 'rc3',
    recordNo: 'TT2026090201',
    category: '临时培训',
    subject: '偏差调查流程宣贯',
    trainerName: '季远达',
    trainType: '在线学习',
    startTime: fmt(now - 12 * day + 10 * 60 * 60 * 1000),
    evalMode: '无需考核',
    archiveStatus: 'ready',
    signRate: 88,
    passRate: 88,
  },
  {
    id: 'rc4',
    recordNo: 'TT2026082812',
    category: '文件生效培训',
    subject: '(SOP-QA-0012) 变更控制管理程序 Rev.05',
    trainerName: '梁波',
    trainType: '在线学习',
    startTime: fmt(now - 20 * day + 9 * 60 * 60 * 1000),
    evalMode: '提问',
    archiveStatus: 'ready',
    signRate: 100,
    passRate: 96,
  },
  {
    id: 'rc5',
    recordNo: 'TT2026090638',
    category: '临时培训',
    subject: '硝酸奥司他韦干混悬剂稳定性标准培训',
    trainerName: '周莉华',
    trainType: '在线学习',
    startTime: '2020-09-17 16:00',
    endTime: '2020-09-17 17:30',
    evalMode: '无需考核',
    archiveStatus: 'archived',
    trainMode: '在线学习',
    signRate: 100,
    passRate: 100,
  },
  {
    id: 'rc6',
    recordNo: 'TT2026090610',
    category: '文件生效培训',
    subject: '(JY-05-0011) 稳定性考察管理规程 Rev.01',
    trainerName: '黄援花',
    trainType: '集中授课',
    startTime: fmt(now - 3 * day + 9.5 * 60 * 60 * 1000),
    evalMode: '无需考核',
    archiveStatus: 'archived',
    signRate: 100,
    passRate: 100,
  },
  {
    id: 'rc7',
    recordNo: 'TT2026081508',
    category: '岗位培训计划',
    subject: '实验室安全再培训',
    trainerName: '李四',
    trainType: '在线学习',
    startTime: fmt(now - 40 * day + 10 * 60 * 60 * 1000),
    evalMode: '在线考试',
    archiveStatus: 'archived',
    signRate: 100,
    passRate: 92,
  },
  {
    id: 'rc8',
    recordNo: 'TT2026072203',
    category: '临时培训',
    subject: '测试在线考试0415',
    trainerName: 'mkj',
    trainType: '在线学习',
    startTime: fmt(now - 60 * day),
    evalMode: '在线考试',
    archiveStatus: 'archived',
    signRate: 80,
    passRate: 75,
  },
];
const signStore: Record<string, TmsRecordDetail['signList']> = {
  rc0b: [
    { id: 's0', userName: '周杰', deptName: '生产部', signTime: fmt(now - 0.5 * day, true), signMode: 'APP扫码' },
  ],
  rc1: [
    { id: 's1', userName: '张三', deptName: '质量管理部', signTime: fmt(now - 5 * day, true), signMode: 'APP扫码' },
    { id: 's2', userName: '李四', deptName: '分析实验室', signTime: fmt(now - 5 * day, true), signMode: '微信扫码' },
  ],
  rc5: [
    { id: 's3', userName: '王五', deptName: '生产部', signTime: '2020-09-17 16:05:00', signMode: '在线签到' },
  ],
};
type Participant = NonNullable<TmsRecordDetail['participants']>[number];
const participantStore: Record<string, Participant[]> = {
  rc0a: [
    { id: 'p0a1', userName: '陈晓', deptName: '质量管理部', signDate: '', examScore: '', passFlag: '' },
    { id: 'p0a2', userName: '林娜', deptName: '生产一部', signDate: '', examScore: '', passFlag: '' },
    { id: 'p0a3', userName: '黄伟', deptName: '生产二部', signDate: '', examScore: '', passFlag: '' },
  ],
  rc0b: [
    { id: 'p0b1', userName: '周杰', deptName: '生产部', signDate: fmt(now - 0.5 * day, true), examScore: '', passFlag: '' },
    { id: 'p0b2', userName: '何静', deptName: '质量保证部', signDate: fmt(now - 0.5 * day, true), examScore: '', passFlag: '' },
    { id: 'p0b3', userName: '罗明', deptName: '工程部', signDate: fmt(now - 0.5 * day + 8 * 60 * 1000, true), examScore: '', passFlag: '' },
    { id: 'p0b4', userName: '邓丽', deptName: '仓储部', signDate: '', examScore: '', passFlag: '' },
  ],
  rc0c: [
    { id: 'p0c1', userName: '赵丽', deptName: '分析实验室', signDate: fmt(now - 2 * day + 14 * 60 * 60 * 1000, true), examScore: 92, passFlag: '1' },
    { id: 'p0c2', userName: '孙浩', deptName: '取样组', signDate: fmt(now - 2 * day + 14.1 * 60 * 60 * 1000, true), examScore: 88, passFlag: '1' },
    { id: 'p0c3', userName: '钱芳', deptName: '取样组', signDate: fmt(now - 2 * day + 14.2 * 60 * 60 * 1000, true), examScore: 76, passFlag: '1' },
    { id: 'p0c4', userName: '吴磊', deptName: 'QC实验室', signDate: fmt(now - 2 * day + 14.3 * 60 * 60 * 1000, true), examScore: 58, passFlag: '0' },
    { id: 'p0c5', userName: '郑雪', deptName: '取样组', signDate: fmt(now - 2 * day + 14.4 * 60 * 60 * 1000, true), examScore: 85, passFlag: '1' },
    { id: 'p0c6', userName: '冯涛', deptName: '质量保证部', signDate: '', examScore: '', passFlag: '' },
  ],
  rc1: [
    { id: 'p11', userName: '张三', deptName: '质量管理部', signDate: fmt(now - 5 * day, true), examScore: '', passFlag: '' },
    { id: 'p12', userName: '李四', deptName: '分析实验室', signDate: fmt(now - 5 * day, true), examScore: '', passFlag: '' },
    { id: 'p13', userName: '王芳', deptName: '文件管理组', signDate: fmt(now - 5 * day + 6 * 60 * 1000, true), examScore: '', passFlag: '' },
    { id: 'p14', userName: '刘洋', deptName: '质量保证部', signDate: fmt(now - 5 * day + 12 * 60 * 1000, true), examScore: '', passFlag: '' },
    { id: 'p15', userName: '陈明', deptName: '生产一部', signDate: fmt(now - 5 * day + 15 * 60 * 1000, true), examScore: '', passFlag: '' },
  ],
  rc2: [
    { id: 'p21', userName: '孔德敏', deptName: '分析实验室', signDate: fmt(now - 8 * day + 14 * 60 * 60 * 1000, true), examScore: 90, passFlag: '1' },
    { id: 'p22', userName: '马超', deptName: '分析实验室', signDate: fmt(now - 8 * day + 14.1 * 60 * 60 * 1000, true), examScore: 82, passFlag: '1' },
    { id: 'p23', userName: '徐倩', deptName: '方法开发组', signDate: fmt(now - 8 * day + 14.2 * 60 * 60 * 1000, true), examScore: 65, passFlag: '0' },
    { id: 'p24', userName: '蔡磊', deptName: '分析实验室', signDate: fmt(now - 8 * day + 14.3 * 60 * 60 * 1000, true), examScore: 95, passFlag: '1' },
  ],
  rc3: [
    { id: 'p31', userName: '季远达', deptName: '质量保证部', signDate: fmt(now - 12 * day + 10 * 60 * 60 * 1000, true), examScore: '', passFlag: '' },
    { id: 'p32', userName: '唐敏', deptName: '偏差调查组', signDate: fmt(now - 12 * day + 10.1 * 60 * 60 * 1000, true), examScore: '', passFlag: '' },
    { id: 'p33', userName: '潘杰', deptName: '生产一部', signDate: fmt(now - 12 * day + 10.2 * 60 * 60 * 1000, true), examScore: '', passFlag: '' },
  ],
  rc4: [
    { id: 'p41', userName: '梁波', deptName: '质量保证部', signDate: fmt(now - 20 * day + 9 * 60 * 60 * 1000, true), examScore: 88, passFlag: '1' },
    { id: 'p42', userName: '谢娜', deptName: '变更控制组', signDate: fmt(now - 20 * day + 9.1 * 60 * 60 * 1000, true), examScore: 91, passFlag: '1' },
    { id: 'p43', userName: '韩冬', deptName: '生产二部', signDate: fmt(now - 20 * day + 9.2 * 60 * 60 * 1000, true), examScore: 70, passFlag: '1' },
    { id: 'p44', userName: '曹颖', deptName: '文件管理组', signDate: fmt(now - 20 * day + 9.3 * 60 * 60 * 1000, true), examScore: 55, passFlag: '0' },
  ],
  rc5: [
    { id: 'p51', userName: '李俊佳', deptName: '稳定性组', signDate: '2020-09-17 16:02:00', examScore: '', passFlag: '' },
    { id: 'p52', userName: '周莉', deptName: '分析实验室', signDate: '2020-09-17 16:03:00', examScore: '', passFlag: '' },
    { id: 'p53', userName: '梁伟星', deptName: '质量保证部', signDate: '2020-09-17 16:05:00', examScore: '', passFlag: '' },
    { id: 'p54', userName: '陈晓明', deptName: '研发中心', signDate: '2020-09-17 16:08:00', examScore: '', passFlag: '' },
    { id: 'p55', userName: '王芳', deptName: '稳定性组', signDate: '2020-09-17 16:10:00', examScore: '', passFlag: '' },
    { id: 'p56', userName: '张敏', deptName: 'QC实验室', signDate: '2020-09-17 16:12:00', examScore: '', passFlag: '' },
    { id: 'p57', userName: '刘洋', deptName: '生产一部', signDate: '2020-09-17 16:15:00', examScore: '', passFlag: '' },
    { id: 'p58', userName: '赵强', deptName: '仓储部', signDate: '', examScore: '', passFlag: '' },
  ],
  rc6: [
    { id: 'p61', userName: '黄援花', deptName: '稳定性组', signDate: fmt(now - 3 * day + 9.5 * 60 * 60 * 1000, true), examScore: '', passFlag: '' },
    { id: 'p62', userName: '宋佳', deptName: '稳定性组', signDate: fmt(now - 3 * day + 9.6 * 60 * 60 * 1000, true), examScore: '', passFlag: '' },
    { id: 'p63', userName: '姜涛', deptName: '质量保证部', signDate: fmt(now - 3 * day + 9.7 * 60 * 60 * 1000, true), examScore: '', passFlag: '' },
  ],
  rc7: [
    { id: 'p71', userName: '李四', deptName: '分析实验室', signDate: fmt(now - 40 * day + 10 * 60 * 60 * 1000, true), examScore: 86, passFlag: '1' },
    { id: 'p72', userName: '周强', deptName: 'EHS', signDate: fmt(now - 40 * day + 10.1 * 60 * 60 * 1000, true), examScore: 78, passFlag: '1' },
    { id: 'p73', userName: '吴倩', deptName: '生产一部', signDate: fmt(now - 40 * day + 10.2 * 60 * 60 * 1000, true), examScore: 62, passFlag: '0' },
    { id: 'p74', userName: '郑浩', deptName: '工程部', signDate: fmt(now - 40 * day + 10.3 * 60 * 60 * 1000, true), examScore: 90, passFlag: '1' },
    { id: 'p75', userName: '冯蕾', deptName: '仓储部', signDate: fmt(now - 40 * day + 10.4 * 60 * 60 * 1000, true), examScore: 84, passFlag: '1' },
  ],
  rc8: [
    { id: 'p81', userName: 'mkj', deptName: '测试组', signDate: fmt(now - 60 * day, true), examScore: 0, passFlag: '0' },
    { id: 'p82', userName: '测试员A', deptName: '测试组', signDate: fmt(now - 60 * day, true), examScore: 80, passFlag: '1' },
    { id: 'p83', userName: '测试员B', deptName: '质量管理部', signDate: fmt(now - 60 * day + 5 * 60 * 1000, true), examScore: 72, passFlag: '1' },
  ],
};
/** 查看页扩展明细(按 id) */
const detailExtra: Record<string, Partial<TmsRecordDetail>> = {
  rc0c: {
    closeTime: fmt(now - 1 * day + 18 * 60 * 60 * 1000),
    keyPoints: '取样操作规范、样品标识、取样记录填写要点',
    placeName: '取样室 / 在线学习平台',
    trainees: '赵丽,孙浩,钱芳,吴磊,郑雪,冯涛',
    remark: '',
    attachments: [
      { id: 'a0c1', name: '取样员岗位操作规程.pdf', url: '#' },
      { id: 'a0c2', name: '取样记录填写示例.pdf', url: '#' },
    ],
  },
  rc5: {
    subject:
      '硝酸奥司他韦干混悬剂, 6mg/ml (360mg/瓶) 稳定性标准 (ST1921B6m360ct, Rev01)\n硝酸奥司他韦干混悬剂, 6mg/ml (15mg/袋) 稳定性标准 (ST1921B6m15ct, Rev01)',
    startTime: '2020-09-17 16:00',
    endTime: '2020-09-17 17:30',
    closeTime: '2020-09-24 17:30',
    category: '临时培训',
    trainMode: '在线学习',
    trainType: '在线学习',
    trainerName: '周莉华',
    evalMode: '无需考核',
    keyPoints: '',
    placeName: '',
    trainees:
      '李俊佳,周莉,梁伟星,陈晓明,王芳,张敏,刘洋,赵强,孙丽,周杰,吴倩,郑浩,冯蕾,曹阳,彭静,徐磊',
    remark: '',
    attachments: [
      {
        id: 'a1',
        name: '硝酸奥司他韦干混悬剂, 6mg/ml (360mg/瓶) 稳定性标准 (ST1921B6m360ct, Rev01).pdf',
        url: '#',
      },
      {
        id: 'a2',
        name: '硝酸奥司他韦干混悬剂, 6mg/ml (15mg/袋) 稳定性标准 (ST1921B6m15ct, Rev01).pdf',
        url: '#',
      },
    ],
  },
};
function matchMode(item: TmsRecordListItem, mode?: RecordListMode) {
  if (mode === 'ready') return item.archiveStatus === 'ready';
  if (mode === 'archived') return item.archiveStatus === 'archived';
  // 培训记录主列表:进行中 / 待归档
  return item.archiveStatus === 'pending';
}
export function mockQueryRecords(params: TmsRecordPageQuery = {}) {
  const {
    currentPage = 1,
    pageSize = 20,
    keyword = '',
    recordNo = '',
    category = '',
    trainerName = '',
    listMode = 'main',
    archiveStatus,
  } = params;
  let list = store.filter((x) => matchMode(x, listMode));
  if (archiveStatus) list = list.filter((x) => x.archiveStatus === archiveStatus);
  if (recordNo) list = list.filter((x) => x.recordNo.includes(recordNo));
  if (category) list = list.filter((x) => x.category.includes(category));
  if (trainerName) list = list.filter((x) => x.trainerName.includes(trainerName));
  if (keyword) {
    const k = keyword.trim().toLowerCase();
    list = list.filter(
      (x) =>
        x.recordNo.toLowerCase().includes(k) ||
        x.subject.toLowerCase().includes(k) ||
        x.trainerName.toLowerCase().includes(k) ||
        x.category.toLowerCase().includes(k),
    );
  }
  const start = (currentPage - 1) * pageSize;
  const pageList = list.slice(start, start + pageSize);
  return delay({
    list: pageList,
    pagination: { total: list.length, currentPage, pageSize },
  });
}
export async function mockGetRecordDetail(id: string) {
  await delay(null);
  const row = store.find((x) => x.id === id);
  if (!row) throw new Error('培训记录不存在');
  const extra = detailExtra[id] || {};
  const signs = signStore[id] || [];
  const participants = participantStore[id] || [];
  const trainees =
    extra.trainees || participants.map((p) => p.userName).join(',') || '';
  return {
    ...row,
    taskId: `task-${id}`,
    taskNo: row.recordNo,
    closeTime: row.endTime || '',
    keyPoints: '',
    placeName: '',
    remark: '',
    attachments: [],
    trainMode: row.trainMode || row.trainType,
    signList: signs,
    ...extra,
    // 名单与培训对象以 store 为准,避免被 extra 空值覆盖
    trainees,
    participants,
  } as TmsRecordDetail;
}
export async function mockUpdateRecord(id: string, data: Partial<TmsRecordListItem>) {
  await delay(null);
  const row = store.find((x) => x.id === id);
  if (!row) throw new Error('培训记录不存在');
  Object.assign(row, data);
  return true;
}
export async function mockArchiveRecords(ids: string[]) {
  await delay(null);
  let count = 0;
  for (const id of ids) {
    const row = store.find((x) => x.id === id);
    if (!row) continue;
    if (row.archiveStatus === 'archived') continue;
    row.archiveStatus = 'archived';
    count += 1;
  }
  if (!count) throw new Error('没有可归档的记录');
  return count;
}
export async function mockDeleteRecords(ids: string[]) {
  await delay(null);
  let count = 0;
  for (const id of ids) {
    const idx = store.findIndex((x) => x.id === id);
    if (idx < 0) continue;
    if (store[idx].archiveStatus === 'archived') {
      throw new Error(`已归档记录「${store[idx].recordNo}」不可删除`);
    }
    store.splice(idx, 1);
    count += 1;
  }
  if (!count) throw new Error('请选择要删除的记录');
  return count;
}
apps/jnpf-web-apps-main/src/views/x/tms/record/types.ts
New file
@@ -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[];
}
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/constants.ts
New file
@@ -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 ?? '-';
}
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/exam.vue
New file
@@ -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;multi 存 label[] */
const answers = reactive<Record<string, string | string[]>>({});
const submitted = ref(false);
const scoreText = ref('');
const current = computed(() => paper.value?.questions?.[currentIndex.value]);
const total = computed(() => paper.value?.questions?.length || 0);
onMounted(() => {
  const raw = sessionStorage.getItem('tms_self_test_paper');
  if (!raw) {
    createMessage.warning('请先设置抽题条件');
    router.replace('/tms/selfTest');
    return;
  }
  try {
    paper.value = JSON.parse(raw);
  } catch {
    router.replace('/tms/selfTest');
  }
});
function stripHtml(html?: string) {
  if (!html) return '';
  return html.replace(/<[^>]+>/g, '').replace(/&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(`检测完成,正确 ${right} 题,共 ${qs.length} 题`);
}
</script>
<template>
  <div class="jnpf-content-wrapper">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-exam-page" v-if="paper">
        <div class="tms-exam-header">
          <div>
            <div class="text-base font-medium">自我检测 · {{ paper.bankName }}</div>
            <div class="mt-1 text-gray-400 text-sm">
              第 {{ currentIndex + 1 }} / {{ total }} 题
              <span v-if="submitted" class="ml-3 text-primary">得分:{{ scoreText }}</span>
            </div>
          </div>
          <a-space>
            <a-button @click="goBack">返回设置</a-button>
            <a-button type="primary" :disabled="submitted" @click="handleSubmit">交卷</a-button>
          </a-space>
        </div>
        <div v-if="current" class="tms-exam-body">
          <div class="mb-3 text-sm text-gray-500">
            {{ labelOfType(current.questionType) }}
          </div>
          <div class="stem mb-4">{{ stripHtml(current.stem) }}</div>
          <!-- 单选 / 判断 -->
          <a-radio-group
            v-if="current.questionType === 'single' || current.questionType === 'judge'"
            v-model:value="answers[current.id]"
            class="!flex !flex-col gap-3"
            :disabled="submitted"
          >
            <a-radio v-for="opt in current.options" :key="opt.optionLabel" :value="opt.optionLabel">
              {{ opt.optionLabel }}. {{ opt.optionContent }}
            </a-radio>
          </a-radio-group>
          <!-- 多选 -->
          <a-checkbox-group
            v-else-if="current.questionType === 'multi'"
            v-model:value="answers[current.id]"
            class="!flex !flex-col gap-3"
            :disabled="submitted"
          >
            <a-checkbox v-for="opt in current.options" :key="opt.optionLabel" :value="opt.optionLabel">
              {{ opt.optionLabel }}. {{ opt.optionContent }}
            </a-checkbox>
          </a-checkbox-group>
          <div v-if="submitted" class="mt-4 text-sm" :class="isCorrect(current) ? 'text-green-600' : 'text-red-500'">
            {{ isCorrect(current) ? '回答正确' : '回答错误' }}
            · 正确答案:
            {{ current.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel).join('、') }}
          </div>
        </div>
        <div class="tms-exam-footer">
          <a-button :disabled="currentIndex <= 0" @click="goPrev">上一题</a-button>
          <a-button :disabled="currentIndex >= total - 1" @click="goNext">下一题</a-button>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-exam-page {
  background: #fff;
  padding: 20px 24px;
  height: 100%;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.tms-exam-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-bottom: 12px;
  margin-bottom: 16px;
  border-bottom: 1px solid #f0f0f0;
  flex-shrink: 0;
}
.tms-exam-body {
  flex: 1;
  min-height: 0;
  overflow: auto;
}
.stem {
  font-size: 15px;
  line-height: 1.7;
  color: rgba(0, 0, 0, 0.88);
}
.tms-exam-footer {
  flex-shrink: 0;
  display: flex;
  gap: 12px;
  justify-content: center;
  padding-top: 16px;
  border-top: 1px solid #f0f0f0;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/index.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/mock.ts
New file
@@ -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,
  });
}
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/types.ts
New file
@@ -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' }[];
}
apps/jnpf-web-apps-main/src/views/x/tms/signMode/Form.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/signMode/constants.ts
New file
@@ -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 || '-';
}
apps/jnpf-web-apps-main/src/views/x/tms/signMode/index.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/signMode/mock.ts
New file
@@ -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;
}
apps/jnpf-web-apps-main/src/views/x/tms/signMode/types.ts
New file
@@ -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;
}
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/index.vue
New file
@@ -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 || '加载培训档案失败');
  } finally {
    loading.value = false;
  }
}
</script>
<template>
  <div class="jnpf-content-wrapper tms-archive-page">
    <div class="jnpf-content-wrapper-center tms-archive-center">
      <div class="jnpf-content-wrapper-content tms-archive-wrap">
        <a-spin :spinning="loading" class="archive-spin">
          <div v-if="detail" class="archive-inner">
            <section class="archive-block">
              <div class="section-title">个人培训档案</div>
              <ADescriptions
                bordered
                :column="{ xs: 1, sm: 2, md: 3 }"
                size="middle"
                class="archive-desc"
              >
                <ADescriptionsItem label="档案编号">{{ detail.profile.archiveNo }}</ADescriptionsItem>
                <ADescriptionsItem label="建档时间">{{ detail.profile.archiveDate }}</ADescriptionsItem>
                <ADescriptionsItem label="员工姓名">{{ detail.profile.userName }}</ADescriptionsItem>
                <ADescriptionsItem label="所属部门">{{ detail.profile.deptName || '无' }}</ADescriptionsItem>
                <ADescriptionsItem label="主要岗位">{{ detail.profile.mainPostName || '无' }}</ADescriptionsItem>
                <ADescriptionsItem label="兼职岗位">{{ detail.profile.partPostName || '无' }}</ADescriptionsItem>
              </ADescriptions>
            </section>
            <section class="archive-block">
              <div class="section-title">工作履历</div>
              <ATable
                size="middle"
                bordered
                row-key="id"
                :columns="workColumns"
                :data-source="detail.workList"
                :pagination="false"
                :locale="{ emptyText: '暂无工作履历' }"
              />
            </section>
            <section class="archive-block archive-block-grow">
              <div class="section-title">
                培训目录
                <span class="section-extra">共 {{ detail.catalogList.length }} 条</span>
              </div>
              <div class="catalog-table-wrap">
                <ATable
                  size="middle"
                  bordered
                  row-key="id"
                  :columns="catalogColumns"
                  :data-source="detail.catalogList"
                  :pagination="false"
                  :scroll="{ x: 1280 }"
                  :locale="{ emptyText: '暂无培训记录' }"
                >
                  <template #bodyCell="{ column, record }">
                    <template v-if="column.key === 'passFlag'">
                      <span v-if="record.passFlag === '1'" class="pass-yes">合格</span>
                      <span v-else-if="record.passFlag === '0'" class="pass-no">不合格</span>
                      <span v-else>-</span>
                    </template>
                  </template>
                </ATable>
              </div>
            </section>
          </div>
        </a-spin>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-archive-page {
  height: 100%;
  min-height: 0;
}
.tms-archive-center {
  height: 100% !important;
  min-height: 0 !important;
  display: flex;
  flex-direction: column;
}
.tms-archive-wrap {
  display: flex !important;
  flex-direction: column;
  flex: 1 1 0 !important;
  min-height: 0 !important;
  height: 100%;
  overflow: auto !important;
  background: #fff;
  padding: 0;
}
.archive-spin {
  display: block;
  flex: 1;
  min-height: 100%;
  width: 100%;
}
.archive-spin :deep(.ant-spin-container) {
  min-height: 100%;
  width: 100%;
}
.archive-inner {
  display: flex;
  flex-direction: column;
  min-height: 100%;
  width: 100%;
  box-sizing: border-box;
  padding: 16px 20px 20px;
  gap: 16px;
}
.archive-block {
  flex-shrink: 0;
  width: 100%;
}
.archive-block-grow {
  flex: 1 1 auto;
  width: 100%;
  display: flex;
  flex-direction: column;
}
.section-title {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 15px;
  font-weight: 600;
  margin-bottom: 10px;
  padding-left: 10px;
  border-left: 3px solid var(--primary-color, #1890ff);
  line-height: 1.2;
}
.section-extra {
  font-size: 12px;
  font-weight: 400;
  color: rgba(0, 0, 0, 0.45);
}
.archive-desc {
  width: 100%;
}
.archive-desc :deep(.ant-descriptions-view) {
  width: 100%;
  table-layout: fixed;
}
.archive-desc :deep(.ant-descriptions-item-label) {
  width: 110px;
  background: #fafafa;
  color: rgba(0, 0, 0, 0.65);
}
.archive-desc :deep(.ant-descriptions-item-content) {
  word-break: break-all;
}
.catalog-table-wrap {
  width: 100%;
  flex: 1;
}
.catalog-table-wrap :deep(.ant-table) {
  width: 100%;
}
.pass-yes {
  color: #52c41a;
  font-weight: 500;
}
.pass-no {
  color: #ff4d4f;
  font-weight: 500;
}
:deep(.ant-table-thead > tr > th) {
  background: #fafafa !important;
  font-weight: 600;
}
:deep(.ant-table-wrapper) {
  width: 100%;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/mock.ts
New file
@@ -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 生成稳定档案号,便于同一账号每次一致
  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',
      },
    ],
  });
}
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/types.ts
New file
@@ -0,0 +1,40 @@
/** 个人培训档案-基本信息 */
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[];
}
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/Form.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/constants.ts
New file
@@ -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 || '-';
}
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/index.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/mock.ts
New file
@@ -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;
}
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/types.ts
New file
@@ -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;
}
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/Catalog.vue
New file
@@ -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>
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/index.vue
New file
@@ -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: '档案编号', dataIndex: 'archiveNo', width: 130 },
  { title: '进岗时间', dataIndex: 'postDate', width: 120 },
  { title: '姓名', dataIndex: 'userName', width: 100 },
  { title: '部门', dataIndex: 'deptName', minWidth: 120 },
  { title: '岗位', dataIndex: 'postName', width: 120 },
  { title: '兼职岗位', dataIndex: 'partPostName', width: 120 },
  { title: '专业', dataIndex: 'major', width: 100 },
  { title: '学历', dataIndex: 'education', width: 90 },
  { title: '技术职称', dataIndex: 'techTitle', width: 110 },
  { title: '入职时间', dataIndex: 'hireDate', width: 120 },
  { title: '离职日期', dataIndex: 'leaveDate', width: 120 },
];
const [registerTable, { getSelectRows }] = useVxeTable({
  api: fetchList,
  columns,
  immediate: true,
  rowKey: 'id',
  rowSelection: { type: 'checkbox' },
  useSearchForm: true,
  formConfig: {
    baseColProps: { span: 6 },
    compact: true,
    schemas: [
      {
        field: 'keyword',
        label: '关键词',
        component: 'Input',
        componentProps: { placeholder: '姓名/部门/档案编号', submitOnPressEnter: true },
      },
      {
        field: 'archiveNo',
        label: '档案编号',
        component: 'Input',
        componentProps: { placeholder: '请输入档案编号', submitOnPressEnter: true },
      },
    ],
  },
  actionColumn: {
    width: 100,
    title: '操作',
    dataIndex: 'action',
    fixed: 'right',
  },
});
async function fetchList(params: Record<string, any>) {
  return { data: await getTrainRecordList(params) };
}
function handleView(record: TrainRecordListItem) {
  router.push(`/tms/trainRecord/catalog/${record.id}`);
}
function handleViewSelected() {
  const rows = (getSelectRows?.() || []) as TrainRecordListItem[];
  if (!rows.length) {
    createMessage.warning('请先勾选一条记录');
    return;
  }
  if (rows.length > 1) {
    createMessage.warning('请只勾选一条记录查看');
    return;
  }
  handleView(rows[0]);
}
function getTableActions(record: TrainRecordListItem): ActionItem[] {
  return [{ label: '查看', onClick: handleView.bind(null, record) }];
}
</script>
<template>
  <div class="jnpf-content-wrapper">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content">
        <BasicVxeTable @register="registerTable">
          <template #tableTitle>
            <a-space>
              <a-button type="primary" @click="handleViewSelected">查看</a-button>
              <span class="text-gray-400 text-sm">个人培训记录表,选择人员查看培训目录。</span>
            </a-space>
          </template>
          <template #action="{ record }">
            <TableAction :actions="getTableActions(record)" />
          </template>
        </BasicVxeTable>
      </div>
    </div>
  </div>
</template>
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/mock.ts
New file
@@ -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,
  });
}
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/types.ts
New file
@@ -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[];
}