82a74ba0402ab546ba524d23ce62198c4d00d2f7..8534025c45b4736975730678b9ccf23570a75f95
21 小时以前 liuyu
feat(tms): 新增试卷、培训任务、个人任务页面
853402 对比 | 目录
21 小时以前 liuyu
feat(tms): 完成课程考试自测等页面与接口对接
93c133 对比 | 目录
29个文件已添加
53个文件已修改
8个文件已删除
10714 ■■■■ 已修改文件
apps/jnpf-web-apps-main/src/api/x/tms/course.ts 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/evalMode.ts 37 ●●●● 补丁 | 查看 | 原始文档 | 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 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/onlineExam.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/paper.ts 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/personTask.ts 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/place.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/question.ts 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/record.ts 35 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/selfTest.ts 49 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/signMode.ts 37 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/task.ts 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/trainArchive.ts 24 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/trainMode.ts 37 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/trainRecord.ts 25 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/basic.ts 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsPaper.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsQuestion.ts 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsSelfTest.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTask.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/course/Form.vue 79 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/course/constants.ts 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/course/index.vue 117 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/course/mock.ts 202 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/Form.vue 51 ●●●● 补丁 | 查看 | 原始文档 | 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 64 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/mock.ts 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Mark.vue 57 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Session.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/constants.ts 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/index.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examScore/Detail.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examScore/Paper.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/examScore/index.vue 98 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/Detail.vue 192 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/Wrong.vue 259 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/constants.ts 63 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue 976 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/index.vue 109 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/mock.ts 82 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/types.ts 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/paper/Detail.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/paper/Form.vue 676 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/paper/components/QuestionPicker.vue 229 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/paper/constants.ts 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/paper/index.vue 256 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/paper/types.ts 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/personTask/Detail.vue 205 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/personTask/Learn.vue 410 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/personTask/constants.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/personTask/index.vue 200 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/personTask/types.ts 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/Detail.vue 254 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/Form.vue 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/constants.ts 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/index.vue 127 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/question/mock.ts 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/Detail.vue 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/Edit.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/Sign.vue 93 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/constants.ts 53 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/index.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/record/types.ts 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/Detail.vue 214 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/exam.vue 825 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/index.vue 45 ●●●● 补丁 | 查看 | 原始文档 | 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/records.vue 355 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/types.ts 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/shared/dic.ts 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/shared/page.css 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/shared/ui.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/signMode/Form.vue 43 ●●●● 补丁 | 查看 | 原始文档 | 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 60 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/signMode/mock.ts 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/task/Detail.vue 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/task/Form.vue 959 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/task/constants.ts 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/task/index.vue 325 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/task/types.ts 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/index.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/Form.vue 89 ●●●● 补丁 | 查看 | 原始文档 | 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 77 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/mock.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/Catalog.vue 43 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/index.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
apps/jnpf-web-apps-main/src/api/x/tms/course.ts
@@ -1,49 +1,44 @@
import type { CourseItem, CoursePageQuery, PaperOption } from '#/views/x/tms/course/types';
import { defHttp } from '#/api/request';
import {
  mockDeleteCourse,
  mockGetCourse,
  mockPaperOptions,
  mockQueryCourses,
  mockSaveCourse,
  mockSetCourseEnabled,
} from '#/views/x/tms/course/mock';
const USE_MOCK = true;
/** 正式路径:/api/tms/course/** */
const prefix = '/api/tms/course';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getCourseList(params: CoursePageQuery) {
  if (USE_MOCK) return mockQueryCourses(params);
  return defHttp.get({ url: `${prefix}/list`, params });
  return unwrapData<{ list: CourseItem[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getCourseInfo(id: string) {
  if (USE_MOCK) return mockGetCourse(id);
  return defHttp.get<CourseItem>({ url: `${prefix}/${id}` });
  return unwrapData<CourseItem>(defHttp.get({ url: `${prefix}/${id}` }));
}
export function getCoursePaperOptions() {
  if (USE_MOCK) return mockPaperOptions();
  return defHttp.get<PaperOption[]>({ url: `${prefix}/paper-options` });
  return unwrapData<PaperOption[]>(defHttp.get({ url: `${prefix}/paper-options` }));
}
export function createCourse(data: Partial<CourseItem>) {
  if (USE_MOCK) return mockSaveCourse(data);
  return defHttp.post({ url: prefix, data });
  return unwrapData<string>(defHttp.post({ url: prefix, data }));
}
export function updateCourse(data: Partial<CourseItem> & { id: string }) {
  if (USE_MOCK) return mockSaveCourse(data);
  return defHttp.put({ url: `${prefix}/${data.id}`, data });
  return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data }));
}
export function setCourseEnabled(id: string, enabled: '0' | '1') {
  if (USE_MOCK) return mockSetCourseEnabled(id, enabled);
  return defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } });
  return unwrapData(defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } }));
}
export function deleteCourse(id: string) {
  if (USE_MOCK) return mockDeleteCourse(id);
  return defHttp.delete({ url: `${prefix}/${id}` });
  return unwrapData(defHttp.delete({ url: `${prefix}/${id}` }));
}
apps/jnpf-web-apps-main/src/api/x/tms/evalMode.ts
@@ -1,43 +1,40 @@
import type { EvalModeItem, EvalModePageQuery } from '#/views/x/tms/evalMode/types';
import { defHttp } from '#/api/request';
import {
  mockDeleteEvalMode,
  mockGetEvalMode,
  mockQueryEvalModes,
  mockSaveEvalMode,
  mockSetEvalModeEnabled,
} from '#/views/x/tms/evalMode/mock';
const USE_MOCK = true;
/** 正式路径:/api/tms/eval-mode/** */
const prefix = '/api/tms/eval-mode';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getEvalModeList(params: EvalModePageQuery) {
  if (USE_MOCK) return mockQueryEvalModes(params);
  return defHttp.get({ url: `${prefix}/list`, params });
  return unwrapData<{ list: EvalModeItem[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getEvalModeInfo(id: string) {
  if (USE_MOCK) return mockGetEvalMode(id);
  return defHttp.get<EvalModeItem>({ url: `${prefix}/${id}` });
  return unwrapData<EvalModeItem>(defHttp.get({ url: `${prefix}/${id}` }));
}
export function createEvalMode(data: Partial<EvalModeItem>) {
  if (USE_MOCK) return mockSaveEvalMode(data);
  return defHttp.post({ url: prefix, data });
  return unwrapData<string>(defHttp.post({ url: prefix, data }));
}
export function updateEvalMode(data: Partial<EvalModeItem> & { id: string }) {
  if (USE_MOCK) return mockSaveEvalMode(data);
  return defHttp.put({ url: `${prefix}/${data.id}`, data });
  return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data }));
}
export function setEvalModeEnabled(id: string, enabled: '0' | '1') {
  if (USE_MOCK) return mockSetEvalModeEnabled(id, enabled);
  return defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } });
  return unwrapData(defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } }));
}
export function deleteEvalMode(id: string) {
  if (USE_MOCK) return mockDeleteEvalMode(id);
  return defHttp.delete({ url: `${prefix}/${id}` });
  return unwrapData(defHttp.delete({ url: `${prefix}/${id}` }));
}
apps/jnpf-web-apps-main/src/api/x/tms/examGrade.ts
@@ -1,44 +1,50 @@
import type {
  GradeExamDetail,
  GradeExamListItem,
  GradeSessionListItem,
  GradeSessionPageQuery,
  GradeSubmitPayload,
} from '#/views/x/tms/examGrade/types';
import { defHttp } from '#/api/request';
import {
  mockGetGradeExamDetail,
  mockGetGradeSession,
  mockQueryGradeExams,
  mockQueryGradeSessions,
  mockSubmitGrade,
} from '#/views/x/tms/examGrade/mock';
/** 后端未就绪前走 mock;联调后改为 false */
const USE_MOCK = true;
const prefix = '/api/tms/exam-grade';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getGradeSessionList(params: GradeSessionPageQuery) {
  if (USE_MOCK) return mockQueryGradeSessions(params);
  return defHttp.get({ url: `${prefix}/sessions`, params });
  return unwrapData<{ list: GradeSessionListItem[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/sessions`, params }),
  );
}
export function getGradeSessionInfo(id: string) {
  if (USE_MOCK) return mockGetGradeSession(id);
  return defHttp.get<GradeSessionListItem>({ url: `${prefix}/sessions/${id}` });
  return unwrapData<GradeSessionListItem>(defHttp.get({ url: `${prefix}/sessions/${id}` }));
}
export function getGradeExamList(sessionId: string) {
  if (USE_MOCK) return mockQueryGradeExams(sessionId);
  return defHttp.get({ url: `${prefix}/sessions/${sessionId}/exams` });
  return unwrapData<GradeExamListItem[]>(
    defHttp.get({ url: `${prefix}/sessions/${sessionId}/exams` }),
  );
}
export function getGradeExamDetail(examId: string) {
  if (USE_MOCK) return mockGetGradeExamDetail(examId);
  return defHttp.get<GradeExamDetail>({ url: `${prefix}/exams/${examId}` });
  return unwrapData<GradeExamDetail>(defHttp.get({ url: `${prefix}/exams/${examId}` }));
}
export function submitGrade(data: GradeSubmitPayload) {
  if (USE_MOCK) return mockSubmitGrade(data);
  return defHttp.post({ url: `${prefix}/exams/${data.examId}/submit`, data });
  return unwrapData<{
    examId: string;
    subjectiveScore?: number;
    totalScore?: number;
    passFlag?: string;
    gradeStatus?: string;
    nextExamId?: string;
  }>(defHttp.post({ url: `${prefix}/exams/${data.examId}/submit`, data }));
}
apps/jnpf-web-apps-main/src/api/x/tms/examScore.ts
@@ -1,27 +1,31 @@
import type { ExamPaperView, ExamScoreDetail, ExamScorePageQuery } from '#/views/x/tms/examScore/types';
import type { ExamPaperView, ExamScoreDetail, ExamScoreDetailRow, ExamScorePageQuery, ExamScoreSummaryItem } from '#/views/x/tms/examScore/types';
import { defHttp } from '#/api/request';
import {
  mockGetExamPaperView,
  mockGetExamScoreDetail,
  mockQueryExamScores,
} from '#/views/x/tms/examScore/mock';
/** 后端未就绪前走 mock;联调后改为 false */
const USE_MOCK = true;
const prefix = '/api/tms/exam-score';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getExamScoreList(params: ExamScorePageQuery) {
  if (USE_MOCK) return mockQueryExamScores(params);
  return defHttp.get({ url: `${prefix}/list`, params });
  return unwrapData<{ list: ExamScoreSummaryItem[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getExamScoreDetail(id: string) {
  if (USE_MOCK) return mockGetExamScoreDetail(id);
  return defHttp.get<ExamScoreDetail>({ url: `${prefix}/${id}` });
  return unwrapData<ExamScoreDetail>(defHttp.get({ url: `${prefix}/${id}` }));
}
export function getExamPaperView(examId: string) {
  if (USE_MOCK) return mockGetExamPaperView(examId);
  return defHttp.get<ExamPaperView>({ url: `${prefix}/paper/${examId}` });
  return unwrapData<ExamPaperView>(defHttp.get({ url: `${prefix}/paper/${examId}` }));
}
export function exportExamScores(ids: string[]) {
  return unwrapData<ExamScoreDetailRow[]>(defHttp.post({ url: `${prefix}/export`, data: { ids } }));
}
apps/jnpf-web-apps-main/src/api/x/tms/onlineExam.ts
@@ -1,37 +1,51 @@
import type {
  MyPaperListItem,
  MyPaperPageQuery,
  OnlineExamDetail,
  OnlineExamPaper,
  OnlineExamSubmitResult,
} from '#/views/x/tms/onlineExam/types';
import { defHttp } from '#/api/request';
import {
  mockGetMyPaperDetail,
  mockQueryMyPapers,
  mockStartExam,
  mockSubmitExam,
} from '#/views/x/tms/onlineExam/mock';
/** 后端未就绪前走 mock;联调后改为 false */
const USE_MOCK = true;
const prefix = '/api/tms/online-exam';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getMyPaperList(params: MyPaperPageQuery) {
  if (USE_MOCK) return mockQueryMyPapers(params);
  return defHttp.get({ url: `${prefix}/my-papers`, params });
  return unwrapData<{ list: MyPaperListItem[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/my-papers`, params }),
  );
}
export function getMyPaperDetail(id: string) {
  if (USE_MOCK) return mockGetMyPaperDetail(id);
  return defHttp.get<OnlineExamDetail>({ url: `${prefix}/my-papers/${id}` });
  return unwrapData<OnlineExamDetail>(defHttp.get({ url: `${prefix}/my-papers/${id}` }));
}
export function getExamReview(examId: string) {
  return unwrapData<OnlineExamDetail>(defHttp.get({ url: `${prefix}/exams/${examId}` }));
}
export function startOnlineExam(myPaperId: string) {
  if (USE_MOCK) return mockStartExam(myPaperId);
  return defHttp.post<OnlineExamPaper>({ url: `${prefix}/start`, data: { myPaperId } });
  return unwrapData<OnlineExamPaper>(
    defHttp.post({ url: `${prefix}/start`, data: { myPaperId } }, { errorMessageMode: 'none' }),
  );
}
export function submitOnlineExam(examId: string, data: { score: number; answers: Record<string, string | string[]> }) {
  if (USE_MOCK) return mockSubmitExam(examId, data.score);
  return defHttp.post({ url: `${prefix}/${examId}/submit`, data });
export function saveOnlineExam(examId: string, answers: Record<string, string | string[]>) {
  return unwrapData(
    defHttp.post({ url: `${prefix}/${examId}/save`, data: { answers } }, { errorMessageMode: 'none' }),
  );
}
export function submitOnlineExam(examId: string, answers: Record<string, string | string[]>) {
  return unwrapData<OnlineExamSubmitResult>(
    defHttp.post({ url: `${prefix}/${examId}/submit`, data: { answers } }, { errorMessageMode: 'none' }),
  );
}
apps/jnpf-web-apps-main/src/api/x/tms/paper.ts
New file
@@ -0,0 +1,42 @@
import type { PaperEntity, PaperPageQuery } from '#/views/x/tms/paper/types';
import { defHttp } from '#/api/request';
/** 正式路径:/api/tms/paper/** */
const prefix = '/api/tms/paper';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getPaperList(params: PaperPageQuery) {
  return unwrapData<{ list: PaperEntity[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getPaperInfo(id: string) {
  return unwrapData<PaperEntity>(defHttp.get({ url: `${prefix}/${id}` }));
}
export function createPaper(data: PaperEntity) {
  return unwrapData<string>(defHttp.post({ url: prefix, data }));
}
export function updatePaper(data: PaperEntity) {
  return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data }));
}
/** 废弃 = biz_status=invalid */
export function invalidatePaper(id: string) {
  return unwrapData(defHttp.put({ url: `${prefix}/${id}/invalidate` }));
}
/** 启用/停用:open | closed */
export function setPaperStatus(id: string, bizStatus: 'open' | 'closed') {
  return unwrapData(defHttp.put({ url: `${prefix}/${id}/status`, data: { bizStatus } }));
}
apps/jnpf-web-apps-main/src/api/x/tms/personTask.ts
New file
@@ -0,0 +1,31 @@
import type { PersonTaskItem, PersonTaskLearnResult, PersonTaskPageQuery, PersonTaskSignResult } from '#/views/x/tms/personTask/types';
import { defHttp } from '#/api/request';
const prefix = '/api/tms/person-task';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getMyPersonTaskList(params: PersonTaskPageQuery) {
  return unwrapData<{ list: PersonTaskItem[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/mine`, params }),
  );
}
export function getPersonTaskInfo(id: string) {
  return unwrapData<PersonTaskItem>(defHttp.get({ url: `${prefix}/${id}` }));
}
export function markPersonTaskLearned(id: string, seconds: number) {
  return unwrapData<PersonTaskLearnResult>(defHttp.post({ url: `${prefix}/${id}/learn`, data: { seconds } }));
}
export function signPersonTask(id: string) {
  return unwrapData<PersonTaskSignResult>(defHttp.post({ url: `${prefix}/${id}/sign` }));
}
apps/jnpf-web-apps-main/src/api/x/tms/place.ts
New file
@@ -0,0 +1,24 @@
import { defHttp } from '#/api/request';
/** 正式路径:/api/tms/place/** */
const prefix = '/api/tms/place';
export interface PlaceOption {
  id: string;
  placeNo?: string;
  fullName: string;
  placeGroup?: string;
}
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
/** 启用中的培训地点(来自培训地点管理 / tms_place) */
export function getPlaceOptions() {
  return unwrapData<PlaceOption[]>(defHttp.get({ url: `${prefix}/options` }));
}
apps/jnpf-web-apps-main/src/api/x/tms/question.ts
@@ -1,55 +1,46 @@
import type { QuestionBankOption, QuestionEntity, QuestionPageQuery } from '#/views/x/tms/question/types';
import { defHttp } from '#/api/request';
import {
  mockCreateQuestion,
  mockDeleteQuestion,
  mockGetAdmins,
  mockGetBanks,
  mockGetQuestion,
  mockQueryQuestions,
  mockUpdateQuestion,
} from '#/views/x/tms/question/mock';
/**
 * 后端 API 未就绪前走本地 mock;联调 jnpf-tms 后改为 false。
 * 正式路径约定:/api/tms/question/**
 */
const USE_MOCK = true;
/** 正式路径:/api/tms/question/** */
const prefix = '/api/tms/question';
/** defHttp 成功时返回整包 ActionResult,页面需要裸 data */
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getQuestionBanks() {
  if (USE_MOCK) return mockGetBanks();
  return defHttp.get<QuestionBankOption[]>({ url: `${prefix}/banks` });
  return unwrapData<QuestionBankOption[]>(defHttp.get({ url: `${prefix}/banks` }));
}
export function getQuestionAdmins() {
  if (USE_MOCK) return mockGetAdmins();
  return defHttp.get<{ id: string; fullName: string }[]>({ url: `${prefix}/admins` });
  return unwrapData<{ id: string; fullName: string }[]>(defHttp.get({ url: `${prefix}/admins` }));
}
export function getQuestionList(params: QuestionPageQuery) {
  if (USE_MOCK) return mockQueryQuestions(params);
  return defHttp.get({ url: `${prefix}/list`, params });
  return unwrapData<{ list: QuestionEntity[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getQuestionInfo(id: string) {
  if (USE_MOCK) return mockGetQuestion(id);
  return defHttp.get<QuestionEntity>({ url: `${prefix}/${id}` });
  return unwrapData<QuestionEntity>(defHttp.get({ url: `${prefix}/${id}` }));
}
export function createQuestion(data: QuestionEntity) {
  if (USE_MOCK) return mockCreateQuestion(data);
  return defHttp.post({ url: prefix, data });
  return unwrapData<string>(defHttp.post({ url: prefix, data }));
}
export function updateQuestion(data: QuestionEntity) {
  if (USE_MOCK) return mockUpdateQuestion(data);
  return defHttp.put({ url: `${prefix}/${data.id}`, data });
  return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data }));
}
export function deleteQuestion(id: string) {
  if (USE_MOCK) return mockDeleteQuestion(id);
  return defHttp.delete({ url: `${prefix}/${id}` });
/** 废弃 = 逻辑删除 */
export function invalidateQuestion(id: string) {
  return unwrapData(defHttp.put({ url: `${prefix}/${id}/invalidate` }));
}
apps/jnpf-web-apps-main/src/api/x/tms/record.ts
@@ -1,39 +1,36 @@
import type { TmsRecordDetail, TmsRecordPageQuery } from '#/views/x/tms/record/types';
import { defHttp } from '#/api/request';
import {
  mockArchiveRecords,
  mockDeleteRecords,
  mockGetRecordDetail,
  mockQueryRecords,
  mockUpdateRecord,
} from '#/views/x/tms/record/mock';
/** 后端未就绪前走 mock;联调后改为 false */
const USE_MOCK = true;
/** 正式路径:/api/tms/record/** */
const prefix = '/api/tms/record';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getTmsRecordList(params: TmsRecordPageQuery) {
  if (USE_MOCK) return mockQueryRecords(params);
  return defHttp.get({ url: `${prefix}/list`, params });
  return unwrapData<{ list: any[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getTmsRecordDetail(id: string) {
  if (USE_MOCK) return mockGetRecordDetail(id);
  return defHttp.get<TmsRecordDetail>({ url: `${prefix}/${id}` });
  return unwrapData<TmsRecordDetail>(defHttp.get({ url: `${prefix}/${id}` }));
}
export function updateTmsRecord(id: string, data: Record<string, any>) {
  if (USE_MOCK) return mockUpdateRecord(id, data);
  return defHttp.put({ url: `${prefix}/${id}`, data });
  return unwrapData(defHttp.put({ url: `${prefix}/${id}`, data }));
}
export function archiveTmsRecords(ids: string[]) {
  if (USE_MOCK) return mockArchiveRecords(ids);
  return defHttp.post({ url: `${prefix}/archive`, data: { ids } });
  return unwrapData<number>(defHttp.post({ url: `${prefix}/archive`, data: { ids } }));
}
export function deleteTmsRecords(ids: string[]) {
  if (USE_MOCK) return mockDeleteRecords(ids);
  return defHttp.delete({ url: `${prefix}`, data: { ids } });
  return unwrapData(defHttp.delete({ url: `${prefix}`, data: { ids } }));
}
apps/jnpf-web-apps-main/src/api/x/tms/selfTest.ts
@@ -1,19 +1,56 @@
import type { QuestionBankOption } from '#/views/x/tms/question/types';
import type { SelfTestPaper, SelfTestStartPayload } from '#/views/x/tms/selfTest/types';
import type {
  SelfTestPaper,
  SelfTestStartPayload,
  SelfTestSubmitPayload,
  SelfTestSubmitResult,
  SelfTestRecordItem,
} from '#/views/x/tms/selfTest/types';
import { defHttp } from '#/api/request';
import { getQuestionBanks } from '#/api/x/tms/question';
import { mockStartSelfTest } from '#/views/x/tms/selfTest/mock';
/** 后端未就绪前走 mock;联调后改为 false */
const USE_MOCK = true;
/** 正式路径:/api/tms/self-test/** */
const prefix = '/api/tms/self-test';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getSelfTestBanks() {
  return getQuestionBanks() as Promise<QuestionBankOption[]>;
}
export function startSelfTest(data: SelfTestStartPayload) {
  if (USE_MOCK) return mockStartSelfTest(data);
  return defHttp.post<SelfTestPaper>({ url: `${prefix}/start`, data });
  return unwrapData<SelfTestPaper>(defHttp.post({ url: `${prefix}/start`, data }));
}
/** 暂存作答(不交卷,便于继续考试) */
export function saveSelfTest(paperId: string, data: SelfTestSubmitPayload) {
  return unwrapData<string>(defHttp.post({ url: `${prefix}/${paperId}/save`, data }));
}
export function submitSelfTest(paperId: string, data: SelfTestSubmitPayload) {
  return unwrapData<SelfTestSubmitResult>(defHttp.post({ url: `${prefix}/${paperId}/submit`, data }));
}
export function getSelfTestList(params?: {
  currentPage?: number;
  pageSize?: number;
  bankId?: string;
  testStatus?: string;
  startTimeBegin?: string;
  startTimeEnd?: string;
}) {
  return unwrapData<{ list: SelfTestRecordItem[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getSelfTestInfo(id: string) {
  return unwrapData<SelfTestPaper>(defHttp.get({ url: `${prefix}/${id}` }));
}
apps/jnpf-web-apps-main/src/api/x/tms/signMode.ts
@@ -1,43 +1,40 @@
import type { SignModeItem, SignModePageQuery } from '#/views/x/tms/signMode/types';
import { defHttp } from '#/api/request';
import {
  mockDeleteSignMode,
  mockGetSignMode,
  mockQuerySignModes,
  mockSaveSignMode,
  mockSetSignModeEnabled,
} from '#/views/x/tms/signMode/mock';
const USE_MOCK = true;
/** 正式路径:/api/tms/sign-mode/** */
const prefix = '/api/tms/sign-mode';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getSignModeList(params: SignModePageQuery) {
  if (USE_MOCK) return mockQuerySignModes(params);
  return defHttp.get({ url: `${prefix}/list`, params });
  return unwrapData<{ list: SignModeItem[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getSignModeInfo(id: string) {
  if (USE_MOCK) return mockGetSignMode(id);
  return defHttp.get<SignModeItem>({ url: `${prefix}/${id}` });
  return unwrapData<SignModeItem>(defHttp.get({ url: `${prefix}/${id}` }));
}
export function createSignMode(data: Partial<SignModeItem>) {
  if (USE_MOCK) return mockSaveSignMode(data);
  return defHttp.post({ url: prefix, data });
  return unwrapData<string>(defHttp.post({ url: prefix, data }));
}
export function updateSignMode(data: Partial<SignModeItem> & { id: string }) {
  if (USE_MOCK) return mockSaveSignMode(data);
  return defHttp.put({ url: `${prefix}/${data.id}`, data });
  return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data }));
}
export function setSignModeEnabled(id: string, enabled: '0' | '1') {
  if (USE_MOCK) return mockSetSignModeEnabled(id, enabled);
  return defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } });
  return unwrapData(defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } }));
}
export function deleteSignMode(id: string) {
  if (USE_MOCK) return mockDeleteSignMode(id);
  return defHttp.delete({ url: `${prefix}/${id}` });
  return unwrapData(defHttp.delete({ url: `${prefix}/${id}` }));
}
apps/jnpf-web-apps-main/src/api/x/tms/task.ts
New file
@@ -0,0 +1,52 @@
import type { TaskEntity, TaskPageQuery } from '#/views/x/tms/task/types';
import { defHttp } from '#/api/request';
/** 正式路径:/api/tms/task/** */
const prefix = '/api/tms/task';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getTaskList(params: TaskPageQuery) {
  return unwrapData<{ list: TaskEntity[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getTaskInfo(id: string) {
  return unwrapData<TaskEntity>(defHttp.get({ url: `${prefix}/${id}` }));
}
export function createTask(data: Partial<TaskEntity>) {
  return unwrapData<string>(defHttp.post({ url: prefix, data }));
}
export function updateTask(data: Partial<TaskEntity> & { id: string }) {
  return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data }));
}
export function deleteTask(id: string) {
  return unwrapData(defHttp.delete({ url: `${prefix}/${id}` }));
}
export function publishTask(id: string) {
  return unwrapData(defHttp.put({ url: `${prefix}/${id}/publish` }));
}
export function cancelTask(id: string) {
  return unwrapData(defHttp.put({ url: `${prefix}/${id}/cancel` }));
}
export function cancelTaskBatch(ids: string[]) {
  return unwrapData(defHttp.put({ url: `${prefix}/cancelBatch`, data: { ids } }));
}
export function restoreTask(id: string) {
  return unwrapData(defHttp.put({ url: `${prefix}/${id}/restore` }));
}
apps/jnpf-web-apps-main/src/api/x/tms/trainArchive.ts
@@ -1,19 +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;
/** 正式路径:/api/tms/train-archive/** */
const prefix = '/api/tms/train-archive';
export function getMyTrainArchive(user?: {
  userId?: string;
  userName?: string;
  userAccount?: string;
  organizeName?: string;
  positionName?: string;
}) {
  if (USE_MOCK) return mockGetMyTrainArchive(user);
  return defHttp.get<TrainArchiveDetail>({ url: `${prefix}/mine` });
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
/** 当前登录用户的个人培训档案 */
export function getMyTrainArchive() {
  return unwrapData<TrainArchiveDetail>(defHttp.get({ url: `${prefix}/mine` }));
}
apps/jnpf-web-apps-main/src/api/x/tms/trainMode.ts
@@ -1,43 +1,40 @@
import type { TrainModeItem, TrainModePageQuery } from '#/views/x/tms/trainMode/types';
import { defHttp } from '#/api/request';
import {
  mockDeleteTrainMode,
  mockGetTrainMode,
  mockQueryTrainModes,
  mockSaveTrainMode,
  mockSetTrainModeEnabled,
} from '#/views/x/tms/trainMode/mock';
const USE_MOCK = true;
/** 正式路径:/api/tms/train-mode/** */
const prefix = '/api/tms/train-mode';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getTrainModeList(params: TrainModePageQuery) {
  if (USE_MOCK) return mockQueryTrainModes(params);
  return defHttp.get({ url: `${prefix}/list`, params });
  return unwrapData<{ list: TrainModeItem[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getTrainModeInfo(id: string) {
  if (USE_MOCK) return mockGetTrainMode(id);
  return defHttp.get<TrainModeItem>({ url: `${prefix}/${id}` });
  return unwrapData<TrainModeItem>(defHttp.get({ url: `${prefix}/${id}` }));
}
export function createTrainMode(data: Partial<TrainModeItem>) {
  if (USE_MOCK) return mockSaveTrainMode(data);
  return defHttp.post({ url: prefix, data });
  return unwrapData<string>(defHttp.post({ url: prefix, data }));
}
export function updateTrainMode(data: Partial<TrainModeItem> & { id: string }) {
  if (USE_MOCK) return mockSaveTrainMode(data);
  return defHttp.put({ url: `${prefix}/${data.id}`, data });
  return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data }));
}
export function setTrainModeEnabled(id: string, enabled: '0' | '1') {
  if (USE_MOCK) return mockSetTrainModeEnabled(id, enabled);
  return defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } });
  return unwrapData(defHttp.put({ url: `${prefix}/${id}/enabled`, data: { enabled } }));
}
export function deleteTrainMode(id: string) {
  if (USE_MOCK) return mockDeleteTrainMode(id);
  return defHttp.delete({ url: `${prefix}/${id}` });
  return unwrapData(defHttp.delete({ url: `${prefix}/${id}` }));
}
apps/jnpf-web-apps-main/src/api/x/tms/trainRecord.ts
@@ -5,18 +5,29 @@
} from '#/views/x/tms/trainRecord/types';
import { defHttp } from '#/api/request';
import { mockGetTrainRecordCatalog, mockQueryTrainRecords } from '#/views/x/tms/trainRecord/mock';
/** 后端未就绪前走 mock;联调后改为 false */
const USE_MOCK = true;
const prefix = '/api/tms/train-record';
async function unwrapData<T>(promise: Promise<any>): Promise<T> {
  const res = await promise;
  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
    return res.data as T;
  }
  return res as T;
}
export function getTrainRecordList(params: TrainRecordPageQuery) {
  if (USE_MOCK) return mockQueryTrainRecords(params);
  return defHttp.get({ url: `${prefix}/list`, params });
  return unwrapData<{ list: any[]; pagination: Record<string, any> }>(
    defHttp.get({ url: `${prefix}/list`, params }),
  );
}
export function getTrainRecordCatalog(params: TrainRecordCatalogQuery) {
  if (USE_MOCK) return mockGetTrainRecordCatalog(params);
  return defHttp.get<TrainRecordCatalogDetail>({ url: `${prefix}/${params.recordId}/catalog`, params });
  const { recordId, startDate, endDate } = params;
  return unwrapData<TrainRecordCatalogDetail>(
    defHttp.get({
      url: `${prefix}/${recordId}/catalog`,
      params: { startDate, endDate },
    }),
  );
}
apps/jnpf-web-apps-main/src/router/routes/basic.ts
@@ -102,6 +102,39 @@
        },
      },
      {
        path: '/tms/paper/create',
        name: 'TmsPaperCreate',
        component: () => import('#/views/x/tms/paper/Form.vue'),
        meta: {
          title: '新增试卷',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/paper',
        },
      },
      {
        path: '/tms/paper/edit/:id',
        name: 'TmsPaperEdit',
        component: () => import('#/views/x/tms/paper/Form.vue'),
        meta: {
          title: '编辑试卷',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/paper',
        },
      },
      {
        path: '/tms/paper/detail/:id',
        name: 'TmsPaperDetail',
        component: () => import('#/views/x/tms/paper/Detail.vue'),
        meta: {
          title: '试卷详情',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/paper',
        },
      },
      {
        path: '/tms/question/create',
        name: 'TmsQuestionCreate',
        component: () => import('#/views/x/tms/question/Form.vue'),
@@ -124,11 +157,44 @@
        },
      },
      {
        path: '/tms/question/detail/:id',
        name: 'TmsQuestionDetail',
        component: () => import('#/views/x/tms/question/Detail.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/selfTest/records',
        name: 'TmsSelfTestRecords',
        component: () => import('#/views/x/tms/selfTest/records.vue'),
        meta: {
          title: '我的检测记录',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/selfTest',
        },
      },
      {
        path: '/tms/selfTest/detail/:id',
        name: 'TmsSelfTestDetail',
        component: () => import('#/views/x/tms/selfTest/Detail.vue'),
        meta: {
          title: '检测详情',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/selfTest',
@@ -151,6 +217,17 @@
        component: () => import('#/views/x/tms/onlineExam/Detail.vue'),
        meta: {
          title: '考试详情',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/onlineExam',
        },
      },
      {
        path: '/tms/onlineExam/review/:id',
        name: 'TmsOnlineExamReview',
        component: () => import('#/views/x/tms/onlineExam/Wrong.vue'),
        meta: {
          title: '考试回顾',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/onlineExam',
@@ -336,6 +413,71 @@
          ignoreAccess: true,
        },
      },
      {
        path: '/tms/personTask',
        name: 'TmsPersonTaskBasic',
        component: () => import('#/views/x/tms/personTask/index.vue'),
        meta: {
          title: '个人培训任务',
          hideInMenu: true,
          ignoreAccess: true,
        },
      },
      {
        path: '/tms/personTask/detail/:id',
        name: 'TmsPersonTaskDetail',
        component: () => import('#/views/x/tms/personTask/Detail.vue'),
        meta: {
          title: '个人培训任务详情',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/personTask',
        },
      },
      {
        path: '/tms/personTask/learn/:id',
        name: 'TmsPersonTaskLearn',
        component: () => import('#/views/x/tms/personTask/Learn.vue'),
        meta: {
          title: '培训学习',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/personTask',
        },
      },
      {
        path: '/tms/task/create',
        name: 'TmsTaskCreate',
        component: () => import('#/views/x/tms/task/Form.vue'),
        meta: {
          title: '新增培训任务',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/task',
        },
      },
      {
        path: '/tms/task/edit/:id',
        name: 'TmsTaskEdit',
        component: () => import('#/views/x/tms/task/Form.vue'),
        meta: {
          title: '编辑培训任务',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/task',
        },
      },
      {
        path: '/tms/task/detail/:id',
        name: 'TmsTaskDetail',
        component: () => import('#/views/x/tms/task/Detail.vue'),
        meta: {
          title: '培训任务详情',
          hideInMenu: true,
          ignoreAccess: true,
          currentActiveMenu: '/tms/task',
        },
      },
    ],
  },
  {
apps/jnpf-web-apps-main/src/router/routes/modules/tmsPaper.ts
New file
@@ -0,0 +1,20 @@
import type { RouteRecordRaw } from 'vue-router';
/**
 * 列表可由后台菜单挂载:pageAddress = x/tms/paper/index,路由 /tms/paper
 * 创建/编辑/详情已挂到 basicRoutes(不依赖菜单)
 */
const tmsPaperRoutes: RouteRecordRaw[] = [
  {
    path: '/tms/paper',
    name: 'TmsPaper',
    component: () => import('#/views/x/tms/paper/index.vue'),
    meta: {
      title: '试卷管理',
      hideInMenu: true,
      ignoreAccess: true,
    },
  },
];
export default tmsPaperRoutes;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsQuestion.ts
@@ -2,7 +2,7 @@
/**
 * 列表可由后台菜单挂载:pageAddress = x/tms/question/index,路由 /tms/question
 * 创建/编辑已挂到 basicRoutes(不依赖菜单)
 * 创建/编辑/详情已挂到 basicRoutes(不依赖菜单)
 */
const tmsQuestionRoutes: RouteRecordRaw[] = [];
apps/jnpf-web-apps-main/src/router/routes/modules/tmsSelfTest.ts
@@ -1,11 +1,9 @@
import type { RouteRecordRaw } from 'vue-router';
/**
 * 自我检测可由后台菜单挂载:
 *   路由地址 /tms/selfTest
 *   页面地址 x/tms/selfTest/index
 * 答题页已挂 basicRoutes:/tms/selfTest/exam
 * 答题/记录/详情页已挂 basicRoutes。
 */
const tmsSelfTestRoutes: RouteRecordRaw[] = [];
const tmsSelfTestRoutes: import('vue-router').RouteRecordRaw[] = [];
export default tmsSelfTestRoutes;
apps/jnpf-web-apps-main/src/router/routes/modules/tmsTask.ts
New file
@@ -0,0 +1,20 @@
import type { RouteRecordRaw } from 'vue-router';
/**
 * 列表可由后台菜单挂载:pageAddress = x/tms/task/index,路由 /tms/task
 * 创建/编辑/详情已挂到 basicRoutes(不依赖菜单)
 */
const tmsTaskRoutes: RouteRecordRaw[] = [
  {
    path: '/tms/task',
    name: 'TmsTask',
    component: () => import('#/views/x/tms/task/index.vue'),
    meta: {
      title: '培训任务管理',
      hideInMenu: true,
      ignoreAccess: true,
    },
  },
];
export default tmsTaskRoutes;
apps/jnpf-web-apps-main/src/views/x/tms/course/Form.vue
@@ -11,19 +11,20 @@
  getCoursePaperOptions,
  updateCourse,
} from '#/api/x/tms/course';
import { useBaseStore } from '#/store';
import {
  CATEGORY_OPTIONS,
  ENABLE_OPTIONS,
  EVAL_MODE_OPTIONS,
  TRAIN_MODE_OPTIONS,
  evalModeNeedPaper,
} from './constants';
  TMS_DIC,
  TMS_DIC_FIELD_NAMES,
  loadTmsDic,
} from '#/views/x/tms/shared/dic';
import { evalModeNeedPaper } from './constants';
defineOptions({ name: 'TmsCourseForm' });
const emit = defineEmits(['register', 'reload']);
const { createMessage } = useMessage();
const baseStore = useBaseStore();
const state = reactive({ id: '' });
const { id } = toRefs(state);
@@ -56,7 +57,8 @@
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: CATEGORY_OPTIONS,
          options: [],
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
      {
@@ -66,7 +68,8 @@
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: TRAIN_MODE_OPTIONS,
          options: [],
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
      {
@@ -76,7 +79,8 @@
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: EVAL_MODE_OPTIONS,
          options: [],
          fieldNames: TMS_DIC_FIELD_NAMES,
          onChange: (val: string) => syncPaperRequired(val),
        },
      },
@@ -110,7 +114,11 @@
        label: '启用状态',
        component: 'Select',
        defaultValue: '1',
        componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS },
        componentProps: {
          placeholder: '请选择',
          options: [],
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
        rules: [{ required: true, message: '必填', trigger: 'change' }],
      },
      {
@@ -136,6 +144,53 @@
  }
}
async function loadDicOptions() {
  const [category, trainMode, evalMode, enable] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.courseCategory),
    loadTmsDic(baseStore, TMS_DIC.trainMode),
    loadTmsDic(baseStore, TMS_DIC.evalMode),
    loadTmsDic(baseStore, TMS_DIC.enableStatus),
  ]);
  updateSchema([
    {
      field: 'category',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: category,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'trainMode',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: trainMode,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'evalMode',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: evalMode,
        fieldNames: TMS_DIC_FIELD_NAMES,
        onChange: (val: string) => syncPaperRequired(val),
      },
    },
    {
      field: 'enabled',
      componentProps: {
        placeholder: '请选择',
        options: enable,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
  ]);
}
async function loadPaperOptions() {
  const list = await getCoursePaperOptions();
  updateSchema({
@@ -156,7 +211,7 @@
  resetFields();
  state.id = data?.id || '';
  try {
    await loadPaperOptions();
    await Promise.all([loadDicOptions(), loadPaperOptions()]);
    if (state.id) {
      const info = await getCourseInfo(state.id);
      setFieldsValue(info);
apps/jnpf-web-apps-main/src/views/x/tms/course/constants.ts
@@ -1,48 +1,3 @@
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
@@ -3,28 +3,34 @@
import type { CourseItem } from './types';
import { onMounted, ref } from 'vue';
import { useMessage } from '@jnpf/hooks';
import { useModal } from '@jnpf/ui/modal';
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { deleteCourse, getCourseList, setCourseEnabled } from '#/api/x/tms/course';
import { useBaseStore } from '#/store';
import {
  TMS_DIC,
  TMS_DIC_FIELD_NAMES,
  labelOfDic,
  loadTmsDic,
  type TmsDicOpt,
} from '#/views/x/tms/shared/dic';
import Form from './Form.vue';
import {
  CATEGORY_OPTIONS,
  ENABLE_OPTIONS,
  EVAL_MODE_OPTIONS,
  TRAIN_MODE_OPTIONS,
  labelOfCategory,
  labelOfEnabled,
  labelOfEvalMode,
  labelOfTrainMode,
} from './constants';
defineOptions({ name: 'TmsCourse' });
const { createMessage } = useMessage();
const baseStore = useBaseStore();
const [registerForm, { openModal: openFormModal }] = useModal();
const categoryOpts = ref<TmsDicOpt[]>([]);
const trainModeOpts = ref<TmsDicOpt[]>([]);
const evalModeOpts = ref<TmsDicOpt[]>([]);
const enableOpts = ref<TmsDicOpt[]>([]);
const columns: BasicColumn[] = [
  { title: '课程编号', dataIndex: 'courseNo', width: 140 },
@@ -33,19 +39,19 @@
    title: '课程分类',
    dataIndex: 'category',
    width: 100,
    customRender: ({ record }) => labelOfCategory((record as CourseItem).category),
    customRender: ({ record }) => labelOfDic(categoryOpts.value, (record as CourseItem).category),
  },
  {
    title: '培训方式',
    dataIndex: 'trainMode',
    width: 110,
    customRender: ({ record }) => labelOfTrainMode((record as CourseItem).trainMode),
    customRender: ({ record }) => labelOfDic(trainModeOpts.value, (record as CourseItem).trainMode),
  },
  {
    title: '考核方式',
    dataIndex: 'evalMode',
    width: 110,
    customRender: ({ record }) => labelOfEvalMode((record as CourseItem).evalMode),
    customRender: ({ record }) => labelOfDic(evalModeOpts.value, (record as CourseItem).evalMode),
  },
  {
    title: '关联考核试卷',
@@ -73,10 +79,10 @@
  { title: '备注', dataIndex: 'remark', minWidth: 140 },
];
const [registerTable, { reload }] = useVxeTable({
const [registerTable, { reload, getForm }] = useVxeTable({
  api: fetchList,
  columns,
  immediate: true,
  immediate: false,
  rowKey: 'id',
  useSearchForm: true,
  formConfig: {
@@ -96,7 +102,8 @@
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: CATEGORY_OPTIONS,
          options: categoryOpts.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
      {
@@ -106,7 +113,8 @@
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: TRAIN_MODE_OPTIONS,
          options: trainModeOpts.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
      {
@@ -116,7 +124,8 @@
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: EVAL_MODE_OPTIONS,
          options: evalModeOpts.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
      {
@@ -126,7 +135,8 @@
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: ENABLE_OPTIONS,
          options: enableOpts.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
    ],
@@ -137,6 +147,58 @@
    dataIndex: 'action',
    fixed: 'right',
  },
});
onMounted(async () => {
  const [category, trainMode, evalMode, enable] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.courseCategory),
    loadTmsDic(baseStore, TMS_DIC.trainMode),
    loadTmsDic(baseStore, TMS_DIC.evalMode),
    loadTmsDic(baseStore, TMS_DIC.enableStatus),
  ]);
  categoryOpts.value = category;
  trainModeOpts.value = trainMode;
  evalModeOpts.value = evalMode;
  enableOpts.value = enable;
  getForm()?.updateSchema?.([
    {
      field: 'category',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: category,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'trainMode',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: trainMode,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'evalMode',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: evalMode,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'enabled',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: enable,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
  ]);
  reload();
});
async function fetchList(params: Record<string, any>) {
@@ -164,6 +226,10 @@
}
async function handleDelete(record: CourseItem) {
  if (record.enabled !== '0') {
    createMessage.warning('请先停用后再删除');
    return;
  }
  try {
    await deleteCourse(record.id);
    createMessage.success('删除成功');
@@ -175,7 +241,7 @@
function getTableActions(record: CourseItem): ActionItem[] {
  const enableLabel = record.enabled === '1' ? '停用' : '启用';
  return [
  const actions: ActionItem[] = [
    { label: '编辑', onClick: handleEdit.bind(null, record) },
    {
      label: enableLabel,
@@ -184,15 +250,18 @@
        onOk: handleToggleEnabled.bind(null, record),
      },
    },
    {
  ];
  if (record.enabled === '0') {
    actions.push({
      label: '删除',
      color: 'error',
      modelConfirm: {
        content: `确定删除培训课程「${record.courseName}」吗?`,
        onOk: handleDelete.bind(null, record),
      },
    },
  ];
    });
  }
  return actions;
}
</script>
@@ -206,7 +275,7 @@
          </template>
          <template #enabled="{ record }">
            <a-tag :color="record.enabled === '1' ? 'success' : 'default'">
              {{ labelOfEnabled(record.enabled) }}
              {{ labelOfDic(enableOpts, record.enabled) }}
            </a-tag>
          </template>
          <template #action="{ record }">
apps/jnpf-web-apps-main/src/views/x/tms/course/mock.ts
File was deleted
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/Form.vue
@@ -6,13 +6,18 @@
import { BasicModal, useModalInner } from '@jnpf/ui/modal';
import { createEvalMode, getEvalModeInfo, updateEvalMode } from '#/api/x/tms/evalMode';
import { ENABLE_OPTIONS, YES_NO_OPTIONS } from './constants';
import { useBaseStore } from '#/store';
import {
  TMS_DIC,
  TMS_DIC_FIELD_NAMES,
  loadTmsDic,
} from '#/views/x/tms/shared/dic';
defineOptions({ name: 'TmsEvalModeForm' });
const emit = defineEmits(['register', 'reload']);
const { createMessage } = useMessage();
const baseStore = useBaseStore();
const state = reactive({ id: '' });
const { id } = toRefs(state);
@@ -40,7 +45,11 @@
      label: '需要试卷',
      component: 'Select',
      defaultValue: '0',
      componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -48,7 +57,11 @@
      label: '需要提问预设',
      component: 'Select',
      defaultValue: '0',
      componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -56,7 +69,11 @@
      label: '需要实操评分',
      component: 'Select',
      defaultValue: '0',
      componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -64,7 +81,11 @@
      label: '启用状态',
      component: 'Select',
      defaultValue: '1',
      componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -77,6 +98,23 @@
});
const [registerModal, { closeModal, changeLoading, changeOkLoading }] = useModalInner(init);
async function loadDicOptions() {
  const [yesNo, enable] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.yesNo),
    loadTmsDic(baseStore, TMS_DIC.enableStatus),
  ]);
  updateSchema(
    ['needPaper', 'needQuiz', 'needPractice'].map((field) => ({
      field,
      componentProps: { placeholder: '请选择', options: yesNo, fieldNames: TMS_DIC_FIELD_NAMES },
    })),
  );
  updateSchema({
    field: 'enabled',
    componentProps: { placeholder: '请选择', options: enable, fieldNames: TMS_DIC_FIELD_NAMES },
  });
}
async function init(data: { id?: string }) {
  changeLoading(true);
@@ -92,6 +130,7 @@
    },
  });
  try {
    await loadDicOptions();
    if (state.id) setFieldsValue(await getEvalModeInfo(state.id));
  } finally {
    changeLoading(false);
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/constants.ts
File was deleted
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/index.vue
@@ -3,19 +3,32 @@
import type { EvalModeItem } from './types';
import { onMounted, ref } from 'vue';
import { useMessage } from '@jnpf/hooks';
import { useModal } from '@jnpf/ui/modal';
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { deleteEvalMode, getEvalModeList, setEvalModeEnabled } from '#/api/x/tms/evalMode';
import { useBaseStore } from '#/store';
import {
  TMS_DIC,
  TMS_DIC_FIELD_NAMES,
  labelOfDic,
  loadTmsDic,
  type TmsDicOpt,
} from '#/views/x/tms/shared/dic';
import Form from './Form.vue';
import { ENABLE_OPTIONS, labelOfEnabled, labelOfYesNo } from './constants';
defineOptions({ name: 'TmsEvalMode' });
const { createMessage } = useMessage();
const baseStore = useBaseStore();
const [registerForm, { openModal: openFormModal }] = useModal();
const yesNoOpts = ref<TmsDicOpt[]>([]);
const enableOpts = ref<TmsDicOpt[]>([]);
const columns: BasicColumn[] = [
  { title: '方式编码', dataIndex: 'modeCode', width: 120 },
@@ -25,21 +38,21 @@
    dataIndex: 'needPaper',
    width: 100,
    align: 'center',
    customRender: ({ record }) => labelOfYesNo((record as EvalModeItem).needPaper),
    customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as EvalModeItem).needPaper),
  },
  {
    title: '需要提问预设',
    dataIndex: 'needQuiz',
    width: 120,
    align: 'center',
    customRender: ({ record }) => labelOfYesNo((record as EvalModeItem).needQuiz),
    customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as EvalModeItem).needQuiz),
  },
  {
    title: '需要实操评分',
    dataIndex: 'needPractice',
    width: 120,
    align: 'center',
    customRender: ({ record }) => labelOfYesNo((record as EvalModeItem).needPractice),
    customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as EvalModeItem).needPractice),
  },
  {
    title: '状态',
@@ -51,10 +64,10 @@
  { title: '备注', dataIndex: 'remark', minWidth: 200 },
];
const [registerTable, { reload }] = useVxeTable({
const [registerTable, { reload, getForm }] = useVxeTable({
  api: fetchList,
  columns,
  immediate: true,
  immediate: false,
  rowKey: 'id',
  useSearchForm: true,
  formConfig: {
@@ -71,7 +84,12 @@
        field: 'enabled',
        label: '状态',
        component: 'Select',
        componentProps: { allowClear: true, placeholder: '请选择', options: ENABLE_OPTIONS },
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: enableOpts.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
    ],
  },
@@ -81,6 +99,21 @@
    dataIndex: 'action',
    fixed: 'right',
  },
});
onMounted(async () => {
  yesNoOpts.value = await loadTmsDic(baseStore, TMS_DIC.yesNo);
  enableOpts.value = await loadTmsDic(baseStore, TMS_DIC.enableStatus);
  getForm()?.updateSchema?.({
    field: 'enabled',
    componentProps: {
      allowClear: true,
      placeholder: '请选择',
      options: enableOpts.value,
      fieldNames: TMS_DIC_FIELD_NAMES,
    },
  });
  reload();
});
async function fetchList(params: Record<string, any>) {
@@ -108,6 +141,10 @@
}
async function handleDelete(record: EvalModeItem) {
  if (record.enabled !== '0') {
    createMessage.warning('请先停用后再删除');
    return;
  }
  try {
    await deleteEvalMode(record.id);
    createMessage.success('删除成功');
@@ -119,7 +156,7 @@
function getTableActions(record: EvalModeItem): ActionItem[] {
  const enableLabel = record.enabled === '1' ? '停用' : '启用';
  return [
  const actions: ActionItem[] = [
    { label: '编辑', onClick: handleEdit.bind(null, record) },
    {
      label: enableLabel,
@@ -128,15 +165,18 @@
        onOk: handleToggleEnabled.bind(null, record),
      },
    },
    {
  ];
  if (record.enabled === '0') {
    actions.push({
      label: '删除',
      color: 'error',
      modelConfirm: {
        content: `确定删除考核方式「${record.modeName}」吗?`,
        onOk: handleDelete.bind(null, record),
      },
    },
  ];
    });
  }
  return actions;
}
</script>
@@ -150,7 +190,7 @@
          </template>
          <template #enabled="{ record }">
            <a-tag :color="record.enabled === '1' ? 'success' : 'default'">
              {{ labelOfEnabled(record.enabled) }}
              {{ labelOfDic(enableOpts, record.enabled) }}
            </a-tag>
          </template>
          <template #action="{ record }">
apps/jnpf-web-apps-main/src/views/x/tms/evalMode/mock.ts
File was deleted
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Mark.vue
@@ -1,15 +1,19 @@
<script lang="ts" setup>
import type { GradeExamDetail, GradeExamItem } from './types';
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { Modal } from 'ant-design-vue';
import { getGradeExamDetail, submitGrade } from '#/api/x/tms/examGrade';
import { labelOfType } from '#/views/x/tms/question/constants';
import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import { colorOfGradeStatus, labelOfGradeStatus } from './constants';
import { colorOfGradeStatus, labelOfGradeStatus, loadExamGradeDics } from './constants';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsExamGradeMark' });
@@ -36,9 +40,21 @@
  return Number(detail.value.objectiveScore || 0) + subjectiveTotal.value;
});
onMounted(() => {
  loadDetail();
onMounted(async () => {
  await Promise.all([loadExamGradeDics(), loadQuestionDics()]);
  await loadDetail();
});
watch(
  () => String(route.params.id || ''),
  async (id, prev) => {
    if (!id || id === prev) return;
    Object.keys(scoreMap).forEach((key) => {
      delete scoreMap[key];
    });
    await loadDetail();
  },
);
async function loadDetail() {
  const id = String(route.params.id || '');
@@ -91,19 +107,30 @@
}
async function handleSubmit() {
  if (!detail.value || readonly.value) return;
  if (!detail.value || readonly.value || saving.value) return;
  const err = validateScores();
  if (err) {
    createMessage.warning(err);
    return;
  }
  Modal.confirm({
    title: '确认提交',
    content: '提交后不可修改,确定提交吗?',
    okText: '确认',
    cancelText: '取消',
    onOk: () => doSubmit(),
  });
}
async function doSubmit() {
  if (!detail.value || readonly.value || saving.value) return;
  saving.value = true;
  try {
    const items = detail.value.items
      .filter((x) => x.isSubjective === '1')
      .map((x) => ({ id: x.id, gotScore: Number(scoreMap[x.id] || 0) }));
    await submitGrade({ examId: detail.value.examId, items });
    createMessage.success(`阅卷完成,总分 ${previewTotal.value}`);
    const result = await submitGrade({ examId: detail.value.examId, items });
    createMessage.success(`阅卷完成,总分 ${result?.totalScore ?? previewTotal.value}`);
    goBack();
  } catch (e: any) {
    createMessage.error(e?.message || '提交失败');
@@ -122,12 +149,10 @@
    <div class="jnpf-content-wrapper-center tms-grade-center">
      <div class="jnpf-content-wrapper-content tms-grade-mark-wrap">
        <div class="mark-top">
          <div class="mark-header">
          <div class="mark-header tms-page-header">
            <div>
              <div class="text-base font-medium">
                {{ detail?.paperName || '阅卷' }} · 阅卷
              </div>
              <div v-if="detail" class="mt-1 text-gray-400 text-sm">
              <div class="tms-page-header__title">{{ detail?.paperName || '阅卷' }} · 阅卷</div>
              <div v-if="detail" class="tms-page-header__sub">
                考生:{{ detail.userName }}
                <span v-if="detail.deptName">({{ detail.deptName }})</span>
                <span class="ml-3">交卷:{{ detail.submitTime || '-' }}</span>
@@ -136,12 +161,12 @@
                </span>
              </div>
            </div>
            <a-space>
              <a-button @click="goBack">返回</a-button>
            <div class="tms-page-header__actions">
              <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
              <a-button v-if="detail && !readonly" type="primary" :loading="saving" @click="handleSubmit">
                提交阅卷
              </a-button>
            </a-space>
            </div>
          </div>
          <div v-if="detail" class="score-bar">
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/Session.vue
@@ -3,15 +3,18 @@
import type { GradeExamListItem, GradeSessionListItem } from './types';
import { onMounted, ref } from 'vue';
import { onActivated, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { getGradeExamList, getGradeSessionInfo } from '#/api/x/tms/examGrade';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import { colorOfGradeStatus, labelOfGradeStatus } from './constants';
import { colorOfGradeStatus, labelOfGradeStatus, loadExamGradeDics } from './constants';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsExamGradeSession' });
@@ -77,16 +80,27 @@
    return;
  }
  try {
    session.value = await getGradeSessionInfo(sessionId);
    reload();
    await loadExamGradeDics();
    await refreshSession();
  } catch (e: any) {
    createMessage.error(e?.message || '加载失败');
    router.replace('/tms/examGrade');
  }
});
onActivated(() => {
  if (!sessionId) return;
  void refreshSession();
});
async function refreshSession() {
  session.value = await getGradeSessionInfo(sessionId);
  reload();
}
async function fetchList() {
  return { data: await getGradeExamList(sessionId) };
  const list = await getGradeExamList(sessionId);
  return { data: Array.isArray(list) ? list : [] };
}
function goBack() {
@@ -99,9 +113,9 @@
function getTableActions(record: GradeExamListItem): ActionItem[] {
  if (record.gradeStatus === 'pending') {
    return [{ label: '阅卷', onClick: handleMark.bind(null, record) }];
    return [{ label: TMS_BTN.grade, onClick: handleMark.bind(null, record) }];
  }
  return [{ label: '查看', onClick: handleMark.bind(null, record) }];
  return [{ label: TMS_BTN.detail, onClick: handleMark.bind(null, record) }];
}
</script>
@@ -111,15 +125,13 @@
      <div class="jnpf-content-wrapper-content">
        <BasicVxeTable @register="registerTable">
          <template #tableTitle>
            <div class="flex items-center gap-3">
              <a-button @click="goBack">返回</a-button>
            <div class="tms-page-header" style="margin-bottom: 0">
              <div>
                <div class="text-base font-medium">
                  {{ session?.paperName || '答卷列表' }}
                </div>
                <div class="mt-1 text-gray-400 text-sm">
                  {{ session?.taskNo }} · {{ session?.taskSubject }}
                </div>
                <div class="tms-page-header__title">{{ session?.paperName || '答卷列表' }}</div>
                <div class="tms-page-header__sub">{{ session?.taskNo }} · {{ session?.taskSubject }}</div>
              </div>
              <div class="tms-page-header__actions">
                <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
              </div>
            </div>
          </template>
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/constants.ts
@@ -1,15 +1,25 @@
import type { GradeStatus } from './types';
import { ref } from 'vue';
export const GRADE_STATUS_OPTIONS: { id: GradeStatus; fullName: string; color?: string }[] = [
  { id: 'auto', fullName: '无需阅卷', color: '#8c8c8c' },
  { id: 'pending', fullName: '待批改', color: '#fa8c16' },
  { id: 'graded', fullName: '已批改', color: '#52c41a' },
];
import { useBaseStore } from '#/store';
import { TMS_DIC, labelOfDic, loadTmsDic, type TmsDicOpt } from '#/views/x/tms/shared/dic';
const GRADE_STATUS_COLOR: Record<string, string> = {
  auto: '#8c8c8c',
  pending: '#fa8c16',
  graded: '#52c41a',
};
export const GRADE_STATUS_OPTIONS = ref<TmsDicOpt[]>([]);
export async function loadExamGradeDics() {
  const baseStore = useBaseStore();
  GRADE_STATUS_OPTIONS.value = await loadTmsDic(baseStore, TMS_DIC.gradeStatus);
}
export function labelOfGradeStatus(v?: string) {
  return GRADE_STATUS_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-';
  return labelOfDic(GRADE_STATUS_OPTIONS.value, v);
}
export function colorOfGradeStatus(v?: string) {
  return GRADE_STATUS_OPTIONS.find((x) => x.id === v)?.color;
  return (v && GRADE_STATUS_COLOR[v]) || undefined;
}
apps/jnpf-web-apps-main/src/views/x/tms/examGrade/index.vue
@@ -8,13 +8,16 @@
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { getGradeSessionList } from '#/api/x/tms/examGrade';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsExamGrade' });
const router = useRouter();
const columns: BasicColumn[] = [
  { title: '培训编号', dataIndex: 'taskNo', width: 140 },
  { title: '试卷编号', dataIndex: 'taskNo', width: 140 },
  { title: '培训主题', dataIndex: 'taskSubject', minWidth: 260 },
  { title: '试卷名称', dataIndex: 'paperName', minWidth: 200 },
  {
@@ -51,9 +54,9 @@
    schemas: [
      {
        field: 'taskNo',
        label: '培训编号',
        label: '试卷编号',
        component: 'Input',
        componentProps: { placeholder: '请输入培训编号', submitOnPressEnter: true },
        componentProps: { placeholder: '请输入试卷编号', submitOnPressEnter: true },
      },
      {
        field: 'keyword',
@@ -72,7 +75,13 @@
});
async function fetchList(params: Record<string, any>) {
  return { data: await getGradeSessionList(params) };
  const page = await getGradeSessionList(params);
  return {
    data: {
      list: Array.isArray(page?.list) ? page.list : [],
      pagination: page?.pagination || { total: 0 },
    },
  };
}
function handleEnter(record: GradeSessionListItem) {
@@ -80,7 +89,7 @@
}
function getTableActions(record: GradeSessionListItem): ActionItem[] {
  return [{ label: '进入', onClick: handleEnter.bind(null, record) }];
  return [{ label: TMS_BTN.grade, onClick: handleEnter.bind(null, record) }];
}
</script>
@@ -91,8 +100,8 @@
        <BasicVxeTable @register="registerTable">
          <template #tableTitle>
            <div>
              <div class="text-base font-medium">考试阅卷</div>
              <div class="mt-1 text-gray-400 text-sm">阅卷列表,选择试卷进行阅卷,或查看考试详情。</div>
              <div class="tms-page-header__title">考试阅卷</div>
              <div class="tms-page-header__sub">选择场次进入答卷列表,对待阅试卷评分。</div>
            </div>
          </template>
          <template #examTime="{ record }">
apps/jnpf-web-apps-main/src/views/x/tms/examScore/Detail.vue
@@ -10,6 +10,9 @@
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { getExamScoreDetail } from '#/api/x/tms/examScore';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsExamScoreDetail' });
@@ -22,10 +25,10 @@
const loaded = ref(false);
const columns: BasicColumn[] = [
  { title: '培训主题', dataIndex: 'taskSubject', minWidth: 220 },
  { title: '培训任务编号', dataIndex: 'taskNo', width: 140 },
  { title: '试卷名称', dataIndex: 'taskSubject', minWidth: 220 },
  { title: '试卷编号', dataIndex: 'taskNo', width: 180 },
  {
    title: '用户ID',
    title: '考生',
    dataIndex: 'userName',
    width: 120,
    customRender: ({ record }) => {
@@ -43,7 +46,6 @@
    align: 'center',
    slots: { default: 'passFlag' },
  },
  { title: '来源IP地址', dataIndex: 'sourceIp', width: 140 },
];
const [registerTable, { getSelectRows, reload }] = useVxeTable({
@@ -146,7 +148,7 @@
}
function getTableActions(record: ExamScoreDetailRow): ActionItem[] {
  return [{ label: '查看试卷', onClick: viewPaper.bind(null, record) }];
  return [{ label: TMS_BTN.viewPaper, onClick: viewPaper.bind(null, record) }];
}
</script>
@@ -157,15 +159,19 @@
        <BasicVxeTable @register="registerTable">
          <template #tableTitle>
            <a-space>
              <a-button @click="goBack">返回</a-button>
              <a-button type="primary" @click="handleViewSelected">查看试卷</a-button>
              <span class="text-gray-400 text-sm">{{ titleText }}</span>
              <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
              <a-button type="primary" @click="handleViewSelected">{{ TMS_BTN.viewPaper }}</a-button>
              <span class="tms-toolbar-hint">{{ titleText }}</span>
            </a-space>
          </template>
          <template #passFlag="{ record }">
            <span :class="record.passFlag === '1' ? 'text-green-600' : 'text-red-500'">
            <span
              v-if="record.passFlag === '1' || record.passFlag === '0'"
              :class="record.passFlag === '1' ? 'text-green-600' : 'text-red-500'"
            >
              {{ record.passFlag === '1' ? '是' : '否' }}
            </span>
            <span v-else>-</span>
          </template>
          <template #action="{ record }">
            <TableAction :actions="getTableActions(record)" />
apps/jnpf-web-apps-main/src/views/x/tms/examScore/Paper.vue
@@ -11,7 +11,10 @@
} from 'ant-design-vue';
import { getExamPaperView } from '#/api/x/tms/examScore';
import { labelOfType } from '#/views/x/tms/question/constants';
import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsExamScorePaper' });
@@ -22,7 +25,8 @@
const loading = ref(false);
const paper = ref<ExamPaperView | null>(null);
onMounted(() => {
onMounted(async () => {
  await loadQuestionDics();
  loadPaper();
});
@@ -62,30 +66,35 @@
    <div class="jnpf-content-wrapper-center tms-paper-center">
      <div class="jnpf-content-wrapper-content tms-paper-wrap">
        <div class="paper-top">
          <div class="paper-header">
          <div class="paper-header tms-page-header">
            <div>
              <div class="text-base font-medium">考生试卷详情</div>
              <div v-if="paper" class="mt-1 text-gray-400 text-sm">
              <div class="tms-page-header__title">考生试卷详情</div>
              <div v-if="paper" class="tms-page-header__sub">
                {{ paper.userName }}({{ paper.userId }}) · {{ paper.paperName }}
              </div>
            </div>
            <a-button @click="goBack">返回</a-button>
            <div class="tms-page-header__actions">
              <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
            </div>
          </div>
          <ADescriptions v-if="paper" bordered :column="3" size="small" class="mb-3">
            <ADescriptionsItem label="培训主题" :span="2">{{ paper.taskSubject }}</ADescriptionsItem>
            <ADescriptionsItem label="培训编号">{{ paper.taskNo }}</ADescriptionsItem>
            <ADescriptionsItem label="试卷名称" :span="2">{{ paper.taskSubject }}</ADescriptionsItem>
            <ADescriptionsItem label="试卷编号">{{ paper.taskNo }}</ADescriptionsItem>
            <ADescriptionsItem label="考试开始">{{ paper.examStart || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="考试结束">{{ paper.examEnd || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="来源IP">{{ paper.sourceIp || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="成绩">
              <b class="text-primary">{{ paper.score }}</b> / {{ paper.totalScore }}
            </ADescriptionsItem>
            <ADescriptionsItem label="及格分">{{ paper.passScore }}</ADescriptionsItem>
            <ADescriptionsItem label="是否合格">
              <span :class="paper.passFlag === '1' ? 'text-green-600' : 'text-red-500'">
              <span
                v-if="paper.passFlag === '1' || paper.passFlag === '0'"
                :class="paper.passFlag === '1' ? 'text-green-600' : 'text-red-500'"
              >
                {{ paper.passFlag === '1' ? '是' : '否' }}
              </span>
              <span v-else>-</span>
            </ADescriptionsItem>
          </ADescriptions>
        </div>
apps/jnpf-web-apps-main/src/views/x/tms/examScore/index.vue
@@ -1,23 +1,28 @@
<script lang="ts" setup>
import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
import type { ExamScoreSummaryItem } from './types';
import type { ExamScoreDetailRow, ExamScoreSummaryItem } from './types';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { getExamScoreList } from '#/api/x/tms/examScore';
import { exportExamScores, getExamScoreList } from '#/api/x/tms/examScore';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsExamScore' });
const router = useRouter();
const { createMessage } = useMessage();
const exporting = ref(false);
const columns: BasicColumn[] = [
  { title: '培训主题', dataIndex: 'taskSubject', minWidth: 240 },
  { title: '培训编号', dataIndex: 'taskNo', width: 140 },
  { title: '试卷名称', dataIndex: 'taskSubject', minWidth: 240 },
  { title: '试卷编号', dataIndex: 'taskNo', width: 180 },
  {
    title: '试卷名称',
    dataIndex: 'paperName',
@@ -63,9 +68,9 @@
      },
      {
        field: 'taskNo',
        label: '培训编号',
        label: '试卷编号',
        component: 'Input',
        componentProps: { placeholder: '请输入培训编号', submitOnPressEnter: true },
        componentProps: { placeholder: '请输入试卷编号', submitOnPressEnter: true },
      },
    ],
  },
@@ -78,24 +83,90 @@
});
async function fetchList(params: Record<string, any>) {
  return { data: await getExamScoreList(params) };
  const page = await getExamScoreList(params);
  return {
    data: {
      list: Array.isArray(page?.list) ? page.list : [],
      pagination: page?.pagination || { total: 0 },
    },
  };
}
function handleDetail(record: ExamScoreSummaryItem) {
  router.push(`/tms/examScore/detail/${record.id}`);
}
function handleExport() {
  const rows = getSelectRows?.() || [];
  if (!rows.length) {
async function handleExport() {
  const selected = (getSelectRows?.() || []) as ExamScoreSummaryItem[];
  if (!selected.length) {
    createMessage.warning('请先勾选要导出的考试记录');
    return;
  }
  createMessage.success(`已选择 ${rows.length} 条(导出接口联调后生效)`);
  if (exporting.value) return;
  exporting.value = true;
  try {
    const rows = await exportExamScores(selected.map((item) => item.id));
    if (!rows?.length) {
      createMessage.warning('所选试卷暂无已交卷成绩');
      return;
    }
    downloadScoreFile(rows, selected);
    createMessage.success(`已导出 ${rows.length} 条考生成绩`);
  } catch (e: any) {
    createMessage.error(e?.message || '导出失败');
  } finally {
    exporting.value = false;
  }
}
function passText(flag?: string) {
  if (flag === '1') return '合格';
  if (flag === '0') return '不合格';
  return '-';
}
function xmlCell(value: unknown) {
  const text = value == null ? '' : String(value);
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function scoreFileName(selected: ExamScoreSummaryItem[]) {
  const names = [...new Set(selected.map((item) => (item.taskSubject || '').trim()).filter(Boolean))];
  const raw = names.length === 1 ? `考试成绩_${names[0]}` : '考试成绩';
  return `${raw.replace(/[\\/:*?"<>|\r\n]/g, '_')}.xls`;
}
function downloadScoreFile(rows: ExamScoreDetailRow[], selected: ExamScoreSummaryItem[]) {
  const header = ['试卷名称', '试卷编号', '考生', '考试开始时间', '考试结束时间', '成绩', '及格分', '是否合格'];
  const body = rows.map((row) => [
    row.taskSubject,
    row.taskNo,
    row.userName || row.userId,
    row.examStart || '',
    row.examEnd || '',
    row.score,
    row.passScore,
    passText(row.passFlag),
  ]);
  const widths = [180, 160, 100, 150, 150, 60, 60, 80];
  const cols = widths.map((width) => `<Column ss:Width="${width}"/>`).join('');
  const toRow = (cells: unknown[]) =>
    `<Row>${cells.map((cell) => `<Cell><Data ss:Type="String">${xmlCell(cell)}</Data></Cell>`).join('')}</Row>`;
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Worksheet ss:Name="考试成绩"><Table>${cols}${toRow(header)}${body.map(toRow).join('')}</Table></Worksheet>
</Workbook>`;
  const blob = new Blob([xml], { type: 'application/vnd.ms-excel;charset=utf-8' });
  const link = document.createElement('a');
  link.href = URL.createObjectURL(blob);
  link.download = scoreFileName(selected);
  link.click();
  URL.revokeObjectURL(link.href);
}
function getTableActions(record: ExamScoreSummaryItem): ActionItem[] {
  return [{ label: '考试详情', onClick: handleDetail.bind(null, record) }];
  return [{ label: TMS_BTN.detail, onClick: handleDetail.bind(null, record) }];
}
</script>
@@ -106,7 +177,8 @@
        <BasicVxeTable @register="registerTable">
          <template #tableTitle>
            <a-space>
              <a-button type="primary" @click="handleExport">导出考试</a-button>
              <a-button type="primary" :loading="exporting" @click="handleExport">{{ TMS_BTN.export }}</a-button>
              <span class="tms-toolbar-hint">按场次汇总成绩,可导出或进入明细</span>
            </a-space>
          </template>
          <template #paperName="{ record }">
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/Detail.vue
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { OnlineExamDetail } from './types';
import type { OnlineExamAttempt, OnlineExamDetail } from './types';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
@@ -9,11 +9,22 @@
  Descriptions as ADescriptions,
  DescriptionsItem as ADescriptionsItem,
  Spin,
  Table as ATable,
} from 'ant-design-vue';
import { getMyPaperDetail, startOnlineExam } from '#/api/x/tms/onlineExam';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import { colorOfMyPaperStatus, labelOfMyPaperStatus } from './constants';
import {
  colorOfMyPaperStatus,
  colorOfPassFlag,
  labelOfGradeStatus,
  labelOfMyPaperStatus,
  labelOfPassFlag,
  loadOnlineExamDics,
} from './constants';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsOnlineExamDetail' });
@@ -25,8 +36,22 @@
const starting = ref(false);
const detail = ref<OnlineExamDetail | null>(null);
onMounted(() => {
  loadDetail();
const attemptColumns = [
  { title: '考试类型', dataIndex: 'attemptLabel', key: 'attemptLabel', width: 130 },
  { title: '状态', dataIndex: 'status', key: 'status', width: 100 },
  { title: '开始时间', dataIndex: 'startTime', key: 'startTime', width: 170 },
  { title: '交卷时间', dataIndex: 'submitTime', key: 'submitTime', width: 170 },
  { title: '得分', dataIndex: 'gotScore', key: 'gotScore', width: 90, align: 'center' as const },
  { title: '是否合格', dataIndex: 'passFlag', key: 'passFlag', width: 100, align: 'center' as const },
  { title: '阅卷', dataIndex: 'gradeStatus', key: 'gradeStatus', width: 100 },
  { title: '操作', key: 'action', width: 100, align: 'center' as const },
];
const attempts = computed(() => detail.value?.attempts || []);
onMounted(async () => {
  await loadOnlineExamDics();
  await loadDetail();
});
async function loadDetail() {
@@ -50,16 +75,40 @@
  router.push('/tms/onlineExam');
}
function goReview(row?: OnlineExamAttempt) {
  if (!detail.value) return;
  const examId = row?.examId || detail.value.examId;
  if (!examId) {
    createMessage.warning('暂无已交卷答卷可回顾');
    return;
  }
  router.push({
    path: `/tms/onlineExam/review/${detail.value.id}`,
    query: { examId },
  });
}
async function handleStart() {
  if (!detail.value || starting.value) return;
  starting.value = true;
  try {
    const paper = await startOnlineExam(detail.value.id);
    if (paper?.autoSubmitted) {
      createMessage.warning('考试时间已到,已自动交卷');
      await loadDetail();
      return;
    }
    sessionStorage.setItem('tms_online_exam_paper', JSON.stringify(paper));
    sessionStorage.setItem('tms_online_exam_myPaperId', detail.value.id);
    router.push('/tms/onlineExam/exam');
  } catch (e: any) {
    createMessage.error(e?.message || '开始考试失败');
    const msg = e?.message || '开始考试失败';
    if (String(msg).includes('自动交卷')) {
      createMessage.warning(msg);
      await loadDetail();
    } else {
      createMessage.error(msg);
    }
  } finally {
    starting.value = false;
  }
@@ -67,49 +116,110 @@
</script>
<template>
  <div class="jnpf-content-wrapper">
  <div class="jnpf-content-wrapper tms-exam-detail-root">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-exam-detail-page">
        <Spin :spinning="loading">
          <div class="tms-exam-detail-header">
            <div class="text-base font-medium">考试详情</div>
            <a-space>
              <a-button @click="goBack">返回列表</a-button>
          <div class="tms-page-header">
            <div>
              <div class="tms-page-header__title">考试详情</div>
              <div v-if="detail?.status === 'submitted'" class="tms-page-header__sub">
                最近得分 {{ detail.gotScore ?? '-' }} / {{ detail.totalScore }}
                <span v-if="detail.submitTime" class="ml-3">交卷时间:{{ detail.submitTime }}</span>
              </div>
            </div>
            <div class="tms-page-header__actions">
              <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
              <a-button
                v-if="detail && detail.status !== 'submitted'"
                type="primary"
                :loading="starting"
                @click="handleStart"
              >
                {{ detail?.status === 'doing' ? '继续考试' : '开始考试' }}
                {{ detail?.status === 'doing' ? TMS_BTN.continueExam : TMS_BTN.startExam }}
              </a-button>
            </a-space>
              <a-button
                v-else-if="detail?.canRetake"
                type="primary"
                :loading="starting"
                @click="handleStart"
              >
                {{ TMS_BTN.retake }}
              </a-button>
            </div>
          </div>
          <ADescriptions v-if="detail" bordered :column="2" size="middle">
            <ADescriptionsItem label="试卷名称" :span="2">{{ detail.paperName }}</ADescriptionsItem>
            <ADescriptionsItem label="最近考试类型">{{ detail.attemptLabel || '首考' }}</ADescriptionsItem>
            <ADescriptionsItem label="状态">
              <span :style="{ color: colorOfMyPaperStatus(detail.status) }">
                {{ labelOfMyPaperStatus(detail.status) }}
              </span>
            </ADescriptionsItem>
            <ADescriptionsItem label="卷面总分">{{ detail.totalScore }}</ADescriptionsItem>
            <ADescriptionsItem label="补考次数">
              {{
                detail.retakeLimit == null
                  ? '-'
                  : `剩余 ${detail.remainingRetakes ?? 0} / 上限 ${detail.retakeLimit}`
              }}
            </ADescriptionsItem>
            <ADescriptionsItem label="考试时间" :span="2">{{ detail.examTimeText || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="考试时长">
              {{ detail.durationMin != null ? `${detail.durationMin} 分钟` : '-' }}
            </ADescriptionsItem>
            <ADescriptionsItem label="合格分数">{{ detail.passScore ?? '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="开始时间">{{ detail.startTime || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="交卷时间">{{ detail.submitTime || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="得分">
              {{ detail.gotScore != null ? detail.gotScore : '-' }}
            </ADescriptionsItem>
            <ADescriptionsItem label="是否合格">
              <template v-if="detail.passFlag === '1'">合格</template>
              <template v-else-if="detail.passFlag === '0'">不合格</template>
              <template v-else>-</template>
            </ADescriptionsItem>
          </ADescriptions>
          <div class="history-block">
            <div class="history-title">历史答卷</div>
            <ATable
              size="middle"
              :columns="attemptColumns"
              :data-source="attempts"
              :pagination="false"
              row-key="examId"
              bordered
            >
              <template #bodyCell="{ column, record }">
                <template v-if="column.key === 'status'">
                  <span :style="{ color: colorOfMyPaperStatus(record.status) }">
                    {{ labelOfMyPaperStatus(record.status) }}
                  </span>
                </template>
                <template v-else-if="column.key === 'gotScore'">
                  {{ record.gotScore != null ? record.gotScore : '-' }}
                </template>
                <template v-else-if="column.key === 'passFlag'">
                  <span :style="{ color: colorOfPassFlag(record.passFlag) }">
                    {{ labelOfPassFlag(record.passFlag) }}
                  </span>
                </template>
                <template v-else-if="column.key === 'gradeStatus'">
                  {{ labelOfGradeStatus(record.gradeStatus) }}
                </template>
                <template v-else-if="column.key === 'action'">
                  <a
                    v-if="record.status === 'submitted'"
                    @click.prevent="goReview(record as OnlineExamAttempt)"
                  >
                    回顾
                  </a>
                  <span v-else class="text-gray-400">-</span>
                </template>
              </template>
            </ATable>
            <div v-if="!attempts.length" class="history-empty">暂无答卷记录</div>
          </div>
          <a-alert
            v-if="detail && detail.status !== 'submitted' && !attempts.some((a) => a.status === 'submitted')"
            class="mt-4"
            type="info"
            show-icon
            :message="detail.status === 'doing' ? '考试进行中,交卷后可在历史答卷中回顾。' : '尚未开考,暂无答题内容。'"
          />
        </Spin>
      </div>
    </div>
@@ -117,19 +227,33 @@
</template>
<style scoped>
.tms-exam-detail-page {
  background: #fff;
  padding: 20px 24px;
.tms-exam-detail-root {
  height: 100%;
  overflow: auto;
  min-height: 0;
}
.tms-exam-detail-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  padding-bottom: 12px;
  border-bottom: 1px solid #f0f0f0;
.tms-exam-detail-page {
  height: 100%;
  min-height: 0;
  overflow-x: hidden;
  overflow-y: auto !important;
  background: #fff;
  padding: 20px 24px;
  box-sizing: border-box;
}
.history-block {
  margin-top: 20px;
}
.history-title {
  font-weight: 600;
  margin-bottom: 12px;
}
.history-empty {
  margin-top: 12px;
  color: #8c8c8c;
  text-align: center;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/Wrong.vue
New file
@@ -0,0 +1,259 @@
<script lang="ts" setup>
import type { OnlineExamQuestionItem } from './types';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { Spin } from 'ant-design-vue';
import { getExamReview, getMyPaperDetail } from '#/api/x/tms/onlineExam';
import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsOnlineExamReview' });
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const loading = ref(false);
const paperName = ref('');
const attemptLabel = ref('');
const questions = ref<OnlineExamQuestionItem[]>([]);
const currentIndex = ref(0);
const answers = reactive<Record<string, string | string[]>>({});
const current = computed(() => questions.value[currentIndex.value]);
const total = computed(() => questions.value.length);
onMounted(async () => {
  await loadQuestionDics();
  await loadReview();
});
async function loadReview() {
  const id = String(route.params.id || '');
  const examId = String(route.query.examId || '');
  if (!id && !examId) {
    router.replace('/tms/onlineExam');
    return;
  }
  loading.value = true;
  try {
    const detail = examId ? await getExamReview(examId) : await getMyPaperDetail(id);
    if (!detail || detail.status !== 'submitted') {
      createMessage.warning('交卷后才能进行考试回顾');
      router.replace(id ? `/tms/onlineExam/detail/${id}` : '/tms/onlineExam');
      return;
    }
    paperName.value = detail.paperName;
    attemptLabel.value = detail.attemptLabel || '';
    questions.value = detail.questions || [];
    restoreAnswers(questions.value);
    currentIndex.value = 0;
  } catch (e: any) {
    createMessage.error(e?.message || '加载考试回顾失败');
    router.replace(id ? `/tms/onlineExam/detail/${id}` : '/tms/onlineExam');
  } finally {
    loading.value = false;
  }
}
function isObjective(q: OnlineExamQuestionItem) {
  return !(q.subjective || q.questionType === 'essay');
}
function restoreAnswers(list: OnlineExamQuestionItem[]) {
  Object.keys(answers).forEach((key) => {
    delete answers[key];
  });
  for (const q of list) {
    if (q.questionType === 'multi') {
      answers[q.id] = q.userAnswer ? q.userAnswer.split(/[,,]/).map((x) => x.trim()).filter(Boolean) : [];
    } else {
      answers[q.id] = q.userAnswer || '';
    }
  }
}
function stripHtml(html?: string) {
  if (!html) return '';
  return html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim();
}
function reviewText(q?: OnlineExamQuestionItem) {
  if (!q) return '';
  if (!isObjective(q)) {
    if (q.gotScore != null) return `主观题得分:${q.gotScore} / ${q.score}`;
    return '主观题,待阅卷';
  }
  if (q.right) return '回答正确';
  return `回答错误 · 正确答案:${correctText(q)}`;
}
function reviewClass(q?: OnlineExamQuestionItem) {
  if (!q || !isObjective(q)) return 'text-gray-500';
  return q.right ? 'text-green-600' : 'text-red-500';
}
function correctText(q?: OnlineExamQuestionItem) {
  if (!q) return '-';
  const labels = (q.correctAnswer || '')
    .split(/[,,]/)
    .map((x) => x.trim())
    .filter(Boolean);
  if (labels.length) return labels.join('、');
  const fromOptions = (q.options || []).filter((o) => o.isCorrect === '1').map((o) => o.optionLabel);
  return fromOptions.join('、') || '-';
}
function goBack() {
  const id = String(route.params.id || '');
  router.push(id ? `/tms/onlineExam/detail/${id}` : '/tms/onlineExam');
}
function goPrev() {
  if (currentIndex.value > 0) currentIndex.value -= 1;
}
function goNext() {
  if (currentIndex.value < total.value - 1) currentIndex.value += 1;
}
</script>
<template>
  <div class="jnpf-content-wrapper tms-wrong-root">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-wrong-page">
        <Spin :spinning="loading" class="tms-wrong-spin">
          <template v-if="!loading && !total">
            <div class="tms-page-header">
              <div class="tms-page-header__title">{{ paperName || '考试回顾' }}</div>
              <div class="tms-page-header__actions">
                <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
              </div>
            </div>
            <a-empty description="暂无答题内容" />
          </template>
          <template v-else-if="current">
            <div class="tms-page-header">
              <div>
                <div class="tms-page-header__title">
                  {{ paperName }}
                  <span v-if="attemptLabel" class="ml-2 text-sm font-normal text-gray-500">({{ attemptLabel }})</span>
                </div>
                <div class="tms-page-header__sub">考试回顾 · 第 {{ currentIndex + 1 }} / {{ total }} 题</div>
              </div>
              <div class="tms-page-header__actions">
                <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
              </div>
            </div>
            <div class="tms-exam-body">
              <div class="mb-3 text-sm text-gray-500">
                {{ labelOfType(current.questionType) }}
                <span class="ml-2">({{ current.score }} 分)</span>
              </div>
              <div class="stem mb-4">{{ stripHtml(current.stem) }}</div>
              <a-radio-group
                v-if="current.questionType === 'single' || current.questionType === 'judge'"
                :value="answers[current.id]"
                class="!flex !flex-col gap-3"
                disabled
              >
                <a-radio v-for="opt in current.options" :key="opt.optionLabel" :value="opt.optionLabel">
                  {{ opt.optionLabel }}. {{ opt.optionContent }}
                </a-radio>
              </a-radio-group>
              <a-checkbox-group
                v-else-if="current.questionType === 'multi'"
                :value="answers[current.id]"
                class="!flex !flex-col gap-3"
                disabled
              >
                <a-checkbox v-for="opt in current.options" :key="opt.optionLabel" :value="opt.optionLabel">
                  {{ opt.optionLabel }}. {{ opt.optionContent }}
                </a-checkbox>
              </a-checkbox-group>
              <a-input
                v-else-if="current.questionType === 'blank'"
                :value="answers[current.id]"
                disabled
                placeholder="未作答"
              />
              <a-textarea
                v-else-if="current.questionType === 'essay'"
                :value="answers[current.id]"
                disabled
                :rows="6"
                placeholder="未作答"
              />
              <div class="mt-4 text-sm" :class="reviewClass(current)">{{ reviewText(current) }}</div>
            </div>
            <div class="tms-exam-footer">
              <a-button :disabled="currentIndex <= 0" @click="goPrev">上一题</a-button>
              <a-button :disabled="currentIndex >= total - 1" @click="goNext">下一题</a-button>
            </div>
          </template>
        </Spin>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-wrong-root {
  height: 100%;
  min-height: 0;
}
.tms-wrong-page {
  height: 100%;
  min-height: 0;
  overflow: hidden;
  background: #fff;
  padding: 20px 24px;
  box-sizing: border-box;
}
.tms-wrong-spin {
  height: 100%;
}
.tms-wrong-spin :deep(.ant-spin-container) {
  height: 100%;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.tms-exam-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto !important;
}
.stem {
  font-size: 15px;
  line-height: 1.7;
  color: rgba(0, 0, 0, 0.88);
}
.tms-exam-footer {
  flex-shrink: 0;
  display: flex;
  gap: 12px;
  justify-content: center;
  padding-top: 16px;
  border-top: 1px solid #f0f0f0;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/constants.ts
@@ -1,19 +1,58 @@
import type { MyPaperStatus } from './types';
import { ref } from 'vue';
export const MY_PAPER_STATUS_OPTIONS: {
  id: MyPaperStatus;
  fullName: string;
  color?: string;
}[] = [
  { id: 'notStarted', fullName: '未开始', color: '#8c8c8c' },
  { id: 'doing', fullName: '考试中', color: '#1890ff' },
  { id: 'submitted', fullName: '已交卷', color: '#52c41a' },
];
import { useBaseStore } from '#/store';
import { TMS_DIC, labelOfDic, loadTmsDic, type TmsDicOpt } from '#/views/x/tms/shared/dic';
/** 我的试卷「未开始」为前端态,字典 tmsExamStatus 不含该项 */
const NOT_STARTED: TmsDicOpt = { id: 'notStarted', enCode: 'notStarted', fullName: '未开始' };
const STATUS_COLOR: Record<string, string> = {
  notStarted: '#8c8c8c',
  doing: '#1890ff',
  submitted: '#52c41a',
  cancelled: '#ff4d4f',
};
export const MY_PAPER_STATUS_OPTIONS = ref<TmsDicOpt[]>([NOT_STARTED]);
export const PASS_FLAG_OPTIONS = ref<TmsDicOpt[]>([]);
export async function loadOnlineExamDics() {
  const baseStore = useBaseStore();
  const [examStatus, passFlag] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.examStatus),
    loadTmsDic(baseStore, TMS_DIC.passFlag),
  ]);
  const rest = examStatus.filter((x) => (x.enCode || x.id) !== 'notStarted');
  MY_PAPER_STATUS_OPTIONS.value = [NOT_STARTED, ...rest];
  PASS_FLAG_OPTIONS.value = passFlag;
}
export function labelOfMyPaperStatus(v?: string) {
  return MY_PAPER_STATUS_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-';
  return labelOfDic(MY_PAPER_STATUS_OPTIONS.value, v);
}
export function colorOfMyPaperStatus(v?: string) {
  return MY_PAPER_STATUS_OPTIONS.find((x) => x.id === v)?.color;
  return (v && STATUS_COLOR[v]) || undefined;
}
export function labelOfPassFlag(v?: string | null) {
  if (v == null || v === '') return '-';
  const code = String(v);
  if (code === '1') return '合格';
  if (code === '0') return '不合格';
  return labelOfDic(PASS_FLAG_OPTIONS.value, code);
}
export function colorOfPassFlag(v?: string | null) {
  if (v === '1') return '#52c41a';
  if (v === '0') return '#ff4d4f';
  return undefined;
}
export function labelOfGradeStatus(v?: string | null) {
  if (!v) return '-';
  if (v === 'auto') return '自动阅卷';
  if (v === 'pending') return '待阅卷';
  if (v === 'graded') return '已阅卷';
  return v;
}
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/exam.vue
@@ -1,14 +1,16 @@
<script lang="ts" setup>
import type { OnlineExamPaper, OnlineExamQuestionItem } from './types';
import type { OnlineExamPaper, OnlineExamQuestionItem, OnlineExamSubmitResult } from './types';
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { Modal } from 'ant-design-vue';
import { AppstoreOutlined } from '@ant-design/icons-vue';
import { submitOnlineExam } from '#/api/x/tms/onlineExam';
import { labelOfType } from '#/views/x/tms/question/constants';
import { saveOnlineExam, submitOnlineExam } from '#/api/x/tms/onlineExam';
import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
defineOptions({ name: 'TmsOnlineExamTake' });
@@ -21,11 +23,47 @@
const submitted = ref(false);
const scoreText = ref('');
const submitting = ref(false);
const saving = ref(false);
const remainSeconds = ref<number | null>(null);
const autoSubmitting = ref(false);
const resultModalOpen = ref(false);
const submitResult = ref<OnlineExamSubmitResult | null>(null);
const resultFilter = ref<'all' | 'wrong'>('all');
const sheetOpen = ref(false);
let timer: ReturnType<typeof setInterval> | null = null;
const current = computed(() => paper.value?.questions?.[currentIndex.value]);
const total = computed(() => paper.value?.questions?.length || 0);
const countdownText = computed(() => formatRemain(remainSeconds.value));
const countdownUrgent = computed(
  () => remainSeconds.value != null && remainSeconds.value > 0 && remainSeconds.value <= 5 * 60,
);
onMounted(() => {
const rightCount = computed(
  () => paper.value?.questions?.filter((q) => isObjective(q) && q.right === true).length || 0,
);
const wrongCount = computed(
  () => paper.value?.questions?.filter((q) => isObjective(q) && q.right === false).length || 0,
);
const objectiveCount = computed(() => rightCount.value + wrongCount.value);
const scoreRate = computed(() => {
  if (!objectiveCount.value) return 0;
  return Math.round((rightCount.value * 10000) / objectiveCount.value) / 100;
});
const resultQuestions = computed(() => {
  const list = paper.value?.questions || [];
  if (resultFilter.value === 'wrong') {
    return list.filter((q) => isObjective(q) && q.right === false);
  }
  return list;
});
const answeredCount = computed(
  () => (paper.value?.questions || []).filter((q) => isAnswered(q)).length,
);
onMounted(async () => {
  await loadQuestionDics();
  const raw = sessionStorage.getItem('tms_online_exam_paper');
  if (!raw) {
    createMessage.warning('请先从试卷列表选择考试');
@@ -33,46 +71,161 @@
    return;
  }
  try {
    paper.value = JSON.parse(raw);
    const parsed = JSON.parse(raw) as OnlineExamPaper;
    paper.value = parsed;
    restoreAnswers(parsed);
    initCountdown(parsed);
  } catch {
    router.replace('/tms/onlineExam');
  }
});
onUnmounted(() => {
  stopCountdown();
  resultModalOpen.value = false;
});
function isObjective(q: OnlineExamQuestionItem) {
  return !(q.subjective || q.questionType === 'essay');
}
function isAnswered(q: OnlineExamQuestionItem) {
  const val = answers[q.id];
  if (Array.isArray(val)) return val.length > 0;
  return String(val ?? '').trim().length > 0;
}
function restoreAnswers(data: OnlineExamPaper) {
  Object.keys(answers).forEach((key) => {
    delete answers[key];
  });
  for (const q of data.questions || []) {
    if (q.questionType === 'multi') {
      answers[q.id] = q.userAnswer ? q.userAnswer.split(',').filter(Boolean) : [];
    } else {
      answers[q.id] = q.userAnswer || '';
    }
  }
}
function parseStartMs(startTime?: string) {
  if (!startTime) return NaN;
  const normalized = startTime.includes('T') ? startTime : startTime.replace(/-/g, '/');
  return new Date(normalized).getTime();
}
function calcRemain(data: OnlineExamPaper) {
  if (data.durationMin == null || data.durationMin <= 0) return null;
  const startMs = parseStartMs(data.startTime);
  if (!Number.isNaN(startMs)) {
    const endMs = startMs + data.durationMin * 60 * 1000;
    return Math.max(0, Math.floor((endMs - Date.now()) / 1000));
  }
  if (data.remainSeconds != null) return Math.max(0, Number(data.remainSeconds));
  return null;
}
function initCountdown(data: OnlineExamPaper) {
  stopCountdown();
  const remain = calcRemain(data);
  remainSeconds.value = remain;
  if (remain == null) return;
  if (remain <= 0) {
    void doSubmit({ auto: true });
    return;
  }
  timer = setInterval(() => {
    if (submitted.value || submitting.value || autoSubmitting.value) {
      stopCountdown();
      return;
    }
    const next = calcRemain(paper.value!);
    if (next == null) {
      remainSeconds.value = null;
      stopCountdown();
      return;
    }
    remainSeconds.value = next;
    if (next <= 0) {
      stopCountdown();
      void doSubmit({ auto: true });
    }
  }, 1000);
}
function stopCountdown() {
  if (timer) {
    clearInterval(timer);
    timer = null;
  }
}
function formatRemain(sec: number | null) {
  if (sec == null) return '';
  const s = Math.max(0, sec);
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const r = s % 60;
  const mm = String(m).padStart(2, '0');
  const ss = String(r).padStart(2, '0');
  if (h > 0) return `${String(h).padStart(2, '0')}:${mm}:${ss}`;
  return `${mm}:${ss}`;
}
function stripHtml(html?: string) {
  if (!html) return '';
  return html.replace(/<[^>]+>/g, '').replace(/&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(',');
async function persistAnswers() {
  if (!paper.value || submitted.value || saving.value) return;
  saving.value = true;
  try {
    await saveOnlineExam(paper.value.examId, { ...answers });
  } catch (e: any) {
    const msg = e?.message || '暂存失败';
    if (String(msg).includes('自动交卷')) {
      createMessage.warning(msg);
      stopCountdown();
      router.replace('/tms/onlineExam');
      return;
    }
    createMessage.error(msg);
  } finally {
    saving.value = false;
  }
  return String(ans || '') === String(correctLabels[0] || '');
}
function calcScore(): number {
  if (!paper.value) return 0;
  let got = 0;
  paper.value.questions.forEach((q) => {
    if (isCorrect(q)) got += Number(q.score || 0);
  });
  return got;
async function goPrev() {
  if (currentIndex.value <= 0) return;
  await persistAnswers();
  currentIndex.value -= 1;
}
async function goNext() {
  if (currentIndex.value >= total.value - 1) return;
  await persistAnswers();
  currentIndex.value += 1;
}
async function goToQuestion(index: number) {
  if (index < 0 || index >= total.value || index === currentIndex.value) {
    sheetOpen.value = false;
    return;
  }
  if (!submitted.value) await persistAnswers();
  currentIndex.value = index;
  sheetOpen.value = false;
}
function openSheet() {
  sheetOpen.value = true;
}
async function goBack() {
  if (!submitted.value) await persistAnswers();
  resultModalOpen.value = false;
  router.push('/tms/onlineExam');
}
function handleSubmit() {
@@ -80,24 +233,130 @@
  Modal.confirm({
    title: '确认交卷',
    content: '交卷后不可再修改答案,确定交卷吗?',
    onOk: doSubmit,
    onOk: () => doSubmit(),
  });
}
async function doSubmit() {
function applyGrade(result: OnlineExamSubmitResult) {
  if (!paper.value) return;
  const byId = new Map((result.items || []).map((item) => [item.id, item]));
  for (const q of paper.value.questions) {
    const item = byId.get(q.id);
    if (!item) continue;
    q.right = item.right;
    q.subjective = item.subjective;
    q.correctAnswer = item.correctAnswer || '';
    if (q.options?.length && item.correctAnswer && q.questionType !== 'blank' && q.questionType !== 'essay') {
      const labels = new Set(item.correctAnswer.split(/[,,]/).map((x) => x.trim()).filter(Boolean));
      q.options.forEach((opt) => {
        opt.isCorrect = labels.has(opt.optionLabel) ? '1' : '0';
      });
    }
  }
}
function closeResultModal() {
  resultModalOpen.value = false;
}
async function openResultModal() {
  resultFilter.value = 'all';
  await nextTick();
  resultModalOpen.value = true;
}
function correctLabelsOf(q: OnlineExamQuestionItem) {
  if (q.questionType === 'blank' || q.questionType === 'essay') {
    return q.correctAnswer ? [q.correctAnswer] : [];
  }
  const fromOpt = q.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel);
  if (fromOpt.length) return fromOpt;
  return (q.correctAnswer || '')
    .split(/[,,]/)
    .map((x) => x.trim())
    .filter(Boolean);
}
function userAnswerText(q: OnlineExamQuestionItem) {
  const ans = answers[q.id];
  if (q.questionType === 'multi') {
    const selected = Array.isArray(ans) ? [...ans].sort() : [];
    return selected.length ? selected.join('、') : '未作答';
  }
  return String(ans || '') || '未作答';
}
function optionClass(q: OnlineExamQuestionItem, label: string) {
  if (!submitted.value || !isObjective(q)) return '';
  const correctSet = new Set(correctLabelsOf(q));
  const ans = answers[q.id];
  const userSet = new Set(
    q.questionType === 'multi'
      ? Array.isArray(ans)
        ? ans
        : []
      : ans
        ? [String(ans)]
        : [],
  );
  if (correctSet.has(label)) return 'opt-correct';
  if (userSet.has(label) && !correctSet.has(label)) return 'opt-wrong';
  return '';
}
function resultTag(q: OnlineExamQuestionItem) {
  if (!isObjective(q)) return { color: 'default', text: '待阅卷' };
  return q.right ? { color: 'success', text: '回答正确' } : { color: 'error', text: '回答错误' };
}
function passText() {
  if (submitResult.value?.pendingGrade) return '待阅卷';
  if (submitResult.value?.passFlag === '1') return '合格';
  if (submitResult.value?.passFlag === '0') return '不合格';
  return '-';
}
async function doSubmit(opts?: { auto?: boolean }) {
  if (!paper.value || submitted.value || submitting.value) return;
  if (opts?.auto) autoSubmitting.value = true;
  submitting.value = true;
  stopCountdown();
  try {
    const got = calcScore();
    await submitOnlineExam(paper.value.examId, { score: got, answers: { ...answers } });
    const result = await submitOnlineExam(paper.value.examId, { ...answers });
    applyGrade(result);
    submitted.value = true;
    scoreText.value = `${got} / ${paper.value.totalScore}`;
    createMessage.success(`交卷成功,得分 ${got} 分(满分 ${paper.value.totalScore})`);
    remainSeconds.value = 0;
    submitResult.value = result;
    const got = result.pendingGrade ? result.objectiveScore : result.totalScore;
    scoreText.value = result.pendingGrade
      ? `客观题 ${got ?? 0} / ${paper.value.totalScore}`
      : `${got ?? 0} / ${paper.value.totalScore}`;
    if (opts?.auto) {
      createMessage.warning('考试时间已到,已自动交卷');
    }
    await openResultModal();
  } catch (e: any) {
    createMessage.error(e?.message || '交卷失败');
    const msg = e?.message || '交卷失败';
    if (String(msg).includes('已交卷') || String(msg).includes('自动交卷')) {
      createMessage.warning(msg);
      router.replace('/tms/onlineExam');
      return;
    }
    createMessage.error(msg);
    if (opts?.auto && paper.value && !submitted.value) {
      initCountdown(paper.value);
    }
  } finally {
    submitting.value = false;
    autoSubmitting.value = false;
  }
}
function reviewText(q?: OnlineExamQuestionItem) {
  if (!q || !submitted.value) return '';
  if (!isObjective(q)) return '主观题,待阅卷';
  if (q.right) return '回答正确';
  return `回答错误 · 正确答案:${correctLabelsOf(q).join('、') || '-'}`;
}
</script>
@@ -107,7 +366,12 @@
      <div v-if="paper" class="jnpf-content-wrapper-content tms-online-exam-page">
        <div class="tms-exam-header">
          <div>
            <div class="text-base font-medium">{{ paper.paperName }}</div>
            <div class="text-base font-medium">
              {{ paper.paperName }}
              <span v-if="paper.attemptLabel" class="ml-2 text-sm font-normal text-gray-500">
                ({{ paper.attemptLabel }})
              </span>
            </div>
            <div class="mt-1 text-gray-400 text-sm">
              第 {{ currentIndex + 1 }} / {{ total }} 题
              <span v-if="paper.durationMin" class="ml-3">时长 {{ paper.durationMin }} 分钟</span>
@@ -115,17 +379,32 @@
            </div>
          </div>
          <a-space>
            <a-button @click="goBack">返回列表</a-button>
            <div
              v-if="countdownText && !submitted"
              class="countdown"
              :class="{ urgent: countdownUrgent, ended: remainSeconds === 0 }"
            >
              剩余 {{ countdownText }}
            </div>
            <a-button v-if="submitted" type="link" @click="openResultModal">查看本次结果</a-button>
            <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
            <a-button type="primary" :loading="submitting" :disabled="submitted" @click="handleSubmit">
              交卷
              {{ TMS_BTN.submitExam }}
            </a-button>
          </a-space>
        </div>
        <div v-if="current" class="tms-exam-body">
          <div class="mb-3 text-sm text-gray-500">
            {{ labelOfType(current.questionType) }}
            <span class="ml-2">({{ current.score }} 分)</span>
          <div class="q-meta mb-3">
            <div class="text-sm text-gray-500">
              {{ labelOfType(current.questionType) }}
              <span class="ml-2">({{ current.score }} 分)</span>
            </div>
            <a-tooltip title="答题卡">
              <button type="button" class="sheet-icon-btn" aria-label="答题卡" @click="openSheet">
                <AppstoreOutlined />
              </button>
            </a-tooltip>
          </div>
          <div class="stem mb-4">{{ stripHtml(current.stem) }}</div>
@@ -151,20 +430,177 @@
            </a-checkbox>
          </a-checkbox-group>
          <a-input
            v-else-if="current.questionType === 'blank'"
            v-model:value="answers[current.id]"
            :disabled="submitted"
            placeholder="请输入答案"
          />
          <a-textarea
            v-else-if="current.questionType === 'essay'"
            v-model:value="answers[current.id]"
            :disabled="submitted"
            :rows="6"
            placeholder="请输入答案"
          />
          <div
            v-if="submitted"
            class="mt-4 text-sm"
            :class="isCorrect(current) ? 'text-green-600' : 'text-red-500'"
            :class="
              current.right
                ? 'text-green-600'
                : !isObjective(current)
                  ? 'text-gray-500'
                  : 'text-red-500'
            "
          >
            {{ isCorrect(current) ? '回答正确' : '回答错误' }}
            · 正确答案:
            {{ current.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel).join('、') }}
            {{ reviewText(current) }}
          </div>
        </div>
        <div class="tms-exam-footer">
          <a-button :disabled="currentIndex <= 0" @click="goPrev">上一题</a-button>
          <a-button :disabled="currentIndex >= total - 1" @click="goNext">下一题</a-button>
        </div>
      </div>
    </div>
    <div v-if="sheetOpen && paper" class="tms-sheet-overlay" @click.self="sheetOpen = false">
      <div class="tms-sheet-dialog" role="dialog" aria-modal="true">
        <div class="sheet-head">
          <div>
            <div class="sheet-title">答题卡</div>
            <div class="sheet-sub">已答 {{ answeredCount }} / {{ total }},点击题号可切换</div>
          </div>
          <button type="button" class="tms-result-close" aria-label="关闭" @click="sheetOpen = false">×</button>
        </div>
        <div class="sheet-legend">
          <span class="legend-chip current">当前</span>
          <span class="legend-chip answered">已答</span>
          <span class="legend-chip unanswered">未答</span>
        </div>
        <div class="sheet-grid">
          <button
            v-for="(q, idx) in paper.questions"
            :key="q.id"
            type="button"
            class="sheet-item"
            :class="{
              current: idx === currentIndex,
              answered: isAnswered(q),
              unanswered: !isAnswered(q),
            }"
            @click="goToQuestion(idx)"
          >
            {{ idx + 1 }}
          </button>
        </div>
      </div>
    </div>
    <div
      v-if="resultModalOpen && submitResult && paper"
      class="tms-result-overlay"
      @click.self="closeResultModal"
    >
      <div class="tms-result-dialog" role="dialog" aria-modal="true">
        <button type="button" class="tms-result-close" aria-label="关闭" @click="closeResultModal">×</button>
        <div class="result-hero">
          <div class="hero-title">本次考试结果</div>
          <div class="hero-bank">
            {{ paper.paperName }}
            <span v-if="paper.attemptLabel">({{ paper.attemptLabel }})</span>
          </div>
          <div class="hero-stats">
            <div class="stat-item">
              <div class="stat-value">{{ rightCount }}</div>
              <div class="stat-label">答对</div>
            </div>
            <div class="stat-divider" />
            <div class="stat-item">
              <div class="stat-value">{{ wrongCount }}</div>
              <div class="stat-label">答错</div>
            </div>
            <div class="stat-divider" />
            <div class="stat-item">
              <div class="stat-value">{{ scoreRate }}%</div>
              <div class="stat-label">正确率</div>
            </div>
            <div class="stat-divider" />
            <div class="stat-item">
              <div class="stat-value">
                {{ submitResult.pendingGrade ? submitResult.objectiveScore : submitResult.totalScore }}/{{
                  paper.totalScore
                }}
              </div>
              <div class="stat-label">{{ submitResult.pendingGrade ? '客观分' : '得分' }}</div>
            </div>
          </div>
          <div class="hero-extra">
            结果:{{ passText() }}
            <span v-if="submitResult.pendingGrade" class="ml-2">(含主观题,待阅卷后出最终成绩)</span>
          </div>
        </div>
        <div class="result-toolbar">
          <a-radio-group v-model:value="resultFilter" button-style="solid" size="small">
            <a-radio-button value="all">全部题目({{ total }})</a-radio-button>
            <a-radio-button value="wrong">仅错题({{ wrongCount }})</a-radio-button>
          </a-radio-group>
          <div class="legend">
            <span class="legend-item correct">正确答案</span>
            <span class="legend-item wrong">你的错误选项</span>
          </div>
        </div>
        <div class="result-list">
          <a-empty v-if="!resultQuestions.length" description="暂无题目" />
          <div
            v-for="(q, idx) in resultQuestions"
            :key="q.id"
            class="question-card"
            :class="!isObjective(q) ? '' : q.right ? 'is-right' : 'is-wrong'"
          >
            <div class="q-head">
              <div class="q-head-left">
                <span class="q-index">第 {{ idx + 1 }} 题</span>
                <span class="q-type">{{ labelOfType(q.questionType) }} · {{ q.score }} 分</span>
              </div>
              <a-tag :color="resultTag(q).color">{{ resultTag(q).text }}</a-tag>
            </div>
            <div class="q-stem">{{ stripHtml(q.stem) }}</div>
            <div v-if="q.options?.length" class="q-options">
              <div
                v-for="opt in q.options"
                :key="opt.optionLabel"
                class="q-option"
                :class="optionClass(q, opt.optionLabel)"
              >
                <span class="opt-label">{{ opt.optionLabel }}</span>
                <span class="opt-content">{{ opt.optionContent }}</span>
              </div>
            </div>
            <div class="q-answer-bar">
              <div>
                <span class="ans-label">你的答案</span>
                <span :class="!isObjective(q) ? '' : q.right ? 'ans-ok' : 'ans-bad'">
                  {{ userAnswerText(q) }}
                </span>
              </div>
              <div v-if="isObjective(q)">
                <span class="ans-label">正确答案</span>
                <span class="ans-ok">{{ correctLabelsOf(q).join('、') || '-' }}</span>
              </div>
            </div>
          </div>
        </div>
        <div class="result-footer">
          <a-button @click="closeResultModal">{{ TMS_BTN.close }}</a-button>
          <a-button type="primary" @click="goBack">{{ TMS_BTN.back }}</a-button>
        </div>
      </div>
    </div>
@@ -191,10 +627,62 @@
  flex-shrink: 0;
}
.countdown {
  min-width: 120px;
  padding: 4px 12px;
  font-size: 16px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: #1890ff;
  background: #e6f7ff;
  border: 1px solid #91d5ff;
  border-radius: 4px;
  text-align: center;
}
.countdown.urgent {
  color: #cf1322;
  background: #fff1f0;
  border-color: #ffa39e;
}
.countdown.ended {
  color: #8c8c8c;
  background: #fafafa;
  border-color: #d9d9d9;
}
.tms-exam-body {
  flex: 1;
  min-height: 0;
  overflow: auto;
}
.q-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.sheet-icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border: 1px solid #d9d9d9;
  border-radius: 6px;
  background: #fff;
  color: #1677ff;
  font-size: 16px;
  cursor: pointer;
  flex-shrink: 0;
}
.sheet-icon-btn:hover {
  border-color: #1677ff;
  background: #e6f4ff;
}
.stem {
@@ -211,4 +699,394 @@
  padding-top: 16px;
  border-top: 1px solid #f0f0f0;
}
.tms-sheet-overlay {
  position: fixed;
  inset: 0;
  z-index: 1900;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(0, 0, 0, 0.45);
}
.tms-sheet-dialog {
  width: min(520px, 100%);
  max-height: min(70vh, 640px);
  display: flex;
  flex-direction: column;
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
  overflow: hidden;
}
.sheet-head {
  position: relative;
  flex-shrink: 0;
  padding: 18px 48px 12px 20px;
  border-bottom: 1px solid #f0f0f0;
}
.sheet-title {
  font-size: 16px;
  font-weight: 600;
  color: rgba(0, 0, 0, 0.88);
}
.sheet-sub {
  margin-top: 4px;
  font-size: 13px;
  color: rgba(0, 0, 0, 0.45);
}
.sheet-legend {
  flex-shrink: 0;
  display: flex;
  gap: 16px;
  padding: 12px 20px 0;
  font-size: 12px;
  color: rgba(0, 0, 0, 0.55);
}
.legend-chip::before {
  content: '';
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-right: 6px;
  border-radius: 3px;
  vertical-align: -2px;
}
.legend-chip.current::before {
  background: #fff;
  border: 2px solid #1677ff;
  box-sizing: border-box;
}
.legend-chip.answered::before {
  background: #1677ff;
}
.legend-chip.unanswered::before {
  background: #f5f5f5;
  border: 1px solid #d9d9d9;
  box-sizing: border-box;
}
.sheet-grid {
  flex: 1;
  min-height: 0;
  overflow: auto;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
  gap: 10px;
  padding: 16px 20px 20px;
}
.sheet-item {
  height: 40px;
  border-radius: 8px;
  border: 1px solid #d9d9d9;
  background: #fafafa;
  color: rgba(0, 0, 0, 0.65);
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.15s ease;
}
.sheet-item.answered {
  background: #1677ff;
  border-color: #1677ff;
  color: #fff;
}
.sheet-item.unanswered {
  background: #f5f5f5;
  border-color: #d9d9d9;
  color: rgba(0, 0, 0, 0.45);
}
.sheet-item.current {
  box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.35);
}
.sheet-item.current.unanswered {
  border-color: #1677ff;
  color: #1677ff;
  background: #e6f4ff;
}
.sheet-item:hover {
  filter: brightness(0.97);
}
.tms-result-overlay {
  position: fixed;
  inset: 0;
  z-index: 2000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(0, 0, 0, 0.45);
}
.tms-result-dialog {
  position: relative;
  width: min(720px, 100%);
  max-height: min(80vh, 780px);
  display: flex;
  flex-direction: column;
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
  overflow: hidden;
}
.tms-result-close {
  position: absolute;
  top: 10px;
  right: 12px;
  z-index: 2;
  width: 32px;
  height: 32px;
  border: none;
  border-radius: 6px;
  background: transparent;
  color: rgba(0, 0, 0, 0.45);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 22px;
  line-height: 1;
}
.tms-result-close:hover {
  background: rgba(0, 0, 0, 0.06);
  color: rgba(0, 0, 0, 0.75);
}
.result-hero {
  flex-shrink: 0;
  padding: 24px 28px 20px;
  background: linear-gradient(135deg, #f0f7ff 0%, #f8fbff 55%, #ffffff 100%);
  border-bottom: 1px solid #eef2f7;
}
.hero-title {
  font-size: 18px;
  font-weight: 600;
  color: rgba(0, 0, 0, 0.88);
  padding-right: 36px;
}
.hero-bank {
  margin-top: 4px;
  font-size: 13px;
  color: rgba(0, 0, 0, 0.45);
}
.hero-stats {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 18px;
  padding: 14px 8px;
  background: #fff;
  border: 1px solid #e8eef5;
  border-radius: 10px;
}
.hero-extra {
  margin-top: 12px;
  font-size: 13px;
  color: rgba(0, 0, 0, 0.65);
}
.stat-item {
  flex: 1;
  text-align: center;
}
.stat-value {
  font-size: 22px;
  font-weight: 700;
  line-height: 1.2;
  color: #1677ff;
}
.stat-label {
  margin-top: 4px;
  font-size: 12px;
  color: rgba(0, 0, 0, 0.45);
}
.stat-divider {
  width: 1px;
  height: 28px;
  background: #eef2f7;
}
.result-toolbar {
  flex-shrink: 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  padding: 12px 28px;
  border-bottom: 1px solid #f0f0f0;
}
.legend {
  display: flex;
  gap: 12px;
  font-size: 12px;
  color: rgba(0, 0, 0, 0.45);
}
.legend-item::before {
  content: '';
  display: inline-block;
  width: 10px;
  height: 10px;
  margin-right: 6px;
  border-radius: 2px;
  vertical-align: -1px;
}
.legend-item.correct::before {
  background: #b7eb8f;
}
.legend-item.wrong::before {
  background: #ffa39e;
}
.result-list {
  flex: 1;
  min-height: 160px;
  overflow: auto;
  padding: 8px 28px 4px;
}
.question-card {
  margin-bottom: 12px;
  padding: 14px 16px;
  border: 1px solid #f0f0f0;
  border-radius: 10px;
  background: #fff;
}
.question-card.is-wrong {
  border-color: #ffccc7;
  background: #fffafa;
}
.question-card.is-right {
  border-color: #d9f7be;
  background: #fcfffb;
}
.q-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}
.q-head-left {
  display: flex;
  align-items: center;
  gap: 8px;
}
.q-index {
  font-weight: 600;
  color: rgba(0, 0, 0, 0.88);
}
.q-type {
  font-size: 12px;
  color: rgba(0, 0, 0, 0.45);
}
.q-stem {
  margin-bottom: 10px;
  font-size: 14px;
  line-height: 1.7;
  color: rgba(0, 0, 0, 0.88);
}
.q-options {
  display: grid;
  gap: 6px;
  margin-bottom: 10px;
}
.q-option {
  display: flex;
  gap: 8px;
  padding: 8px 10px;
  border-radius: 6px;
  background: #fafafa;
  border: 1px solid transparent;
  color: rgba(0, 0, 0, 0.75);
}
.q-option .opt-label {
  min-width: 18px;
  font-weight: 600;
}
.q-option.opt-correct {
  background: #f6ffed;
  color: #389e0d;
  border-color: #b7eb8f;
}
.q-option.opt-wrong {
  background: #fff2f0;
  color: #cf1322;
  border-color: #ffccc7;
}
.q-answer-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 16px 28px;
  padding-top: 8px;
  border-top: 1px dashed #f0f0f0;
  font-size: 13px;
}
.ans-label {
  margin-right: 8px;
  color: rgba(0, 0, 0, 0.45);
}
.ans-ok {
  color: #389e0d;
  font-weight: 600;
}
.ans-bad {
  color: #cf1322;
  font-weight: 600;
}
.result-footer {
  flex-shrink: 0;
  display: flex;
  justify-content: center;
  gap: 16px;
  padding: 14px 28px 18px;
  border-top: 1px solid #f0f0f0;
  background: #fafafa;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/index.vue
@@ -3,15 +3,26 @@
import type { MyPaperListItem } from './types';
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { getMyPaperList, startOnlineExam } from '#/api/x/tms/onlineExam';
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import { MY_PAPER_STATUS_OPTIONS, colorOfMyPaperStatus, labelOfMyPaperStatus } from './constants';
import {
  MY_PAPER_STATUS_OPTIONS,
  colorOfMyPaperStatus,
  colorOfPassFlag,
  labelOfMyPaperStatus,
  labelOfPassFlag,
  loadOnlineExamDics,
} from './constants';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsOnlineExam' });
@@ -20,7 +31,18 @@
const starting = ref(false);
const columns: BasicColumn[] = [
  { title: '试卷名称', dataIndex: 'paperName', minWidth: 260 },
  { title: '试卷名称', dataIndex: 'paperName', minWidth: 220 },
  {
    title: '考试类型',
    dataIndex: 'attemptLabel',
    width: 130,
    align: 'center',
    customRender: ({ record }) => {
      const row = record as MyPaperListItem;
      return row.attemptLabel
        || (row.attemptNo && row.attemptNo > 1 ? `补考(第${row.attemptNo - 1}次)` : '首考');
    },
  },
  {
    title: '状态',
    dataIndex: 'status',
@@ -31,7 +53,7 @@
  {
    title: '考试时间',
    dataIndex: 'examTimeText',
    minWidth: 280,
    minWidth: 260,
    customRender: ({ record }) => (record as MyPaperListItem).examTimeText || '-',
  },
  {
@@ -40,12 +62,30 @@
    width: 100,
    align: 'center',
  },
  {
    title: '是否合格',
    dataIndex: 'passFlag',
    width: 100,
    align: 'center',
    slots: { default: 'passFlag' },
  },
  {
    title: '剩余补考',
    dataIndex: 'remainingRetakes',
    width: 100,
    align: 'center',
    customRender: ({ record }) => {
      const row = record as MyPaperListItem;
      if (row.retakeLimit == null) return '-';
      return `${row.remainingRetakes ?? 0} / ${row.retakeLimit}`;
    },
  },
];
const [registerTable] = useVxeTable({
const [registerTable, { reload, getForm }] = useVxeTable({
  api: fetchList,
  columns,
  immediate: true,
  immediate: false,
  rowKey: 'id',
  useSearchForm: true,
  formConfig: {
@@ -65,21 +105,42 @@
        componentProps: {
          allowClear: true,
          placeholder: '请选择状态',
          options: MY_PAPER_STATUS_OPTIONS,
          options: MY_PAPER_STATUS_OPTIONS.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
    ],
  },
  actionColumn: {
    width: 160,
    width: 200,
    title: '操作',
    dataIndex: 'action',
    fixed: 'right',
  },
});
onMounted(async () => {
  await loadOnlineExamDics();
  getForm()?.updateSchema?.({
    field: 'status',
    componentProps: {
      allowClear: true,
      placeholder: '请选择状态',
      options: MY_PAPER_STATUS_OPTIONS.value,
      fieldNames: TMS_DIC_FIELD_NAMES,
    },
  });
  reload();
});
async function fetchList(params: Record<string, any>) {
  return { data: await getMyPaperList(params) };
  const page = await getMyPaperList(params);
  return {
    data: {
      list: Array.isArray(page?.list) ? page.list : [],
      pagination: page?.pagination || { total: 0 },
    },
  };
}
function handleDetail(record: MyPaperListItem) {
@@ -91,11 +152,22 @@
  starting.value = true;
  try {
    const paper = await startOnlineExam(record.id);
    if (paper?.autoSubmitted) {
      createMessage.warning('考试时间已到,已自动交卷');
      reload();
      return;
    }
    sessionStorage.setItem('tms_online_exam_paper', JSON.stringify(paper));
    sessionStorage.setItem('tms_online_exam_myPaperId', record.id);
    router.push('/tms/onlineExam/exam');
  } catch (e: any) {
    createMessage.error(e?.message || '开始考试失败');
    const msg = e?.message || '开始考试失败';
    if (String(msg).includes('自动交卷')) {
      createMessage.warning(msg);
      reload();
    } else {
      createMessage.error(msg);
    }
  } finally {
    starting.value = false;
  }
@@ -104,11 +176,13 @@
function getTableActions(record: MyPaperListItem): ActionItem[] {
  const actions: ActionItem[] = [];
  if (record.status === 'notStarted') {
    actions.push({ label: '开始考试', onClick: handleStart.bind(null, record) });
    actions.push({ label: TMS_BTN.startExam, onClick: handleStart.bind(null, record) });
  } else if (record.status === 'doing') {
    actions.push({ label: '继续考试', onClick: handleStart.bind(null, record) });
    actions.push({ label: TMS_BTN.continueExam, onClick: handleStart.bind(null, record) });
  } else if (record.status === 'submitted' && record.canRetake) {
    actions.push({ label: TMS_BTN.retake, onClick: handleStart.bind(null, record) });
  }
  actions.push({ label: '查看详情', onClick: handleDetail.bind(null, record) });
  actions.push({ label: TMS_BTN.detail, onClick: handleDetail.bind(null, record) });
  return actions;
}
</script>
@@ -120,8 +194,8 @@
        <BasicVxeTable @register="registerTable">
          <template #tableTitle>
            <div>
              <div class="text-base font-medium">我的试卷</div>
              <div class="mt-1 text-gray-400 text-sm">我的试卷,选择试卷参加考试,或者查看考试详情。</div>
              <div class="tms-page-header__title">我的试卷</div>
              <div class="tms-page-header__sub">参加考试或查看成绩;可考时操作列直接开考。</div>
            </div>
          </template>
          <template #status="{ record }">
@@ -129,6 +203,11 @@
              {{ labelOfMyPaperStatus(record.status) }}
            </span>
          </template>
          <template #passFlag="{ record }">
            <span :style="{ color: colorOfPassFlag(record.passFlag) }">
              {{ labelOfPassFlag(record.passFlag) }}
            </span>
          </template>
          <template #action="{ record }">
            <TableAction :actions="getTableActions(record)" />
          </template>
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/mock.ts
@@ -6,11 +6,50 @@
  OnlineExamQuestionItem,
} from './types';
import { mockGetQuestion, mockQueryQuestions } from '#/views/x/tms/question/mock';
function delay<T>(data: T, ms = 220): Promise<T> {
  return new Promise((resolve) => setTimeout(() => resolve(data), ms));
}
/** 在线考试 mock 自带样题(不再依赖试题管理 mock) */
const DEMO_QUESTIONS: OnlineExamQuestionItem[] = [
  {
    id: 'demo_q1',
    questionNo: '260001',
    questionType: 'judge',
    stem: '特种作业人员必须取得有效资格证书后方可上岗作业。',
    score: 20,
    options: [
      { optionLabel: 'T', optionContent: '正确', isCorrect: '1' },
      { optionLabel: 'F', optionContent: '错误', isCorrect: '0' },
    ],
  },
  {
    id: 'demo_q2',
    questionNo: '260002',
    questionType: 'multi',
    stem: '下列哪些属于特种设备?',
    score: 20,
    options: [
      { optionLabel: 'A', optionContent: '电梯', isCorrect: '1' },
      { optionLabel: 'B', optionContent: '压力容器', isCorrect: '1' },
      { optionLabel: 'C', optionContent: '普通办公桌', isCorrect: '0' },
      { optionLabel: 'D', optionContent: '锅炉', isCorrect: '1' },
    ],
  },
  {
    id: 'demo_q3',
    questionNo: '260003',
    questionType: 'single',
    stem: 'GMP 的全称是?',
    score: 20,
    options: [
      { optionLabel: 'A', optionContent: '药品生产质量管理规范', isCorrect: '1' },
      { optionLabel: 'B', optionContent: '药品经营质量管理规范', isCorrect: '0' },
      { optionLabel: 'C', optionContent: '实验室管理规范', isCorrect: '0' },
      { optionLabel: 'D', optionContent: '文件管理规范', isCorrect: '0' },
    ],
  },
];
const store: MyPaperListItem[] = [
  {
@@ -89,48 +128,11 @@
  });
}
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;
@@ -142,7 +144,7 @@
    totalScore: row.totalScore,
    passScore: row.passScore,
    durationMin: row.durationMin,
    questions,
    questions: DEMO_QUESTIONS.map((q) => ({ ...q, options: q.options.map((o) => ({ ...o })) })),
  });
}
apps/jnpf-web-apps-main/src/views/x/tms/onlineExam/types.ts
@@ -17,7 +17,17 @@
  durationMin?: number;
  /** 已交卷时的得分 */
  gotScore?: number;
  /** 1合格 / 0不合格 / 空=未出结果 */
  passFlag?: '0' | '1' | '';
  examId?: string;
  /** 当前/最近一次考试序号,1=首考 */
  attemptNo?: number;
  /** 首考 / 补考(第1次)…(补考序号不含首考) */
  attemptLabel?: string;
  submittedCount?: number;
  retakeLimit?: number;
  remainingRetakes?: number;
  canRetake?: boolean;
}
export interface MyPaperPageQuery {
@@ -35,16 +45,60 @@
  totalScore: number;
  passScore?: number;
  durationMin?: number;
  /** 开考时间,倒计时按此续算 */
  startTime?: string;
  /** 服务端剩余秒数 */
  remainSeconds?: number | null;
  /** 开考接口发现已超时并完成自动交卷 */
  autoSubmitted?: boolean;
  attemptNo?: number;
  attemptLabel?: string;
  questions: OnlineExamQuestionItem[];
}
export type OnlineQuestionType = 'single' | 'multi' | 'judge' | 'blank' | 'essay';
export interface OnlineExamQuestionItem {
  id: string;
  questionId?: string;
  questionNo?: string;
  questionType: 'single' | 'multi' | 'judge';
  questionType: OnlineQuestionType;
  stem: string;
  score: number;
  options: { optionLabel: string; optionContent: string; isCorrect: '0' | '1' }[];
  gotScore?: number | null;
  userAnswer?: string;
  options: { optionLabel: string; optionContent: string; isCorrect?: '0' | '1' }[];
  /** 交卷后回填 */
  right?: boolean | null;
  correctAnswer?: string;
  subjective?: boolean;
}
export interface OnlineExamSubmitResult {
  objectiveScore?: number;
  totalScore?: number;
  passFlag?: '0' | '1' | '';
  gradeStatus?: string;
  pendingGrade?: boolean;
  items?: {
    id: string;
    correctAnswer?: string;
    gotScore?: number | null;
    right?: boolean | null;
    subjective?: boolean;
  }[];
}
export interface OnlineExamAttempt {
  examId: string;
  attemptNo?: number;
  attemptLabel?: string;
  status?: MyPaperStatus;
  gradeStatus?: string;
  startTime?: string;
  submitTime?: string;
  gotScore?: number | null;
  passFlag?: '0' | '1' | '';
}
export interface OnlineExamDetail {
@@ -60,4 +114,16 @@
  startTime?: string;
  submitTime?: string;
  passFlag?: '0' | '1';
  gradeStatus?: string;
  examId?: string;
  attemptNo?: number;
  attemptLabel?: string;
  submittedCount?: number;
  retakeLimit?: number;
  remainingRetakes?: number;
  canRetake?: boolean;
  /** 历史答卷 */
  attempts?: OnlineExamAttempt[];
  /** 已交卷回顾 */
  questions?: OnlineExamQuestionItem[];
}
apps/jnpf-web-apps-main/src/views/x/tms/paper/Detail.vue
New file
@@ -0,0 +1,163 @@
<script lang="ts" setup>
import type { PaperEntity } 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 { getPaperInfo } from '#/api/x/tms/paper';
import {
  labelOfSortMode,
  labelOfStatus,
  labelOfType,
  loadPaperDics,
} from './constants';
defineOptions({ name: 'TmsPaperDetail' });
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const loading = ref(false);
const detail = ref<PaperEntity | null>(null);
onMounted(async () => {
  await loadPaperDics();
  await loadData();
});
async function loadData() {
  const id = String(route.params.id || '');
  if (!id) {
    router.replace('/tms/paper');
    return;
  }
  loading.value = true;
  try {
    detail.value = await getPaperInfo(id);
  } catch (e: any) {
    createMessage.error(e?.message || '加载试卷失败');
    router.replace('/tms/paper');
  } finally {
    loading.value = false;
  }
}
function goBack() {
  router.push('/tms/paper');
}
function goEdit() {
  if (!detail.value?.id || detail.value.bizStatus !== 'closed') return;
  router.push(`/tms/paper/edit/${detail.value.id}`);
}
function stripHtml(html?: string) {
  if (!html) return '';
  return html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim();
}
</script>
<template>
  <div class="jnpf-content-wrapper tms-paper-detail-page" v-loading="loading">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-paper-detail-wrap" v-if="detail">
        <a-card title="试卷详情" :bordered="false">
          <template #extra>
            <a-space>
              <a-button @click="goBack">返回</a-button>
              <a-button
                type="primary"
                :disabled="detail.bizStatus !== 'closed'"
                @click="goEdit"
              >
                编辑
              </a-button>
            </a-space>
          </template>
          <ADescriptions :column="2" bordered size="small">
            <ADescriptionsItem label="试卷编号">{{ detail.paperNo || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="试卷名称">{{ detail.paperName }}</ADescriptionsItem>
            <ADescriptionsItem label="状态">{{ labelOfStatus(detail.bizStatus) }}</ADescriptionsItem>
            <ADescriptionsItem label="考试时长">{{ detail.durationMin ?? '-' }} 分钟</ADescriptionsItem>
            <ADescriptionsItem label="试题排序">{{ labelOfSortMode(detail.sortMode) }}</ADescriptionsItem>
            <ADescriptionsItem label="开考时间">{{ detail.examStart || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="结束时间">{{ detail.examEnd || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="成绩公布时间">{{ detail.scorePublishTime || '立即公布' }}</ADescriptionsItem>
            <ADescriptionsItem label="试卷总分">{{ detail.totalScore ?? '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="合格分数">{{ detail.passScore ?? '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="含主观题">{{ detail.hasSubjective === '1' ? '是' : '否' }}</ADescriptionsItem>
            <ADescriptionsItem label="创建时间">{{ detail.creatorTime || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="备注" :span="2">{{ detail.remark || '-' }}</ADescriptionsItem>
          </ADescriptions>
        </a-card>
        <a-card title="组卷内容" :bordered="false" style="margin-top: 12px">
          <div v-for="sec in detail.sections || []" :key="sec.id" class="section-block">
            <div class="section-title">
              {{ sec.sectionName }}
              <span class="muted">({{ labelOfType(sec.questionType) }} · {{ (sec.questions || []).length }} 题)</span>
            </div>
            <a-table
              size="small"
              row-key="questionId"
              :pagination="false"
              :data-source="sec.questions || []"
              :columns="[
                { title: '序号', width: 60, customRender: ({ index }: any) => index + 1 },
                { title: '编号', dataIndex: 'questionNo', width: 90 },
                {
                  title: '题干',
                  dataIndex: 'stem',
                  customRender: ({ record }: any) => stripHtml(record.stem),
                },
                { title: '题库', dataIndex: 'bankName', width: 140 },
                { title: '分值', dataIndex: 'score', width: 80 },
              ]"
            />
          </div>
          <a-empty v-if="!(detail.sections || []).length" description="暂无组卷内容" />
        </a-card>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-paper-detail-page {
  height: 100%;
  min-height: 0;
}
.tms-paper-detail-wrap {
  height: 100%;
  min-height: 0;
  overflow-x: hidden;
  overflow-y: auto !important;
  background: #fff;
  padding: 16px;
  box-sizing: border-box;
}
.section-block {
  margin-bottom: 16px;
}
.section-title {
  margin-bottom: 8px;
  font-weight: 600;
}
.muted {
  margin-left: 8px;
  color: #999;
  font-weight: 400;
  font-size: 13px;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/paper/Form.vue
New file
@@ -0,0 +1,676 @@
<script lang="ts" setup>
import type { PaperEntity, PaperQuestionItem, PaperSectionItem } from './types';
import type { QuestionEntity } from '#/views/x/tms/question/types';
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { useModal } from '@jnpf/ui/modal';
import { Modal } from 'ant-design-vue';
import dayjs from 'dayjs';
import { createPaper, getPaperInfo, updatePaper } from '#/api/x/tms/paper';
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
import { loadQuestionDics } from '#/views/x/tms/question/constants';
import QuestionPicker from './components/QuestionPicker.vue';
import {
  QUESTION_TYPE_OPTIONS,
  SORT_MODE_OPTIONS,
  STATUS_OPTIONS,
  labelOfType,
  loadPaperDics,
  nextTmpId,
} from './constants';
defineOptions({ name: 'TmsPaperForm' });
const DT_FMT = 'YYYY-MM-DD HH:mm:ss';
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const loading = ref(false);
const submitting = ref(false);
const formRef = ref();
const activeSectionId = ref('');
const isEdit = computed(() => !!route.params.id && route.params.id !== 'create');
const pageTitle = computed(() => (isEdit.value ? '编辑试卷' : '新增试卷'));
const dataForm = reactive<PaperEntity>({
  paperName: '',
  bizStatus: 'open',
  durationMin: 60,
  examStart: undefined,
  examEnd: undefined,
  scorePublishTime: undefined,
  passScore: undefined,
  totalScore: 0,
  sortMode: 'paper',
  remark: '',
  sections: [],
});
const statusTip = computed(
  () => STATUS_OPTIONS.value.find((x) => (x.enCode || x.id) === dataForm.bizStatus)?.tip,
);
const statusTipColor = computed(
  () => STATUS_OPTIONS.value.find((x) => (x.enCode || x.id) === dataForm.bizStatus)?.tipColor,
);
const editableStatusOptions = computed(() =>
  STATUS_OPTIONS.value.filter((x) => (x.enCode || x.id) !== 'invalid'),
);
const computedTotal = computed(() => {
  let sum = 0;
  for (const sec of dataForm.sections || []) {
    for (const q of sec.questions || []) {
      sum += Number(q.score || 0);
    }
  }
  return Math.round(sum * 10) / 10;
});
function toDayjs(v: any) {
  if (v == null || v === '') return null;
  if (typeof v === 'number') {
    const d = dayjs(v);
    return d.isValid() ? d : null;
  }
  const text = String(v).trim();
  if (!text) return null;
  if (/^\d+$/.test(text)) {
    const n = Number(text);
    const d = dayjs(text.length === 10 ? n * 1000 : n);
    return d.isValid() ? d : null;
  }
  const d = dayjs(text);
  return d.isValid() ? d : null;
}
function formatDateTime(v: any): string | undefined {
  const d = toDayjs(v);
  return d ? d.format(DT_FMT) : undefined;
}
const validateExamEnd = async (_rule: any, value: any) => {
  const end = toDayjs(value);
  const start = toDayjs(dataForm.examStart);
  if (!end && !start) return;
  if (start && !end) {
    return Promise.reject('请选择结束时间');
  }
  if (!start && end) {
    return Promise.reject('请先选择开考时间');
  }
  if (start && end && !end.isAfter(start)) {
    return Promise.reject('结束时间必须晚于开考时间');
  }
  const duration = Number(dataForm.durationMin || 0);
  if (start && end && duration > 0) {
    const spanMin = end.diff(start, 'minute');
    if (duration > spanMin) {
      return Promise.reject(`考试时长不能超过开考至结束的间隔(${spanMin} 分钟)`);
    }
  }
};
const validateExamStart = async (_rule: any, value: any) => {
  const start = toDayjs(value);
  const end = toDayjs(dataForm.examEnd);
  if (!start && end) {
    return Promise.reject('请选择开考时间');
  }
  if (start && end && !end.isAfter(start)) {
    return Promise.reject('开考时间必须早于结束时间');
  }
};
const validatePublishTime = async (_rule: any, value: any) => {
  const publish = toDayjs(value);
  if (!publish) return;
  const end = toDayjs(dataForm.examEnd);
  const start = toDayjs(dataForm.examStart);
  if (end && publish.isBefore(end)) {
    return Promise.reject('成绩公布时间不能早于结束时间');
  }
  if (!end && start && publish.isBefore(start)) {
    return Promise.reject('成绩公布时间不能早于开考时间');
  }
};
const validateDuration = async (_rule: any, value: any) => {
  if (value == null || value === '') return;
  const n = Number(value);
  if (Number.isNaN(n) || n < 0) {
    return Promise.reject('考试时长不能为负数');
  }
  const start = toDayjs(dataForm.examStart);
  const end = toDayjs(dataForm.examEnd);
  if (start && end && n > 0) {
    const spanMin = end.diff(start, 'minute');
    if (n > spanMin) {
      return Promise.reject(`考试时长不能超过开考至结束的间隔(${spanMin} 分钟)`);
    }
  }
};
const validatePassScore = async (_rule: any, value: any) => {
  if (value == null || value === '') return;
  const pass = Number(value);
  if (Number.isNaN(pass) || pass < 0) {
    return Promise.reject('合格分数不能为负数');
  }
  const total = computedTotal.value;
  if (pass > total) {
    return Promise.reject(`合格分数不能大于试卷总分(当前总分 ${total})`);
  }
};
const rules = {
  paperName: [{ required: true, message: '请填写试卷名称', trigger: 'blur' }],
  bizStatus: [{ required: true, message: '请选择状态', trigger: 'change' }],
  sortMode: [{ required: true, message: '请选择试题排序', trigger: 'change' }],
  durationMin: [{ validator: validateDuration, trigger: 'change' }],
  examStart: [{ validator: validateExamStart, trigger: 'change' }],
  examEnd: [{ validator: validateExamEnd, trigger: 'change' }],
  scorePublishTime: [{ validator: validatePublishTime, trigger: 'change' }],
  passScore: [{ validator: validatePassScore, trigger: 'change' }],
};
function revalidateTimes() {
  formRef.value?.validateFields?.(['examStart', 'examEnd', 'scorePublishTime', 'durationMin']).catch(() => undefined);
}
function revalidatePassScore() {
  formRef.value?.validateFields?.(['passScore']).catch(() => undefined);
}
function toTimestamp(v: any): number | undefined {
  const d = toDayjs(v);
  return d ? d.valueOf() : undefined;
}
const [registerPicker, { openModal: openPicker }] = useModal();
onMounted(async () => {
  await Promise.all([loadPaperDics(), loadQuestionDics()]);
  if (isEdit.value) {
    await loadDetail(String(route.params.id));
  }
});
async function loadDetail(id: string) {
  loading.value = true;
  try {
    const info = await getPaperInfo(id);
    Object.assign(dataForm, {
      ...info,
      sections: (info.sections || []).map((s) => ({
        ...s,
        id: s.id || nextTmpId('sec'),
        questions: (s.questions || []).map((q) => ({ ...q })),
      })),
    });
    if (dataForm.sections?.length) {
      activeSectionId.value = dataForm.sections[0]!.id;
    }
  } catch (e: any) {
    createMessage.error(e?.message || '加载失败');
  } finally {
    loading.value = false;
  }
}
function goList() {
  router.push('/tms/paper');
}
function handleCancel() {
  Modal.confirm({
    title: '确认取消',
    content: '确定取消编辑吗?未保存的内容将丢失。',
    okText: '确定',
    cancelText: '继续编辑',
    onOk: () => goList(),
  });
}
function addSection() {
  const sec: PaperSectionItem = {
    id: nextTmpId('sec'),
    sectionName: '单选题',
    questionType: 'single',
    sortNo: (dataForm.sections?.length || 0) + 1,
    questions: [],
  };
  if (!dataForm.sections) dataForm.sections = [];
  dataForm.sections.push(sec);
  activeSectionId.value = sec.id;
  nextTick(() => {
    document.getElementById(`paper-sec-${sec.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  });
}
function removeSection(secId: string) {
  dataForm.sections = (dataForm.sections || []).filter((s) => s.id !== secId);
  if (activeSectionId.value === secId) {
    activeSectionId.value = dataForm.sections[0]?.id || '';
  }
}
function onSectionTypeChange(sec: PaperSectionItem) {
  if (sec.questionType === 'single') sec.sectionName = '单选题';
  else if (sec.questionType === 'multi') sec.sectionName = '多选题';
  else if (sec.questionType === 'judge') sec.sectionName = '判断题';
  else if (sec.questionType === 'blank') sec.sectionName = '填空题';
  else if (sec.questionType === 'essay') sec.sectionName = '问答题';
}
function openPick(sec: PaperSectionItem) {
  if (!sec.questionType) {
    createMessage.warning('请先选择章节题型');
    return;
  }
  activeSectionId.value = sec.id;
  const excludeIds = (dataForm.sections || [])
    .flatMap((s) => s.questions || [])
    .map((q) => q.questionId)
    .filter(Boolean);
  openPicker(true, { questionType: sec.questionType, excludeIds });
}
function onPickConfirm(rows: QuestionEntity[]) {
  const sec = (dataForm.sections || []).find((s) => s.id === activeSectionId.value);
  if (!sec) return;
  const start = sec.questions.length;
  for (let i = 0; i < rows.length; i++) {
    const row = rows[i]!;
    if (!row.id) continue;
    if (sec.questionType && row.questionType && row.questionType !== sec.questionType) {
      createMessage.warning(`试题 ${row.questionNo} 与章节题型不一致,已跳过`);
      continue;
    }
    sec.questions.push({
      questionId: row.id,
      questionNo: row.questionNo,
      stem: row.stem,
      questionType: row.questionType,
      bankName: row.bankName,
      score: 1,
      sortNo: start + i + 1,
    } as PaperQuestionItem);
  }
  syncTotal();
}
function removeQuestion(sec: PaperSectionItem, idx: number) {
  sec.questions.splice(idx, 1);
  sec.questions.forEach((q, i) => {
    q.sortNo = i + 1;
  });
  syncTotal();
}
function moveQuestion(sec: PaperSectionItem, idx: number, delta: number) {
  const target = idx + delta;
  if (target < 0 || target >= sec.questions.length) return;
  const list = sec.questions;
  const tmp = list[idx]!;
  list[idx] = list[target]!;
  list[target] = tmp;
  list.forEach((q, i) => {
    q.sortNo = i + 1;
  });
}
function syncTotal() {
  dataForm.totalScore = computedTotal.value;
  revalidatePassScore();
}
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;
}
async function handleSubmit() {
  try {
    await formRef.value?.validate();
  } catch {
    return;
  }
  if (!(dataForm.sections || []).some((s) => (s.questions || []).length)) {
    createMessage.warning('请至少添加一道试题');
    return;
  }
  for (const sec of dataForm.sections || []) {
    for (const q of sec.questions || []) {
      if (q.score == null || Number(q.score) <= 0) {
        createMessage.warning(`试题 ${q.questionNo || ''} 分值必须大于 0`);
        return;
      }
    }
  }
  if (dataForm.passScore != null && Number(dataForm.passScore) > computedTotal.value) {
    createMessage.warning(`合格分数不能大于试卷总分(当前总分 ${computedTotal.value})`);
    return;
  }
  syncTotal();
  submitting.value = true;
  try {
    const payload: PaperEntity = {
      ...dataForm,
      examStart: formatDateTime(dataForm.examStart) ?? null,
      examEnd: formatDateTime(dataForm.examEnd) ?? null,
      scorePublishTime: formatDateTime(dataForm.scorePublishTime) ?? null,
      sections: (dataForm.sections || []).map((s, si) => ({
        ...s,
        sortNo: si + 1,
        questions: (s.questions || []).map((q, qi) => ({
          questionId: q.questionId,
          score: q.score ?? 0,
          sortNo: qi + 1,
        })),
      })),
    };
    if (isEdit.value) {
      await updatePaper(payload);
      createMessage.success('更新成功');
    } else {
      await createPaper(payload);
      createMessage.success('创建成功');
    }
    goList();
  } catch (e: any) {
    createMessage.error(e?.message || '保存失败');
  } finally {
    submitting.value = false;
  }
}
</script>
<template>
  <div class="jnpf-content-wrapper tms-paper-form-page" v-loading="loading">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-paper-form-wrap">
        <div class="page-toolbar">
          <div class="page-title">{{ pageTitle }}</div>
        </div>
        <div class="tms-paper-form-body">
          <a-card title="基本信息" :bordered="false" class="form-card">
            <a-form ref="formRef" :model="dataForm" :rules="rules" :label-col="{ style: { width: '120px' } }">
              <a-row :gutter="16">
                <a-col v-if="dataForm.id" :span="12">
                  <a-form-item label="试卷编号">
                    <a-input :value="dataForm.paperNo || '-'" disabled />
                  </a-form-item>
                </a-col>
                <a-col :span="12">
                  <a-form-item label="试卷名称" name="paperName">
                    <a-input v-model:value="dataForm.paperName" placeholder="请输入试卷名称" :maxlength="200" />
                  </a-form-item>
                </a-col>
                <a-col :span="12">
                  <a-form-item label="试卷状态" name="bizStatus">
                    <a-select
                      v-model:value="dataForm.bizStatus"
                      :options="editableStatusOptions"
                      :field-names="TMS_DIC_FIELD_NAMES"
                      style="width: 160px"
                    />
                    <span v-if="statusTip" class="status-tip" :style="{ color: statusTipColor }">{{ statusTip }}</span>
                  </a-form-item>
                </a-col>
                <a-col :span="12">
                  <a-form-item label="考试时长" name="durationMin">
                    <a-input-number
                      v-model:value="dataForm.durationMin"
                      :min="0"
                      :precision="0"
                      style="width: 160px"
                      @change="revalidateTimes"
                    />
                    <span class="field-hint">分钟(不应超过设置的时间间隔)</span>
                  </a-form-item>
                </a-col>
                <a-col :span="12">
                <a-form-item label="试题排序" name="sortMode">
                  <a-radio-group v-model:value="dataForm.sortMode">
                    <a-radio v-for="opt in SORT_MODE_OPTIONS" :key="opt.enCode || opt.id" :value="opt.enCode || opt.id">
                      {{ opt.fullName }}
                    </a-radio>
                  </a-radio-group>
                </a-form-item>
                </a-col>
                <a-col :span="12">
                  <a-form-item label="开考时间" name="examStart">
                    <jnpf-date-picker
                      v-model:value="dataForm.examStart"
                      format="YYYY-MM-DD HH:mm:ss"
                      placeholder="请选择开考时间"
                      style="width: 100%"
                      :end-time="toTimestamp(dataForm.examEnd)"
                      @change="revalidateTimes"
                    />
                  </a-form-item>
                </a-col>
                <a-col :span="12">
                  <a-form-item label="结束时间" name="examEnd">
                    <jnpf-date-picker
                      v-model:value="dataForm.examEnd"
                      format="YYYY-MM-DD HH:mm:ss"
                      placeholder="请选择结束时间"
                      style="width: 100%"
                      :start-time="toTimestamp(dataForm.examStart)"
                      @change="revalidateTimes"
                    />
                  </a-form-item>
                </a-col>
                <a-col :span="12">
                  <a-form-item label="成绩公布时间" name="scorePublishTime">
                    <jnpf-date-picker
                      v-model:value="dataForm.scorePublishTime"
                      format="YYYY-MM-DD HH:mm:ss"
                      placeholder="立即公布请留空"
                      style="width: 100%"
                      :start-time="toTimestamp(dataForm.examEnd || dataForm.examStart)"
                      @change="revalidateTimes"
                    />
                    <div class="field-hint block">设置成绩公布时间,立即公布请留空</div>
                  </a-form-item>
                </a-col>
                <a-col :span="12">
                  <a-form-item label="试卷总分">
                    <a-input-number :value="computedTotal" disabled style="width: 160px" />
                    <span class="field-hint">由组卷分值自动汇总</span>
                  </a-form-item>
                </a-col>
                <a-col :span="12">
                  <a-form-item label="合格分数" name="passScore">
                    <a-input-number
                      v-model:value="dataForm.passScore"
                      :min="0"
                      :precision="1"
                      style="width: 160px"
                      @change="revalidatePassScore"
                    />
                    <span class="field-hint">不能大于试卷总分</span>
                  </a-form-item>
                </a-col>
                <a-col :span="24">
                  <a-form-item label="备注">
                    <a-textarea v-model:value="dataForm.remark" :rows="3" placeholder="请输入备注" />
                  </a-form-item>
                </a-col>
              </a-row>
            </a-form>
          </a-card>
          <a-card title="组卷" :bordered="false" class="form-card">
            <template #extra>
              <a-button type="primary" @click="addSection">添加章节</a-button>
            </template>
            <a-empty v-if="!(dataForm.sections || []).length" description="请添加章节并从试题管理选题组卷" />
            <div
              v-for="sec in dataForm.sections"
              :id="`paper-sec-${sec.id}`"
              :key="sec.id"
              class="section-block"
            >
              <div class="section-head">
                <a-input v-model:value="sec.sectionName" placeholder="章节名称" style="width: 200px" />
                <a-select
                  v-model:value="sec.questionType"
                  placeholder="题型"
                  style="width: 140px; margin-left: 8px"
                  :options="QUESTION_TYPE_OPTIONS"
                  :field-names="TMS_DIC_FIELD_NAMES"
                  @change="onSectionTypeChange(sec)"
                />
                <a-space style="margin-left: auto">
                  <a-button type="link" @click="openPick(sec)">选题</a-button>
                  <a-button type="link" danger @click="removeSection(sec.id)">删除章节</a-button>
                </a-space>
              </div>
              <a-table
                size="small"
                row-key="questionId"
                :pagination="false"
                :data-source="sec.questions"
                :columns="[
                  { title: '序号', width: 60, key: 'idx' },
                  { title: '编号', dataIndex: 'questionNo', width: 90 },
                  { title: '题型', width: 80, key: 'qtype' },
                  { title: '题干', dataIndex: 'stem', ellipsis: true, key: 'stem' },
                  { title: '题库', dataIndex: 'bankName', width: 140, ellipsis: true },
                  { title: '分值', width: 110, key: 'score' },
                  { title: '操作', width: 160, key: 'action' },
                ]"
              >
                <template #bodyCell="{ column, record, index }">
                  <template v-if="column.key === 'idx'">{{ index + 1 }}</template>
                  <template v-else-if="column.key === 'qtype'">{{ labelOfType(record.questionType) }}</template>
                  <template v-else-if="column.key === 'stem'">{{ stripHtml(record.stem) }}</template>
                  <template v-else-if="column.key === 'score'">
                    <a-input-number
                      v-model:value="record.score"
                      :min="0"
                      :precision="1"
                      size="small"
                      style="width: 90px"
                      @change="syncTotal"
                    />
                  </template>
                  <template v-else-if="column.key === 'action'">
                    <a-space>
                      <a @click="moveQuestion(sec, index, -1)">上移</a>
                      <a @click="moveQuestion(sec, index, 1)">下移</a>
                      <a class="danger" @click="removeQuestion(sec, index)">移除</a>
                    </a-space>
                  </template>
                </template>
              </a-table>
            </div>
          </a-card>
          <div class="form-footer">
            <a-space>
              <a-button @click="handleCancel">取消</a-button>
              <a-button type="primary" :loading="submitting" @click="handleSubmit">保存</a-button>
            </a-space>
          </div>
        </div>
      </div>
    </div>
    <QuestionPicker @register="registerPicker" @confirm="onPickConfirm" />
  </div>
</template>
<style scoped>
.tms-paper-form-page {
  height: 100%;
  min-height: 0;
}
.tms-paper-form-wrap {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
  overflow: hidden;
  background: #fff;
  padding: 16px;
}
.page-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-shrink: 0;
  margin-bottom: 12px;
  padding-bottom: 12px;
  border-bottom: 1px solid #f0f0f0;
}
.page-title {
  font-size: 16px;
  font-weight: 600;
}
.tms-paper-form-body {
  flex: 1;
  min-height: 0;
  overflow-x: hidden;
  overflow-y: auto;
}
.form-card {
  margin-bottom: 12px;
}
.status-tip,
.field-hint {
  margin-left: 8px;
  color: #999;
  font-size: 12px;
}
.field-hint.block {
  display: block;
  margin: 4px 0 0;
}
.section-block {
  margin-bottom: 16px;
  padding: 12px;
  background: #fafafa;
  border: 1px solid #f0f0f0;
  border-radius: 4px;
}
.section-head {
  display: flex;
  align-items: center;
  margin-bottom: 8px;
}
.form-footer {
  padding: 16px 0 24px;
  text-align: center;
}
.danger {
  color: #ff4d4f;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/paper/components/QuestionPicker.vue
New file
@@ -0,0 +1,229 @@
<script lang="ts" setup>
import type { QuestionBankOption, QuestionEntity } from '#/views/x/tms/question/types';
import { computed, reactive, ref } from 'vue';
import { useMessage } from '@jnpf/hooks';
import { BasicModal, useModalInner } from '@jnpf/ui/modal';
import { getQuestionBanks, getQuestionList } from '#/api/x/tms/question';
import { labelOfType } from '#/views/x/tms/question/constants';
defineOptions({ name: 'TmsPaperQuestionPicker' });
const emit = defineEmits<{
  confirm: [QuestionEntity[]];
}>();
const { createMessage } = useMessage();
const banks = ref<QuestionBankOption[]>([]);
const loading = ref(false);
const list = ref<QuestionEntity[]>([]);
const selectedKeys = ref<string[]>([]);
const selectedRows = ref<QuestionEntity[]>([]);
const excludeIds = ref<Set<string>>(new Set());
/** 章节已定题型:选题时锁定,只需再选题库 */
const lockedType = ref<string | undefined>();
const query = reactive({
  bankId: undefined as string | undefined,
  keyword: '',
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { title: '编号', dataIndex: 'questionNo', width: 90 },
  { title: '题库', dataIndex: 'bankName', width: 140, ellipsis: true },
  { title: '题型', dataIndex: 'questionType', width: 80, key: 'questionType' },
  { title: '题干', dataIndex: 'stem', ellipsis: true, key: 'stem' },
];
const typeLabel = computed(() => (lockedType.value ? labelOfType(lockedType.value) : ''));
const [registerModal, { closeModal }] = useModalInner(async (data: any) => {
  excludeIds.value = new Set(Array.isArray(data?.excludeIds) ? data.excludeIds : []);
  lockedType.value = data?.questionType || undefined;
  query.bankId = undefined;
  query.keyword = '';
  query.currentPage = 1;
  query.total = 0;
  list.value = [];
  selectedKeys.value = [];
  selectedRows.value = [];
  if (!banks.value.length) {
    banks.value = (await getQuestionBanks()) || [];
  }
});
async function loadList() {
  if (!query.bankId) {
    list.value = [];
    query.total = 0;
    return;
  }
  loading.value = true;
  try {
    const page = await getQuestionList({
      bankId: query.bankId,
      questionType: lockedType.value || '',
      bizStatus: 'open',
      keyword: query.keyword || undefined,
      currentPage: query.currentPage,
      pageSize: query.pageSize,
    });
    list.value = Array.isArray(page?.list) ? page.list : [];
    query.total = Number(page?.pagination?.total || 0);
  } catch (e: any) {
    createMessage.error(e?.message || '加载试题失败');
    list.value = [];
  } finally {
    loading.value = false;
  }
}
function onSelectChange(keys: (string | number)[], rows: QuestionEntity[]) {
  selectedKeys.value = keys.map(String);
  selectedRows.value = rows;
}
function rowDisabled(record: QuestionEntity) {
  return !!(record.id && excludeIds.value.has(record.id));
}
function stripHtml(html?: string) {
  if (!html) return '';
  const text = html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim();
  return text.length > 60 ? `${text.slice(0, 60)}…` : text;
}
function onBankChange() {
  query.currentPage = 1;
  selectedKeys.value = [];
  selectedRows.value = [];
  loadList();
}
function handleSearch() {
  if (!query.bankId) {
    createMessage.warning('请先选择题库');
    return;
  }
  query.currentPage = 1;
  loadList();
}
function handlePageChange(page: number, pageSize: number) {
  query.currentPage = page;
  query.pageSize = pageSize;
  loadList();
}
function handleOk() {
  if (!query.bankId) {
    createMessage.warning('请先选择题库');
    return;
  }
  const rows = selectedRows.value.filter((r) => r.id && !excludeIds.value.has(r.id));
  if (!rows.length) {
    createMessage.warning('请选择试题');
    return;
  }
  emit('confirm', rows);
  closeModal();
}
</script>
<template>
  <BasicModal
    v-bind="$attrs"
    title="选择试题"
    width="900px"
    destroy-on-close
    @register="registerModal"
    @ok="handleOk"
  >
    <div v-if="lockedType" class="picker-tip">
      当前章节题型:<b>{{ typeLabel }}</b>,请选择题库后勾选试题
    </div>
    <a-form layout="inline" class="picker-query" @submit.prevent>
      <a-form-item label="题库" required>
        <a-select
          v-model:value="query.bankId"
          allow-clear
          show-search
          placeholder="请选择题库"
          style="width: 220px"
          :options="banks"
          :field-names="{ label: 'fullName', value: 'id' }"
          option-filter-prop="fullName"
          @change="onBankChange"
        />
      </a-form-item>
      <a-form-item label="关键词">
        <a-input
          v-model:value="query.keyword"
          allow-clear
          placeholder="编号/题干"
          style="width: 180px"
          :disabled="!query.bankId"
          @press-enter="handleSearch"
        />
      </a-form-item>
      <a-form-item>
        <a-button type="primary" :disabled="!query.bankId" @click="handleSearch">查询</a-button>
      </a-form-item>
    </a-form>
    <a-empty v-if="!query.bankId" description="请先选择题库" />
    <a-table
      v-else
      size="small"
      row-key="id"
      :loading="loading"
      :data-source="list"
      :columns="columns"
      :pagination="{
        current: query.currentPage,
        pageSize: query.pageSize,
        total: query.total,
        showSizeChanger: true,
        onChange: handlePageChange,
      }"
      :row-selection="{
        selectedRowKeys: selectedKeys,
        onChange: onSelectChange,
        getCheckboxProps: (record: QuestionEntity) => ({ disabled: rowDisabled(record) }),
      }"
    >
      <template #bodyCell="{ column, record }">
        <template v-if="column.key === 'questionType'">
          {{ labelOfType(record.questionType) }}
        </template>
        <template v-else-if="column.key === 'stem'">
          {{ stripHtml(record.stem) }}
          <span v-if="rowDisabled(record)" class="picked">(已入卷)</span>
        </template>
      </template>
    </a-table>
  </BasicModal>
</template>
<style scoped>
.picker-tip {
  margin-bottom: 12px;
  color: #666;
  font-size: 13px;
}
.picker-query {
  margin-bottom: 12px;
}
.picked {
  color: #999;
  margin-left: 4px;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/paper/constants.ts
New file
@@ -0,0 +1,49 @@
import { ref } from 'vue';
import { useBaseStore } from '#/store';
import { TMS_DIC, labelOfDic, loadTmsDic, type TmsDicOpt } from '#/views/x/tms/shared/dic';
type StatusOpt = TmsDicOpt & { tip?: string; tipColor?: string };
function withStatusTip(list: TmsDicOpt[]): StatusOpt[] {
  return list.map((x) => {
    const code = x.enCode || x.id;
    if (code === 'open') return { ...x, tip: '学员可作答', tipColor: 'green' };
    if (code === 'closed') return { ...x, tip: '学员不可作答', tipColor: 'red' };
    return x;
  });
}
export const STATUS_OPTIONS = ref<StatusOpt[]>([]);
export const SORT_MODE_OPTIONS = ref<TmsDicOpt[]>([]);
export const QUESTION_TYPE_OPTIONS = ref<TmsDicOpt[]>([]);
export async function loadPaperDics() {
  const baseStore = useBaseStore();
  const [status, sortMode, questionType] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.openClosedInvalid),
    loadTmsDic(baseStore, TMS_DIC.paperSortMode),
    loadTmsDic(baseStore, TMS_DIC.questionType),
  ]);
  STATUS_OPTIONS.value = withStatusTip(status);
  SORT_MODE_OPTIONS.value = sortMode;
  QUESTION_TYPE_OPTIONS.value = questionType;
}
export function labelOfStatus(v?: string) {
  return labelOfDic(STATUS_OPTIONS.value, v);
}
export function labelOfSortMode(v?: string) {
  return labelOfDic(SORT_MODE_OPTIONS.value, v);
}
export function labelOfType(v?: string) {
  return labelOfDic(QUESTION_TYPE_OPTIONS.value, v);
}
let tmpSeq = 0;
export function nextTmpId(prefix = 'tmp') {
  tmpSeq += 1;
  return `${prefix}_${Date.now()}_${tmpSeq}`;
}
apps/jnpf-web-apps-main/src/views/x/tms/paper/index.vue
New file
@@ -0,0 +1,256 @@
<script lang="ts" setup>
import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
import type { PaperEntity } from './types';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { getPaperList, invalidatePaper, setPaperStatus } from '#/api/x/tms/paper';
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
import { STATUS_OPTIONS, labelOfSortMode, labelOfStatus, loadPaperDics } from './constants';
defineOptions({ name: 'TmsPaperList' });
const router = useRouter();
const { createMessage } = useMessage();
const invalidMode = ref(false);
const activeStatusOptions = computed(() =>
  STATUS_OPTIONS.value.filter((x) => (x.enCode || x.id) !== 'invalid'),
);
const columns: BasicColumn[] = [
  { title: '试卷编号', dataIndex: 'paperNo', width: 120 },
  { title: '试卷名称', dataIndex: 'paperName', minWidth: 200 },
  {
    title: '状态',
    dataIndex: 'bizStatus',
    width: 100,
    align: 'center',
    slots: { default: 'bizStatus' },
  },
  {
    title: '时长(分钟)',
    dataIndex: 'durationMin',
    width: 100,
    align: 'center',
  },
  {
    title: '总分',
    dataIndex: 'totalScore',
    width: 90,
    align: 'center',
  },
  {
    title: '合格分',
    dataIndex: 'passScore',
    width: 90,
    align: 'center',
  },
  {
    title: '题量',
    dataIndex: 'questionCount',
    width: 80,
    align: 'center',
  },
  {
    title: '试题排序',
    dataIndex: 'sortMode',
    width: 110,
    customRender: ({ record }) => labelOfSortMode((record as PaperEntity).sortMode),
  },
  { title: '创建时间', dataIndex: 'creatorTime', width: 170 },
];
const [registerTable, { reload, getForm }] = useVxeTable({
  api: fetchList,
  columns,
  immediate: false,
  rowKey: 'id',
  useSearchForm: true,
  formConfig: {
    baseColProps: { span: 6 },
    compact: true,
    schemas: [
      {
        field: 'keyword',
        label: '关键词',
        component: 'Input',
        componentProps: { placeholder: '编号/名称', submitOnPressEnter: true },
      },
      {
        field: 'bizStatus',
        label: '状态',
        component: 'Select',
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: STATUS_OPTIONS.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
    ],
  },
  actionColumn: {
    width: 180,
    title: '操作',
    dataIndex: 'action',
    fixed: 'right',
  },
});
onMounted(async () => {
  await loadPaperDics();
  getForm()?.updateSchema?.([
    {
      field: 'bizStatus',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: activeStatusOptions.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
  ]);
  reload();
});
async function fetchList(params: Record<string, any>) {
  const query = { ...params };
  if (invalidMode.value) {
    query.bizStatus = 'invalid';
  }
  const page = await getPaperList(query);
  return {
    data: {
      list: Array.isArray(page?.list) ? page.list : [],
      pagination: page?.pagination || { total: 0 },
    },
  };
}
function openInvalidList() {
  invalidMode.value = true;
  getForm()?.setFieldsValue?.({ bizStatus: undefined });
  getForm()?.updateSchema?.([{ field: 'bizStatus', ifShow: false }]);
  reload();
}
function backToNormal() {
  invalidMode.value = false;
  getForm()?.updateSchema?.([{ field: 'bizStatus', ifShow: true }]);
  reload();
}
function statusColor(status?: string) {
  if (status === 'open') return '#52c41a';
  if (status === 'closed') return '#ff4d4f';
  if (status === 'invalid') return '#999';
  return undefined;
}
function handleCreate() {
  router.push('/tms/paper/create');
}
function handleEdit(record: PaperEntity) {
  if (record.bizStatus === 'open') {
    createMessage.warning('请先停用后再编辑');
    return;
  }
  if (record.bizStatus === 'invalid') {
    createMessage.warning('已废弃试卷不可编辑');
    return;
  }
  router.push(`/tms/paper/edit/${record.id}`);
}
function handleDetail(record: PaperEntity) {
  router.push(`/tms/paper/detail/${record.id}`);
}
async function handleInvalidate(record: PaperEntity) {
  await invalidatePaper(record.id!);
  createMessage.success('废弃成功');
  reload();
}
async function handleEnable(record: PaperEntity) {
  await setPaperStatus(record.id!, 'open');
  createMessage.success('已启用');
  reload();
}
async function handleDisable(record: PaperEntity) {
  await setPaperStatus(record.id!, 'closed');
  createMessage.success('已停用');
  reload();
}
function getTableActions(record: PaperEntity): ActionItem[] {
  if (record.bizStatus === 'open') {
    return [
      {
        label: '停用',
        modelConfirm: {
          content: `确定停用试卷「${record.paperName}」吗?停用后不可用于培训任务在线考试。`,
          onOk: handleDisable.bind(null, record),
        },
      },
      { label: '详情', onClick: handleDetail.bind(null, record) },
    ];
  }
  if (record.bizStatus === 'closed') {
    return [
      {
        label: '启用',
        modelConfirm: {
          content: `确定启用试卷「${record.paperName}」吗?启用后可用于培训任务在线考试。`,
          onOk: handleEnable.bind(null, record),
        },
      },
      { label: '编辑', onClick: handleEdit.bind(null, record) },
      {
        label: '废弃',
        color: 'error',
        modelConfirm: {
          content: `确定废弃试卷「${record.paperName}」吗?废弃后不可再启用,且不可用于培训任务在线考试。`,
          onOk: handleInvalidate.bind(null, record),
        },
      },
    ];
  }
  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>
              <template v-if="!invalidMode">
                <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleCreate">新增</a-button>
                <a-button @click="openInvalidList">废弃列表</a-button>
              </template>
              <a-button v-else @click="backToNormal">返回</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/paper/types.ts
New file
@@ -0,0 +1,53 @@
/** 试卷状态(字典 tmsOpenClosedInvalid) */
export type PaperStatus = 'open' | 'closed' | 'invalid';
/** 试题排序(字典 tmsPaperSortMode) */
export type PaperSortMode = 'paper' | 'random';
export interface PaperQuestionItem {
  id?: string;
  sectionId?: string;
  questionId: string;
  questionNo?: string;
  stem?: string;
  questionType?: string;
  bankName?: string;
  score?: number | null;
  sortNo?: number;
}
export interface PaperSectionItem {
  /** 前端临时 key 或已保存 ID */
  id: string;
  sectionName: string;
  questionType?: string;
  sortNo?: number;
  questions: PaperQuestionItem[];
}
export interface PaperEntity {
  id?: string;
  /** 试卷编号,如 260001 */
  paperNo?: string;
  paperName: string;
  bizStatus: PaperStatus;
  durationMin?: number | null;
  examStart?: string | number | null;
  examEnd?: string | number | null;
  scorePublishTime?: string | number | null;
  passScore?: number | null;
  totalScore?: number | null;
  sortMode: PaperSortMode;
  hasSubjective?: string;
  remark?: string;
  creatorTime?: string;
  questionCount?: number;
  sections?: PaperSectionItem[];
}
export interface PaperPageQuery {
  currentPage?: number;
  pageSize?: number;
  bizStatus?: PaperStatus | '';
  keyword?: string;
}
apps/jnpf-web-apps-main/src/views/x/tms/personTask/Detail.vue
New file
@@ -0,0 +1,205 @@
<script lang="ts" setup>
import type { PersonTaskItem } from './types';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { Descriptions as ADescriptions, DescriptionsItem as ADescriptionsItem } from 'ant-design-vue';
import { getPersonTaskInfo, signPersonTask } from '#/api/x/tms/personTask';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import {
  labelOfCategory,
  labelOfEvalMode,
  labelOfPersonStatus,
  labelOfTrainMode,
  loadPersonTaskDics,
} from './constants';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsPersonTaskDetail' });
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const loading = ref(false);
const signing = ref(false);
const detail = ref<PersonTaskItem | null>(null);
const progressText = computed(() => {
  const learned = detail.value?.learnedSeconds || 0;
  const required = detail.value?.requiredSeconds || 0;
  return `${formatDuration(learned)} / ${required > 0 ? formatDuration(required) : '未配置'}`;
});
const isClosed = computed(() => detail.value?.bizStatus === 'cancelled' || detail.value?.bizStatus === 'expired');
const canLearn = computed(() => Boolean(detail.value?.id) && !isClosed.value);
const showExam = computed(() => Boolean(detail.value?.canExam && detail.value?.paperId));
const tipMessage = computed(() => {
  if (!detail.value) return '';
  if (detail.value.bizStatus === 'cancelled') return '任务已取消,无法继续学习或考试。';
  if (detail.value.bizStatus === 'expired') return '任务已过期,无法继续学习或考试。';
  if (detail.value.examTip && !detail.value.canExam) return detail.value.examTip;
  if (detail.value.signTip && detail.value.canSign) return detail.value.signTip;
  return '';
});
onMounted(async () => {
  await loadPersonTaskDics();
  await loadData();
});
async function loadData() {
  const id = String(route.params.id || '');
  if (!id) {
    router.replace('/tms/personTask');
    return;
  }
  loading.value = true;
  try {
    detail.value = await getPersonTaskInfo(id);
  } catch (e: any) {
    createMessage.error(e?.message || '加载失败');
    router.replace('/tms/personTask');
  } finally {
    loading.value = false;
  }
}
function goBack() {
  router.push('/tms/personTask');
}
function goLearn() {
  if (!detail.value?.id) return;
  if (isClosed.value) {
    createMessage.warning(tipMessage.value || '当前任务不可学习');
    return;
  }
  router.push(`/tms/personTask/learn/${detail.value.id}`);
}
function goExam() {
  if (!detail.value?.id || !showExam.value) {
    createMessage.info(detail.value?.examTip || '当前暂不可考试');
    return;
  }
  router.push(`/tms/personTask/learn/${detail.value.id}`);
}
async function handleSign() {
  if (!detail.value?.id || !detail.value.canSign || signing.value) return;
  signing.value = true;
  try {
    const result = await signPersonTask(detail.value.id);
    detail.value.signTime = result.signTime;
    detail.value.signed = result.signed ?? true;
    detail.value.canSign = result.canSign ?? false;
    detail.value.signTip = result.signTip || '已签到';
    detail.value.bizStatus = result.bizStatus || detail.value.bizStatus;
    detail.value.canExam = result.canExam;
    detail.value.examTip = result.examTip;
    createMessage.success(result.message || '签到成功');
  } catch (e: any) {
    createMessage.error(e?.message || '签到失败');
  } finally {
    signing.value = false;
  }
}
function formatDuration(seconds: number) {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = seconds % 60;
  if (h > 0) return `${h}小时${m}分`;
  if (m > 0) return `${m}分${s}秒`;
  return `${s}秒`;
}
</script>
<template>
  <div class="jnpf-content-wrapper tms-person-task-page" v-loading="loading">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-person-task-wrap" v-if="detail">
        <div class="tms-page-header">
          <div>
            <div class="tms-page-header__title">个人培训任务</div>
            <div class="tms-page-header__sub">{{ detail.taskNo || '-' }} · {{ detail.subject || '-' }}</div>
          </div>
          <div class="tms-page-header__actions">
            <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
            <a-button
              v-if="detail.canSign"
              type="primary"
              :ghost="showExam"
              :loading="signing"
              @click="handleSign"
            >
              {{ TMS_BTN.sign }}
            </a-button>
            <a-button v-if="canLearn" type="primary" ghost @click="goLearn">{{ TMS_BTN.enterLearn }}</a-button>
            <a-button v-if="showExam" type="primary" @click="goExam">
              {{ detail.passFlag === '0' ? TMS_BTN.retake : TMS_BTN.enterExam }}
            </a-button>
          </div>
        </div>
        <a-alert v-if="tipMessage" class="tms-page-tip" type="warning" show-icon :message="tipMessage" />
        <ADescriptions bordered :column="2" size="small">
          <ADescriptionsItem label="培训编号">{{ detail.taskNo || '-' }}</ADescriptionsItem>
          <ADescriptionsItem label="状态">{{ labelOfPersonStatus(detail.bizStatus) }}</ADescriptionsItem>
          <ADescriptionsItem label="培训主题" :span="2">{{ detail.subject || '-' }}</ADescriptionsItem>
          <ADescriptionsItem label="培训分类">{{ labelOfCategory(detail.category) }}</ADescriptionsItem>
          <ADescriptionsItem label="培训方式">{{ labelOfTrainMode(detail.trainMode) }}</ADescriptionsItem>
          <ADescriptionsItem label="考核方式">{{ labelOfEvalMode(detail.evalMode) }}</ADescriptionsItem>
          <ADescriptionsItem label="签到状态">
            {{
              detail.signed || detail.signTime
                ? `已签到${detail.signTime ? `(${detail.signTime})` : ''}`
                : detail.signTip || '未签到'
            }}
          </ADescriptionsItem>
          <ADescriptionsItem label="是否可考">
            {{ detail.canExam ? '是' : '否' }}
            <span v-if="!detail.canExam && detail.examTip" class="tip-inline">({{ detail.examTip }})</span>
          </ADescriptionsItem>
          <ADescriptionsItem label="学习进度">{{ detail.learnStatus || '-' }}</ADescriptionsItem>
          <ADescriptionsItem label="要求课时">{{ detail.requiredHours ?? '-' }} 小时</ADescriptionsItem>
          <ADescriptionsItem label="已学/需学">{{ progressText }}</ADescriptionsItem>
          <ADescriptionsItem label="开始时间">{{ detail.startTime || '-' }}</ADescriptionsItem>
          <ADescriptionsItem label="结束时间">{{ detail.endTime || '-' }}</ADescriptionsItem>
          <ADescriptionsItem label="关闭时间">{{ detail.closeTime || '-' }}</ADescriptionsItem>
          <ADescriptionsItem label="地点">{{ detail.placeName || '-' }}</ADescriptionsItem>
          <ADescriptionsItem label="试卷">{{ detail.paperName || '-' }}</ADescriptionsItem>
          <ADescriptionsItem label="成绩">{{ detail.examScore ?? '-' }}</ADescriptionsItem>
          <ADescriptionsItem label="培训要点" :span="2">{{ detail.keyPoints || '-' }}</ADescriptionsItem>
        </ADescriptions>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-person-task-page,
.tms-person-task-wrap {
  height: 100%;
  min-height: 0;
}
.tms-person-task-wrap {
  overflow: auto;
  background: #fff;
  padding: 16px 20px 24px;
  box-sizing: border-box;
}
.tip-inline {
  color: #fa8c16;
  margin-left: 4px;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/personTask/Learn.vue
New file
@@ -0,0 +1,410 @@
<script lang="ts" setup>
import type { PersonTaskFile, PersonTaskItem } from './types';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { startOnlineExam } from '#/api/x/tms/onlineExam';
import { getPersonTaskInfo, markPersonTaskLearned, signPersonTask } from '#/api/x/tms/personTask';
import { getAuthMediaUrl } from '#/utils/jnpf';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsPersonTaskLearn' });
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const loading = ref(false);
const acting = ref(false);
const signing = ref(false);
const detail = ref<PersonTaskItem | null>(null);
const activeFileId = ref<string>();
const sessionSeconds = ref(0);
const notifiedFull = ref(false);
let learnTimer: ReturnType<typeof setInterval> | null = null;
let flushTimer: ReturnType<typeof setInterval> | null = null;
let pendingSeconds = 0;
const files = computed(() => detail.value?.files || []);
const activeFile = computed(() => files.value.find((f) => f.id === activeFileId.value) || files.value[0]);
const previewUrl = computed(() => toFullUrl(activeFile.value?.url));
const progressText = computed(() => {
  const learned = detail.value?.learnedSeconds || 0;
  const required = detail.value?.requiredSeconds || 0;
  return `${formatDuration(learned)} / ${required > 0 ? formatDuration(required) : '未配置'}`;
});
const progressPercent = computed(() => {
  const required = detail.value?.requiredSeconds || 0;
  if (required <= 0) return 0;
  return Math.min(100, Math.round(((detail.value?.learnedSeconds || 0) / required) * 100));
});
const signStatusText = computed(() => {
  if (detail.value?.signed || detail.value?.signTime) {
    return `已签到${detail.value.signTime ? `(${detail.value.signTime})` : ''}`;
  }
  return detail.value?.signTip || '未签到';
});
const showSignButton = computed(() => Boolean(detail.value?.canSign));
onMounted(async () => {
  await loadData();
  startTimer();
});
onBeforeUnmount(() => {
  stopTimer(true);
});
watch(
  () => files.value,
  (list) => {
    if (!list.length) return;
    if (!activeFileId.value || !list.some((f) => f.id === activeFileId.value)) {
      activeFileId.value = list[0]?.id;
    }
  },
  { immediate: true },
);
async function loadData() {
  const id = String(route.params.id || '');
  if (!id) {
    router.replace('/tms/personTask');
    return;
  }
  loading.value = true;
  try {
    detail.value = await getPersonTaskInfo(id);
    if ((detail.value.learnedSeconds || 0) >= (detail.value.requiredSeconds || 0) && (detail.value.requiredSeconds || 0) > 0) {
      notifiedFull.value = true;
    }
  } catch (e: any) {
    createMessage.error(e?.message || '加载失败');
    router.replace('/tms/personTask');
  } finally {
    loading.value = false;
  }
}
function goBack() {
  stopTimer(true).finally(() => {
    router.push('/tms/personTask');
  });
}
function selectFile(file: PersonTaskFile) {
  activeFileId.value = file.id;
}
function toFullUrl(url?: string) {
  if (!url) return '';
  const apiIndex = url.indexOf('/api/');
  const path = /^https?:\/\//i.test(url) && apiIndex >= 0 ? url.slice(apiIndex) : url;
  // t=t 让文件服务直接输出内容,否则会 302,img/video 拿不到文件
  return getAuthMediaUrl(path, false);
}
function formatDuration(seconds: number) {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = seconds % 60;
  if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
  return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function startTimer() {
  if (learnTimer) return;
  learnTimer = setInterval(() => {
    sessionSeconds.value += 1;
    pendingSeconds += 1;
    if (!detail.value) return;
    detail.value.learnedSeconds = (detail.value.learnedSeconds || 0) + 1;
    const required = detail.value.requiredSeconds || 0;
    const learned = detail.value.learnedSeconds || 0;
    detail.value.learnStatus = required > 0 && learned >= required ? '已学满' : learned > 0 ? '学习中' : '未学习';
  }, 1000);
  flushTimer = setInterval(() => {
    flushLearn();
  }, 8000);
}
async function flushLearn() {
  if (!detail.value?.id || pendingSeconds <= 0) return;
  const seconds = pendingSeconds;
  pendingSeconds = 0;
  try {
    const result = await markPersonTaskLearned(detail.value.id, seconds);
    detail.value.learnedSeconds = result.learnedSeconds ?? detail.value.learnedSeconds;
    detail.value.requiredSeconds = result.requiredSeconds ?? detail.value.requiredSeconds;
    detail.value.learnStatus = result.learnStatus || detail.value.learnStatus;
    detail.value.bizStatus = result.bizStatus || detail.value.bizStatus;
    detail.value.canExam = result.canExam;
    detail.value.examTip = result.examTip;
    if (result.message && (!notifiedFull.value || result.autoCompleted)) {
      notifiedFull.value = true;
      if (result.autoCompleted) {
        createMessage.success(result.message);
      } else {
        createMessage.info(result.message);
      }
    }
  } catch {
    pendingSeconds += seconds;
  }
}
async function stopTimer(flush = false) {
  if (learnTimer) {
    clearInterval(learnTimer);
    learnTimer = null;
  }
  if (flushTimer) {
    clearInterval(flushTimer);
    flushTimer = null;
  }
  if (flush) {
    await flushLearn();
  }
}
async function handleSign() {
  if (!detail.value?.id || !detail.value.canSign || signing.value) return;
  signing.value = true;
  try {
    const result = await signPersonTask(detail.value.id);
    detail.value.signTime = result.signTime;
    detail.value.signed = result.signed ?? true;
    detail.value.canSign = result.canSign ?? false;
    detail.value.signTip = result.signTip || '已签到';
    detail.value.bizStatus = result.bizStatus || detail.value.bizStatus;
    detail.value.canExam = result.canExam;
    detail.value.examTip = result.examTip;
    createMessage.success(result.message || '签到成功');
  } catch (e: any) {
    createMessage.error(e?.message || '签到失败');
  } finally {
    signing.value = false;
  }
}
async function handleExam() {
  if (!detail.value?.paperId || !detail.value.canExam || acting.value) return;
  await stopTimer(true);
  acting.value = true;
  try {
    const paper = await startOnlineExam(detail.value.paperId);
    if (paper?.autoSubmitted) {
      createMessage.warning('考试时间已到,已自动交卷');
      await loadData();
      startTimer();
      return;
    }
    sessionStorage.setItem('tms_online_exam_paper', JSON.stringify(paper));
    sessionStorage.setItem('tms_online_exam_myPaperId', detail.value.paperId);
    router.push('/tms/onlineExam/exam');
  } catch (e: any) {
    const msg = e?.message || '进入考试失败';
    if (String(msg).includes('自动交卷')) {
      createMessage.warning(msg);
      await loadData();
    } else {
      createMessage.error(msg);
    }
    startTimer();
  } finally {
    acting.value = false;
  }
}
</script>
<template>
  <div class="jnpf-content-wrapper tms-learn-page" v-loading="loading">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-learn-wrap" v-if="detail">
        <div class="tms-page-header">
          <div>
            <div class="tms-page-header__title">{{ detail.subject || '学习' }}</div>
            <div class="tms-page-header__sub">
              本场 {{ formatDuration(sessionSeconds) }} · 累计 {{ progressText }} · {{ signStatusText }}
            </div>
          </div>
          <div class="tms-page-header__actions">
            <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
            <a-button
              v-if="showSignButton"
              type="primary"
              :ghost="Boolean(detail.canExam)"
              :loading="signing"
              @click="handleSign"
            >
              {{ TMS_BTN.sign }}
            </a-button>
            <a-button v-if="detail.canExam" type="primary" :loading="acting" @click="handleExam">
              {{ detail.passFlag === '0' ? TMS_BTN.retake : TMS_BTN.enterExam }}
            </a-button>
          </div>
        </div>
        <a-progress :percent="progressPercent" :show-info="true" class="learn-progress" />
        <a-alert
          v-if="detail.examTip && !detail.canExam"
          class="tms-page-tip"
          type="info"
          show-icon
          :message="detail.examTip"
        />
        <div class="learn-body">
          <div class="file-side">
            <div class="side-title">教材列表</div>
            <div
              v-for="file in files"
              :key="file.id"
              class="file-row"
              :class="{ active: file.id === activeFile?.id }"
              @click="selectFile(file)"
            >
              <div class="name">{{ file.name }}</div>
              <div class="ext">{{ (file.previewType || 'other').toUpperCase() }}</div>
            </div>
            <div v-if="!files.length" class="empty">暂无教材</div>
          </div>
          <div class="preview-side">
            <template v-if="activeFile && previewUrl">
              <img v-if="activeFile.previewType === 'image'" :src="previewUrl" class="preview-media" alt="" />
              <video
                v-else-if="activeFile.previewType === 'video'"
                :src="previewUrl"
                class="preview-media"
                controls
                controlslist="nodownload"
              />
              <audio
                v-else-if="activeFile.previewType === 'audio'"
                :src="previewUrl"
                class="preview-audio"
                controls
              />
              <iframe
                v-else-if="activeFile.previewType === 'pdf'"
                :src="previewUrl"
                class="preview-frame"
                title="pdf-preview"
              />
              <div v-else class="preview-fallback">
                <div class="mb-2">当前文件类型({{ activeFile.fileExt || activeFile.previewType }})暂不支持页内预览。</div>
              </div>
            </template>
            <div v-else class="preview-fallback">请选择左侧教材开始学习</div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-learn-page,
.tms-learn-wrap {
  height: 100%;
  min-height: 0;
}
.tms-learn-wrap {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: #fff;
  padding: 12px 16px 16px;
  box-sizing: border-box;
}
.learn-progress {
  margin-bottom: 12px;
}
.learn-body {
  flex: 1;
  min-height: 0;
  display: grid;
  grid-template-columns: 260px 1fr;
  gap: 12px;
}
.file-side {
  border: 1px solid #f0f0f0;
  border-radius: 6px;
  overflow: auto;
  padding: 8px;
}
.side-title {
  font-weight: 600;
  margin-bottom: 8px;
}
.file-row {
  padding: 8px 10px;
  border-radius: 6px;
  cursor: pointer;
}
.file-row:hover,
.file-row.active {
  background: #e6f4ff;
}
.file-row .name {
  line-height: 1.4;
  word-break: break-all;
}
.file-row .ext {
  margin-top: 2px;
  color: #8c8c8c;
  font-size: 12px;
}
.preview-side {
  border: 1px solid #f0f0f0;
  border-radius: 6px;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #fafafa;
  overflow: hidden;
}
.preview-media {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}
.preview-audio {
  width: 80%;
}
.preview-frame {
  width: 100%;
  height: 100%;
  border: 0;
  background: #fff;
}
.preview-fallback,
.empty {
  color: #8c8c8c;
  padding: 24px;
  text-align: center;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/personTask/constants.ts
New file
@@ -0,0 +1,39 @@
import { ref } from 'vue';
import { useBaseStore } from '#/store';
import { TMS_DIC, labelOfDic, loadTmsDic, type TmsDicOpt } from '#/views/x/tms/shared/dic';
export const PERSON_STATUS_OPTIONS = ref<TmsDicOpt[]>([]);
export const TRAIN_MODE_OPTIONS = ref<TmsDicOpt[]>([]);
export const EVAL_MODE_OPTIONS = ref<TmsDicOpt[]>([]);
export const CATEGORY_OPTIONS = ref<TmsDicOpt[]>([]);
export async function loadPersonTaskDics() {
  const baseStore = useBaseStore();
  const [status, trainMode, evalMode, category] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.personTaskStatus),
    loadTmsDic(baseStore, TMS_DIC.trainMode),
    loadTmsDic(baseStore, TMS_DIC.evalMode),
    loadTmsDic(baseStore, TMS_DIC.taskCategory),
  ]);
  PERSON_STATUS_OPTIONS.value = status;
  TRAIN_MODE_OPTIONS.value = trainMode;
  EVAL_MODE_OPTIONS.value = evalMode;
  CATEGORY_OPTIONS.value = category;
}
export function labelOfPersonStatus(v?: string) {
  return labelOfDic(PERSON_STATUS_OPTIONS.value, v);
}
export function labelOfTrainMode(v?: string) {
  return labelOfDic(TRAIN_MODE_OPTIONS.value, v);
}
export function labelOfEvalMode(v?: string) {
  return labelOfDic(EVAL_MODE_OPTIONS.value, v);
}
export function labelOfCategory(v?: string) {
  return labelOfDic(CATEGORY_OPTIONS.value, v);
}
apps/jnpf-web-apps-main/src/views/x/tms/personTask/index.vue
New file
@@ -0,0 +1,200 @@
<script lang="ts" setup>
import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
import type { PersonTaskItem } from './types';
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { getMyPersonTaskList } from '#/api/x/tms/personTask';
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import {
  PERSON_STATUS_OPTIONS,
  labelOfEvalMode,
  labelOfPersonStatus,
  labelOfTrainMode,
  loadPersonTaskDics,
} from './constants';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsPersonTask' });
const router = useRouter();
const { createMessage } = useMessage();
const columns: BasicColumn[] = [
  { title: '培训编号', dataIndex: 'taskNo', width: 140 },
  { title: '培训主题', dataIndex: 'subject', minWidth: 200 },
  {
    title: '培训方式',
    dataIndex: 'trainMode',
    width: 110,
    customRender: ({ record }) => labelOfTrainMode((record as PersonTaskItem).trainMode),
  },
  { title: '开始时间', dataIndex: 'startTime', width: 170 },
  { title: '结束时间', dataIndex: 'endTime', width: 170 },
  {
    title: '状态',
    dataIndex: 'bizStatus',
    width: 100,
    customRender: ({ record }) => labelOfPersonStatus((record as PersonTaskItem).bizStatus),
  },
  { title: '学习进度', dataIndex: 'learnStatus', width: 100 },
  {
    title: '是否可考',
    dataIndex: 'canExam',
    width: 100,
    customRender: ({ record }) => {
      const row = record as PersonTaskItem;
      if (row.canExam) return '是';
      return row.examTip ? '否' : '否';
    },
  },
  {
    title: '考核方式',
    dataIndex: 'evalMode',
    width: 110,
    customRender: ({ record }) => labelOfEvalMode((record as PersonTaskItem).evalMode),
  },
];
const [registerTable, { reload, getForm }] = useVxeTable({
  api: fetchList,
  columns,
  immediate: false,
  rowKey: 'id',
  useSearchForm: true,
  formConfig: {
    baseColProps: { span: 6 },
    compact: true,
    showAdvancedButton: true,
    alwaysShowLines: 1,
    autoAdvancedLine: 1,
    schemas: [
      {
        field: 'keyword',
        label: '关键词',
        component: 'Input',
        componentProps: { placeholder: '编号/主题', submitOnPressEnter: true },
      },
      {
        field: 'bizStatus',
        label: '状态',
        component: 'Select',
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: PERSON_STATUS_OPTIONS.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
    ],
  },
  actionColumn: {
    width: 160,
    title: '操作',
    dataIndex: 'action',
    fixed: 'right',
  },
});
onMounted(async () => {
  await loadPersonTaskDics();
  getForm()?.updateSchema?.({
    field: 'bizStatus',
    componentProps: {
      allowClear: true,
      placeholder: '请选择',
      options: PERSON_STATUS_OPTIONS.value,
      fieldNames: TMS_DIC_FIELD_NAMES,
    },
  });
  reload();
});
async function fetchList(params: Record<string, any>) {
  const page = await getMyPersonTaskList(params);
  return {
    data: {
      list: Array.isArray(page?.list) ? page.list : [],
      pagination: page?.pagination || { total: 0 },
    },
  };
}
function isClosedStatus(status?: string) {
  return status === 'cancelled' || status === 'expired';
}
function canEnterLearn(record: PersonTaskItem) {
  return !isClosedStatus(record.bizStatus);
}
function handleView(record: PersonTaskItem) {
  router.push(`/tms/personTask/detail/${record.id}`);
}
function handleLearn(record: PersonTaskItem) {
  if (!canEnterLearn(record)) {
    createMessage.warning(
      record.bizStatus === 'cancelled' ? '任务已取消,无法学习' : '任务已过期,无法学习',
    );
    return;
  }
  router.push(`/tms/personTask/learn/${record.id}`);
}
function handleExamHint(record: PersonTaskItem) {
  if (record.canExam) {
    router.push(`/tms/personTask/learn/${record.id}`);
    return;
  }
  createMessage.info(record.examTip || record.signTip || '当前暂不可考试');
}
function getTableActions(record: PersonTaskItem): ActionItem[] {
  const actions: ActionItem[] = [{ label: TMS_BTN.detail, onClick: handleView.bind(null, record) }];
  if (canEnterLearn(record)) {
    actions.push({ label: TMS_BTN.learn, onClick: handleLearn.bind(null, record) });
  }
  if (record.canExam) {
    actions.push({
      label: record.passFlag === '0' ? TMS_BTN.retake : TMS_BTN.exam,
      onClick: handleExamHint.bind(null, record),
    });
  } else if (canEnterLearn(record) && record.examTip) {
    actions.push({
      label: TMS_BTN.exam,
      disabled: true,
      tooltip: record.examTip,
    });
  }
  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="tms-page-header__title">个人培训任务</div>
              <div class="tms-page-header__sub">学习教材并完成考核;可考时操作列直接进入考试。</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/personTask/types.ts
New file
@@ -0,0 +1,69 @@
export interface PersonTaskFile {
  id?: string;
  name?: string;
  url?: string;
  fileExt?: string;
  previewType?: 'image' | 'video' | 'audio' | 'pdf' | 'office' | 'other' | string;
}
export interface PersonTaskItem {
  id: string;
  taskId?: string;
  taskNo?: string;
  subject?: string;
  category?: string;
  trainMode?: string;
  evalMode?: string;
  startTime?: string;
  endTime?: string;
  closeTime?: string;
  placeName?: string;
  keyPoints?: string;
  bizStatus?: string;
  signTime?: string;
  signed?: boolean;
  canSign?: boolean;
  signTip?: string;
  learnSeconds?: number;
  learnedSeconds?: number;
  requiredHours?: number;
  requiredSeconds?: number;
  learnStatus?: string;
  examScore?: number;
  passFlag?: string;
  paperId?: string;
  paperName?: string;
  canExam?: boolean;
  examTip?: string;
  files?: PersonTaskFile[];
}
export interface PersonTaskLearnResult {
  learnedSeconds?: number;
  requiredSeconds?: number;
  learnStatus?: string;
  bizStatus?: string;
  canExam?: boolean;
  examTip?: string;
  autoCompleted?: boolean;
  message?: string;
}
export interface PersonTaskSignResult {
  signTime?: string;
  bizStatus?: string;
  signed?: boolean;
  canSign?: boolean;
  signTip?: string;
  canExam?: boolean;
  examTip?: string;
  message?: string;
}
export interface PersonTaskPageQuery {
  keyword?: string;
  bizStatus?: string;
  currentPage?: number;
  pageSize?: number;
  [key: string]: any;
}
apps/jnpf-web-apps-main/src/views/x/tms/question/Detail.vue
New file
@@ -0,0 +1,254 @@
<script lang="ts" setup>
import type { QuestionEntity, QuestionOption } from './types';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import {
  Descriptions as ADescriptions,
  DescriptionsItem as ADescriptionsItem,
} from 'ant-design-vue';
import { getQuestionInfo } from '#/api/x/tms/question';
import {
  labelOfDifficulty,
  labelOfSource,
  labelOfStatus,
  labelOfType,
  loadQuestionDics,
} from './constants';
defineOptions({ name: 'TmsQuestionDetail' });
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const loading = ref(false);
const detail = ref<QuestionEntity | null>(null);
const answerText = computed(() => formatAnswer(detail.value));
onMounted(async () => {
  await loadQuestionDics();
  await loadData();
});
async function loadData() {
  const id = String(route.params.id || '');
  if (!id) {
    router.replace('/tms/question');
    return;
  }
  loading.value = true;
  try {
    detail.value = await getQuestionInfo(id);
  } catch (e: any) {
    createMessage.error(e?.message || '加载试题失败');
    router.replace('/tms/question');
  } finally {
    loading.value = false;
  }
}
function goBack() {
  router.push('/tms/question');
}
function goEdit() {
  if (!detail.value?.id) return;
  router.push(`/tms/question/edit/${detail.value.id}`);
}
function formatAnswer(row?: QuestionEntity | null) {
  if (!row) return '-';
  const opts = row.options || [];
  switch (row.questionType) {
    case 'single':
    case 'multi': {
      const correct = opts.filter((o) => o.isCorrect === '1');
      if (!correct.length) return '-';
      return correct.map((o) => `${o.optionLabel}. ${o.optionContent || ''}`).join(';');
    }
    case 'judge': {
      const correct = opts.find((o) => o.isCorrect === '1');
      return correct?.optionContent || correct?.optionLabel || '-';
    }
    case 'blank':
      return opts.map((o, i) => `填空${i + 1}:${o.optionContent || '-'}`).join(';') || '-';
    case 'essay':
      return opts[0]?.optionContent || '-';
    default:
      return '-';
  }
}
function optionClass(opt: QuestionOption) {
  return opt.isCorrect === '1' ? 'opt-correct' : '';
}
</script>
<template>
  <div class="jnpf-content-wrapper tms-question-detail-page">
    <div class="jnpf-content-wrapper-center tms-question-detail-center">
      <div class="jnpf-content-wrapper-content tms-question-detail-wrap">
        <a-spin :spinning="loading" class="detail-spin">
          <div v-if="detail" class="detail-inner">
            <div class="detail-header">
              <div>
                <div class="text-base font-medium">试题详情</div>
                <div class="mt-1 text-gray-400 text-sm">编号 {{ detail.questionNo || '-' }}</div>
              </div>
              <a-space>
                <a-button @click="goBack">返回</a-button>
                <a-button type="primary" @click="goEdit">编辑</a-button>
              </a-space>
            </div>
            <ADescriptions bordered :column="2" size="middle" class="detail-desc">
              <ADescriptionsItem label="试题编号">{{ detail.questionNo || '-' }}</ADescriptionsItem>
              <ADescriptionsItem label="试题类型">{{ labelOfType(detail.questionType) }}</ADescriptionsItem>
              <ADescriptionsItem label="所属题库">{{ detail.bankName || '-' }}</ADescriptionsItem>
              <ADescriptionsItem label="试题难度">{{ labelOfDifficulty(detail.difficulty) }}</ADescriptionsItem>
              <ADescriptionsItem label="试题来源">{{ labelOfSource(detail.sourceType) }}</ADescriptionsItem>
              <ADescriptionsItem label="试题状态">{{ labelOfStatus(detail.bizStatus) }}</ADescriptionsItem>
              <ADescriptionsItem label="管理员">{{ detail.adminUserName || '-' }}</ADescriptionsItem>
              <ADescriptionsItem label="创建时间">{{ detail.creatorTime || '-' }}</ADescriptionsItem>
              <ADescriptionsItem label="题干内容" :span="2">
                <div class="stem-html" v-html="detail.stem || '-'" />
              </ADescriptionsItem>
              <ADescriptionsItem
                v-if="detail.questionType === 'single' || detail.questionType === 'multi' || detail.questionType === 'judge'"
                label="选项"
                :span="2"
              >
                <div class="option-list">
                  <div
                    v-for="(opt, idx) in detail.options || []"
                    :key="opt.id || idx"
                    class="option-item"
                    :class="optionClass(opt)"
                  >
                    <span class="opt-label">{{ opt.optionLabel }}.</span>
                    <span>{{ opt.optionContent }}</span>
                    <span v-if="opt.isCorrect === '1'" class="opt-tag">正确答案</span>
                  </div>
                  <div v-if="!(detail.options && detail.options.length)" class="text-gray-400">-</div>
                </div>
              </ADescriptionsItem>
              <ADescriptionsItem label="参考答案" :span="2">
                <div class="pre-line">{{ answerText }}</div>
              </ADescriptionsItem>
              <ADescriptionsItem label="试题解析" :span="2">
                <div class="pre-line">{{ detail.analysis || '-' }}</div>
              </ADescriptionsItem>
            </ADescriptions>
          </div>
        </a-spin>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-question-detail-page {
  height: 100%;
  min-height: 0;
}
.tms-question-detail-center {
  height: 100% !important;
  min-height: 0 !important;
  display: flex;
  flex-direction: column;
}
.tms-question-detail-wrap {
  display: flex !important;
  flex-direction: column;
  flex: 1 1 0 !important;
  min-height: 0 !important;
  height: 100%;
  overflow: auto !important;
  background: #fff;
  padding: 0;
}
.detail-spin {
  display: block;
  min-height: 100%;
  width: 100%;
}
.detail-spin :deep(.ant-spin-container) {
  min-height: 100%;
  width: 100%;
}
.detail-inner {
  padding: 16px 20px 24px;
  box-sizing: border-box;
}
.detail-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid #f0f0f0;
}
.detail-desc {
  max-width: 1100px;
  margin-bottom: 16px;
}
.stem-html :deep(p) {
  margin-bottom: 0.5em;
}
.stem-html :deep(img) {
  max-width: 100%;
}
.pre-line {
  white-space: pre-wrap;
  word-break: break-word;
}
.option-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.option-item {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  line-height: 1.6;
}
.opt-label {
  flex-shrink: 0;
  font-weight: 500;
}
.opt-correct {
  color: #52c41a;
}
.opt-tag {
  flex-shrink: 0;
  margin-left: 4px;
  padding: 0 6px;
  font-size: 12px;
  color: #52c41a;
  background: #f6ffed;
  border: 1px solid #b7eb8f;
  border-radius: 2px;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/question/Form.vue
@@ -14,7 +14,9 @@
  QUESTION_TYPE_OPTIONS,
  SOURCE_OPTIONS,
  STATUS_OPTIONS,
  loadQuestionDics,
} from './constants';
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
import {
  createDefaultOptions,
  hydrateFormFromEntity,
@@ -50,8 +52,12 @@
  blankMixMode: false,
});
const statusTip = computed(() => STATUS_OPTIONS.find((x) => x.id === dataForm.bizStatus)?.tip);
const statusTipColor = computed(() => STATUS_OPTIONS.find((x) => x.id === dataForm.bizStatus)?.tipColor);
const statusTip = computed(
  () => STATUS_OPTIONS.value.find((x) => (x.enCode || x.id) === dataForm.bizStatus)?.tip,
);
const statusTipColor = computed(
  () => STATUS_OPTIONS.value.find((x) => (x.enCode || x.id) === dataForm.bizStatus)?.tipColor,
);
const rules = {
  questionType: [{ required: true, message: '请选择题型', trigger: 'change' }],
@@ -68,6 +74,7 @@
);
onMounted(async () => {
  await loadQuestionDics();
  banks.value = (await getQuestionBanks()) || [];
  if (isEdit.value) {
    await loadDetail(String(route.params.id));
@@ -170,6 +177,7 @@
                    <jnpf-select
                      v-model:value="dataForm.questionType"
                      :options="QUESTION_TYPE_OPTIONS"
                      :field-names="TMS_DIC_FIELD_NAMES"
                      :allow-clear="false"
                      placeholder="请选择题型"
                    />
@@ -190,6 +198,7 @@
                    <jnpf-select
                      v-model:value="dataForm.difficulty"
                      :options="DIFFICULTY_OPTIONS"
                      :field-names="TMS_DIC_FIELD_NAMES"
                      :allow-clear="false"
                    />
                  </a-form-item>
@@ -200,6 +209,7 @@
                      <jnpf-select
                        v-model:value="dataForm.bizStatus"
                        :options="STATUS_OPTIONS"
                        :field-names="TMS_DIC_FIELD_NAMES"
                        :allow-clear="false"
                        class="!w-[200px]"
                      />
@@ -216,6 +226,7 @@
                    <jnpf-select
                      v-model:value="dataForm.sourceType"
                      :options="SOURCE_OPTIONS"
                      :field-names="TMS_DIC_FIELD_NAMES"
                      :allow-clear="false"
                    />
                  </a-form-item>
apps/jnpf-web-apps-main/src/views/x/tms/question/constants.ts
@@ -1,45 +1,52 @@
import type { Difficulty, QuestionStatus, QuestionType, SourceType } from './types';
import { ref } from 'vue';
export const QUESTION_TYPE_OPTIONS: { id: QuestionType; fullName: string }[] = [
  { id: 'single', fullName: '单选题' },
  { id: 'multi', fullName: '多选题' },
  { id: 'judge', fullName: '判断题' },
  { id: 'blank', fullName: '填空题' },
  { id: 'essay', fullName: '问答题' },
];
import { useBaseStore } from '#/store';
import { TMS_DIC, labelOfDic, loadTmsDic, type TmsDicOpt } from '#/views/x/tms/shared/dic';
export const DIFFICULTY_OPTIONS: { id: Difficulty; fullName: string }[] = [
  { id: 'easy', fullName: '简单' },
  { id: 'normal', fullName: '一般' },
  { id: 'hard', fullName: '困难' },
];
type StatusOpt = TmsDicOpt & { tip?: string; tipColor?: string };
export const SOURCE_OPTIONS: { id: SourceType; fullName: string }[] = [
  { id: 'self', fullName: '自主命题' },
  { id: 'import', fullName: '导入' },
  { id: 'external', fullName: '外购' },
];
function withStatusTip(list: TmsDicOpt[]): StatusOpt[] {
  return list.map((x) => {
    const code = x.enCode || x.id;
    if (code === 'open') return { ...x, tip: '学生可以模拟', tipColor: 'green' };
    if (code === 'closed') return { ...x, tip: '学生不能模拟', tipColor: 'red' };
    return x;
  });
}
export const STATUS_OPTIONS: { id: QuestionStatus; fullName: string; tip?: string; tipColor?: string }[] = [
  { id: 'open', fullName: '开放', tip: '学生可以模拟', tipColor: 'green' },
  { id: 'closed', fullName: '不开放', tip: '学生不能模拟', tipColor: 'red' },
  { id: 'invalid', fullName: '废弃' },
];
export const QUESTION_TYPE_OPTIONS = ref<TmsDicOpt[]>([]);
export const DIFFICULTY_OPTIONS = ref<TmsDicOpt[]>([]);
export const SOURCE_OPTIONS = ref<TmsDicOpt[]>([]);
export const STATUS_OPTIONS = ref<StatusOpt[]>([]);
export async function loadQuestionDics() {
  const baseStore = useBaseStore();
  const [questionType, difficulty, source, status] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.questionType),
    loadTmsDic(baseStore, TMS_DIC.difficulty),
    loadTmsDic(baseStore, TMS_DIC.questionSource),
    loadTmsDic(baseStore, TMS_DIC.openClosedInvalid),
  ]);
  QUESTION_TYPE_OPTIONS.value = questionType;
  DIFFICULTY_OPTIONS.value = difficulty;
  SOURCE_OPTIONS.value = source;
  STATUS_OPTIONS.value = withStatusTip(status);
}
export const OPTION_LABELS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
export function labelOfType(type?: string) {
  return QUESTION_TYPE_OPTIONS.find((x) => x.id === type)?.fullName ?? type ?? '-';
  return labelOfDic(QUESTION_TYPE_OPTIONS.value, type);
}
export function labelOfStatus(status?: string) {
  return STATUS_OPTIONS.find((x) => x.id === status)?.fullName ?? status ?? '-';
  return labelOfDic(STATUS_OPTIONS.value, status);
}
export function labelOfDifficulty(v?: string) {
  return DIFFICULTY_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-';
  return labelOfDic(DIFFICULTY_OPTIONS.value, v);
}
export function labelOfSource(v?: string) {
  return SOURCE_OPTIONS.find((x) => x.id === v)?.fullName ?? v ?? '-';
  return labelOfDic(SOURCE_OPTIONS.value, v);
}
apps/jnpf-web-apps-main/src/views/x/tms/question/index.vue
@@ -11,14 +11,16 @@
import { Modal } from 'ant-design-vue';
import { deleteQuestion, getQuestionBanks, getQuestionList } from '#/api/x/tms/question';
import { getQuestionBanks, getQuestionList, invalidateQuestion } from '#/api/x/tms/question';
import {
  QUESTION_TYPE_OPTIONS,
  STATUS_OPTIONS,
  labelOfStatus,
  labelOfType,
  loadQuestionDics,
} from './constants';
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
defineOptions({ name: 'TmsQuestionList' });
@@ -53,17 +55,18 @@
  },
];
const [registerTable, { reload, getForm }] = useVxeTable({
const [registerTable, { reload, getForm, getSelectRows }] = useVxeTable({
  api: fetchList,
  columns,
  immediate: true,
  immediate: false,
  rowKey: 'id',
  useSearchForm: true,
  formConfig: {
    baseColProps: { span: 6 },
    compact: true,
    showAdvancedButton: true,
    autoAdvancedLine: 1,
    alwaysShowLines: 1,
    autoAdvancedLine: 4,
    schemas: [
      {
        field: 'bankId',
@@ -84,7 +87,8 @@
          allowClear: true,
          showSearch: true,
          placeholder: '请选择题型',
          options: QUESTION_TYPE_OPTIONS,
          options: QUESTION_TYPE_OPTIONS.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
      {
@@ -95,7 +99,8 @@
          allowClear: true,
          showSearch: true,
          placeholder: '请选择状态',
          options: STATUS_OPTIONS.filter((x) => x.id !== 'invalid'),
          options: STATUS_OPTIONS.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
      {
@@ -114,8 +119,9 @@
      },
    ],
  },
  rowSelection: { type: 'checkbox' },
  actionColumn: {
    width: 100,
    width: 180,
    title: '操作',
    dataIndex: 'action',
    fixed: 'right',
@@ -123,20 +129,55 @@
});
onMounted(async () => {
  banks.value = (await getQuestionBanks()) || [];
  getForm()?.updateSchema?.({
    field: 'bankId',
    componentProps: {
      allowClear: true,
      showSearch: true,
      placeholder: '请选择题库',
      options: banks.value,
  await loadQuestionDics();
  try {
    const list = await getQuestionBanks();
    banks.value = Array.isArray(list) ? list : [];
  } catch {
    banks.value = [];
  }
  getForm()?.updateSchema?.([
    {
      field: 'bankId',
      componentProps: {
        allowClear: true,
        showSearch: true,
        placeholder: '请选择题库',
        options: banks.value,
      },
    },
  });
    {
      field: 'questionType',
      componentProps: {
        allowClear: true,
        showSearch: true,
        placeholder: '请选择题型',
        options: QUESTION_TYPE_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'bizStatus',
      componentProps: {
        allowClear: true,
        showSearch: true,
        placeholder: '请选择状态',
        options: STATUS_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
  ]);
  reload();
});
async function fetchList(params: Record<string, any>) {
  return { data: await getQuestionList(params) };
  const page = await getQuestionList(params);
  return {
    data: {
      list: Array.isArray(page?.list) ? page.list : [],
      pagination: page?.pagination || { total: 0 },
    },
  };
}
function stripHtml(html?: string) {
@@ -155,31 +196,55 @@
  router.push('/tms/question/create');
}
function handleEdit(record: QuestionEntity) {
  router.push(`/tms/question/edit/${record.id}`);
}
function handleDelete(record: QuestionEntity) {
function handleManage() {
  const rows = (getSelectRows?.() || []) as QuestionEntity[];
  if (!rows.length) {
    createMessage.warning('请先勾选要管理的试题');
    return;
  }
  if (rows.length === 1) {
    handleEdit(rows[0]!);
    return;
  }
  Modal.confirm({
    title: '确认删除',
    content: `确定删除试题「${stripHtml(record.stem) || record.questionNo}」吗?`,
    title: '批量废弃',
    content: `已勾选 ${rows.length} 道试题,确定全部废弃吗?`,
    onOk: async () => {
      await deleteQuestion(record.id!);
      createMessage.success('删除成功');
      for (const row of rows) {
        if (row.id) await invalidateQuestion(row.id);
      }
      createMessage.success('废弃成功');
      reload();
    },
  });
}
function handleDetail(record: QuestionEntity) {
  router.push(`/tms/question/detail/${record.id}`);
}
function handleEdit(record: QuestionEntity) {
  router.push(`/tms/question/edit/${record.id}`);
}
async function handleInvalidate(record: QuestionEntity) {
  await invalidateQuestion(record.id!);
  createMessage.success('废弃成功');
  reload();
}
function getTableActions(record: QuestionEntity): ActionItem[] {
  return [
    { icon: 'icon-ym icon-ym-btn-edit', tooltip: '编辑', onClick: handleEdit.bind(null, record) },
    { label: '编辑', onClick: handleEdit.bind(null, record) },
    {
      icon: 'icon-ym icon-ym-btn-clearn',
      tooltip: '删除',
      label: '废弃',
      color: 'error',
      onClick: handleDelete.bind(null, record),
      modelConfirm: {
        content: `确定废弃试题「${stripHtml(record.stem) || record.questionNo}」吗?废弃后将从列表移除。`,
        onOk: handleInvalidate.bind(null, record),
      },
    },
    { label: '详情', onClick: handleDetail.bind(null, record) },
  ];
}
</script>
@@ -192,7 +257,7 @@
          <template #tableTitle>
            <a-space>
              <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleCreate">创建试题</a-button>
              <a-button disabled>管理试题</a-button>
              <a-button @click="handleManage">管理试题</a-button>
            </a-space>
          </template>
          <template #bizStatus="{ record }">
apps/jnpf-web-apps-main/src/views/x/tms/question/mock.ts
File was deleted
apps/jnpf-web-apps-main/src/views/x/tms/record/Detail.vue
@@ -12,6 +12,17 @@
} from 'ant-design-vue';
import { getTmsRecordDetail } from '#/api/x/tms/record';
import { getAuthMediaUrl } from '#/utils/jnpf';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import {
  labelOfCategory,
  labelOfEvalMode,
  labelOfTrainMode,
  loadRecordDics,
} from './constants';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsRecordDetail' });
@@ -37,8 +48,9 @@
  },
];
onMounted(() => {
  loadData();
onMounted(async () => {
  await loadRecordDics();
  await loadData();
});
async function loadData() {
@@ -83,10 +95,12 @@
function openAttachment(url?: string) {
  if (!url || url === '#') {
    createMessage.info('附件预览联调后生效');
    createMessage.info('暂无可预览附件');
    return;
  }
  window.open(url, '_blank');
  const apiIndex = url.indexOf('/api/');
  const path = /^https?:\/\//i.test(url) && apiIndex >= 0 ? url.slice(apiIndex) : url;
  window.open(getAuthMediaUrl(path, false), '_blank');
}
</script>
@@ -96,9 +110,11 @@
      <div class="jnpf-content-wrapper-content tms-record-detail-wrap">
        <a-spin :spinning="loading" class="detail-spin">
          <div v-if="detail" class="detail-inner">
            <div class="detail-header">
              <div class="text-base font-medium">培训记录</div>
              <a-button type="primary" @click="goBack">返回</a-button>
            <div class="tms-page-header">
              <div class="tms-page-header__title">培训记录</div>
              <div class="tms-page-header__actions">
                <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
              </div>
            </div>
            <ADescriptions bordered :column="2" size="middle" class="detail-desc">
@@ -113,10 +129,10 @@
              <ADescriptionsItem label="培训开始时间">{{ detail.startTime }}</ADescriptionsItem>
              <ADescriptionsItem label="培训结束时间">{{ detail.endTime || '-' }}</ADescriptionsItem>
              <ADescriptionsItem label="培训地点" :span="2">{{ detail.placeName || '-' }}</ADescriptionsItem>
              <ADescriptionsItem label="培训分类">{{ detail.category }}</ADescriptionsItem>
              <ADescriptionsItem label="培训方式">{{ detail.trainMode || detail.trainType || '-' }}</ADescriptionsItem>
              <ADescriptionsItem label="培训分类">{{ labelOfCategory(detail.category) }}</ADescriptionsItem>
              <ADescriptionsItem label="培训方式">{{ labelOfTrainMode(detail.trainMode || detail.trainType) }}</ADescriptionsItem>
              <ADescriptionsItem label="培训师">{{ detail.trainerName }}</ADescriptionsItem>
              <ADescriptionsItem label="培训考核方式">{{ detail.evalMode }}</ADescriptionsItem>
              <ADescriptionsItem label="培训考核方式">{{ labelOfEvalMode(detail.evalMode) }}</ADescriptionsItem>
              <ADescriptionsItem label="培训对象" :span="2">{{ detail.trainees || '-' }}</ADescriptionsItem>
              <ADescriptionsItem label="附件" :span="2">
                <div v-if="detail.attachments?.length" class="attach-list">
@@ -189,15 +205,6 @@
.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 {
apps/jnpf-web-apps-main/src/views/x/tms/record/Edit.vue
@@ -7,6 +7,7 @@
import { useMessage } from '@jnpf/hooks';
import { getTmsRecordDetail, updateTmsRecord } from '#/api/x/tms/record';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
defineOptions({ name: 'TmsRecordEdit' });
@@ -91,8 +92,8 @@
                <div class="mt-1 text-gray-400 text-sm">编号:{{ detail.recordNo }}</div>
              </div>
              <a-space>
                <a-button @click="goBack">取消</a-button>
                <a-button type="primary" :loading="submitting" @click="handleSubmit">保存</a-button>
                <a-button @click="goBack">{{ TMS_BTN.cancel }}</a-button>
                <a-button type="primary" :loading="submitting" @click="handleSubmit">{{ TMS_BTN.save }}</a-button>
              </a-space>
            </div>
apps/jnpf-web-apps-main/src/views/x/tms/record/Sign.vue
@@ -1,14 +1,16 @@
<script lang="ts" setup>
import type { TmsRecordDetail } from './types';
import { onMounted, reactive, ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { Table as ATable } from 'ant-design-vue';
import dayjs from 'dayjs';
import { getTmsRecordDetail } from '#/api/x/tms/record';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsRecordSign' });
@@ -17,14 +19,7 @@
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 },
@@ -59,32 +54,6 @@
  }
}
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');
}
@@ -96,38 +65,23 @@
      <div class="jnpf-content-wrapper-content tms-record-wrap">
        <a-spin :spinning="loading" class="record-spin">
          <div v-if="detail" class="record-inner">
            <div class="record-header">
            <div class="tms-page-header">
              <div>
                <div class="text-base font-medium">培训签到</div>
                <div class="mt-1 text-gray-400 text-sm">
                  {{ detail.recordNo }} · {{ detail.subject }}
                </div>
                <div class="tms-page-header__title">培训签到</div>
                <div class="tms-page-header__sub">{{ detail.recordNo }} · {{ detail.subject }}</div>
              </div>
              <a-button @click="goBack">返回</a-button>
              <div class="tms-page-header__actions">
                <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
              </div>
            </div>
            <a-form layout="inline" class="sign-form mb-4">
              <a-form-item label="姓名">
                <a-input v-model:value="form.userName" placeholder="签到人姓名" class="!w-[140px]" allow-clear />
              </a-form-item>
              <a-form-item label="部门">
                <a-input v-model:value="form.deptName" placeholder="部门(可选)" class="!w-[160px]" allow-clear />
              </a-form-item>
              <a-form-item label="方式">
                <a-select
                  v-model:value="form.signMode"
                  class="!w-[130px]"
                  :options="[
                    { label: '在线签到', value: '在线签到' },
                    { label: 'APP扫码', value: 'APP扫码' },
                    { label: '微信扫码', value: '微信扫码' },
                  ]"
                />
              </a-form-item>
              <a-form-item>
                <a-button type="primary" :loading="submitting" @click="handleSubmit">确认签到</a-button>
              </a-form-item>
            </a-form>
            <a-alert
              class="tms-page-tip"
              type="info"
              show-icon
              message="管理端补签/扫码签到即将接入"
              description="当前请学员在「个人培训任务」中完成签到。下方为已同步的签到名单(只读)。"
            />
            <div class="section-title">已签到人员</div>
            <ATable
@@ -173,24 +127,11 @@
  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
@@ -1,11 +1,33 @@
import type { ArchiveStatus, RecordListMode } from './types';
export const ARCHIVE_STATUS_OPTIONS: { id: ArchiveStatus; fullName: string }[] = [
  { id: 'pending', fullName: '待归档' },
  { id: 'ready', fullName: '可归档' },
  { id: 'archived', fullName: '已归档' },
  { id: 'invalid', fullName: '无效' },
];
import { ref } from 'vue';
import { useBaseStore } from '#/store';
import {
  TMS_DIC,
  labelOfDic,
  loadTmsDic,
  type TmsDicOpt,
} from '#/views/x/tms/shared/dic';
export const ARCHIVE_STATUS_OPTIONS = ref<TmsDicOpt[]>([]);
export const TASK_CATEGORY_OPTIONS = ref<TmsDicOpt[]>([]);
export const TRAIN_MODE_OPTIONS = ref<TmsDicOpt[]>([]);
export const EVAL_MODE_OPTIONS = ref<TmsDicOpt[]>([]);
export async function loadRecordDics() {
  const baseStore = useBaseStore();
  const [archive, category, trainMode, evalMode] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.archiveStatus),
    loadTmsDic(baseStore, TMS_DIC.taskCategory),
    loadTmsDic(baseStore, TMS_DIC.trainMode),
    loadTmsDic(baseStore, TMS_DIC.evalMode),
  ]);
  ARCHIVE_STATUS_OPTIONS.value = archive;
  TASK_CATEGORY_OPTIONS.value = category;
  TRAIN_MODE_OPTIONS.value = trainMode;
  EVAL_MODE_OPTIONS.value = evalMode;
}
export const LIST_MODE_LABEL: Record<RecordListMode, string> = {
  main: '培训记录',
@@ -13,18 +35,29 @@
  archived: '已归档列表',
};
/** 各列表页路由 */
export const RECORD_LIST_PATH: Record<RecordListMode, string> = {
  main: '/tms/record',
  ready: '/tms/record/ready',
  archived: '/tms/record/archived',
};
export function labelOfArchiveStatus(status?: ArchiveStatus) {
  return ARCHIVE_STATUS_OPTIONS.find((x) => x.id === status)?.fullName || status || '-';
export function labelOfArchiveStatus(status?: ArchiveStatus | string) {
  return labelOfDic(ARCHIVE_STATUS_OPTIONS.value, status);
}
export function colorOfArchiveStatus(status?: ArchiveStatus) {
export function labelOfCategory(v?: string) {
  return labelOfDic(TASK_CATEGORY_OPTIONS.value, v);
}
export function labelOfTrainMode(v?: string) {
  return labelOfDic(TRAIN_MODE_OPTIONS.value, v);
}
export function labelOfEvalMode(v?: string) {
  return labelOfDic(EVAL_MODE_OPTIONS.value, v);
}
export function colorOfArchiveStatus(status?: ArchiveStatus | string) {
  if (status === 'ready') return '#1677ff';
  if (status === 'archived') return '#52c41a';
  if (status === 'invalid') return '#ff4d4f';
apps/jnpf-web-apps-main/src/views/x/tms/record/index.vue
@@ -3,7 +3,7 @@
import type { RecordListMode, TmsRecordListItem } from './types';
import { computed, watch } from 'vue';
import { computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
@@ -12,8 +12,11 @@
import { Modal } from 'ant-design-vue';
import { archiveTmsRecords, deleteTmsRecords, getTmsRecordList } from '#/api/x/tms/record';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import { LIST_MODE_LABEL, RECORD_LIST_PATH } from './constants';
import { LIST_MODE_LABEL, RECORD_LIST_PATH, labelOfCategory, labelOfEvalMode, labelOfTrainMode, loadRecordDics } from './constants';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsRecord' });
@@ -35,7 +38,12 @@
const columns: BasicColumn[] = [
  { title: '编号', dataIndex: 'recordNo', width: 140 },
  { title: '培训分类', dataIndex: 'category', width: 120 },
  {
    title: '培训分类',
    dataIndex: 'category',
    width: 120,
    customRender: ({ record }) => labelOfCategory((record as TmsRecordListItem).category),
  },
  {
    title: '培训主题',
    dataIndex: 'subject',
@@ -43,9 +51,22 @@
    slots: { default: 'subject' },
  },
  { title: '培训师', dataIndex: 'trainerName', width: 100 },
  { title: '培训类型', dataIndex: 'trainType', width: 110 },
  {
    title: '培训类型',
    dataIndex: 'trainType',
    width: 110,
    customRender: ({ record }) => {
      const row = record as TmsRecordListItem;
      return labelOfTrainMode(row.trainMode || row.trainType);
    },
  },
  { title: '培训开始时间', dataIndex: 'startTime', width: 160 },
  { title: '考核方式', dataIndex: 'evalMode', width: 110 },
  {
    title: '考核方式',
    dataIndex: 'evalMode',
    width: 110,
    customRender: ({ record }) => labelOfEvalMode((record as TmsRecordListItem).evalMode),
  },
];
const [registerTable, { reload, getSelectRows }] = useVxeTable({
@@ -59,6 +80,7 @@
    baseColProps: { span: 6 },
    compact: true,
    showAdvancedButton: true,
    alwaysShowLines: 1,
    autoAdvancedLine: 1,
    schemas: [
      {
@@ -100,12 +122,20 @@
  () => reload(),
);
onMounted(() => {
  loadRecordDics();
});
async function fetchList(params: Record<string, any>) {
  const page = await getTmsRecordList({
    ...params,
    listMode: listMode.value,
  });
  return {
    data: await getTmsRecordList({
      ...params,
      listMode: listMode.value,
    }),
    data: {
      list: Array.isArray(page?.list) ? page.list : [],
      pagination: page?.pagination || { total: 0 },
    },
  };
}
@@ -139,19 +169,13 @@
  router.push(RECORD_LIST_PATH[mode]);
}
function handleQrcode() {
  const row = requireOneRow();
  if (!row) return;
  createMessage.success(`已生成「${row.recordNo}」签到二维码(接口联调后生效)`);
}
function handleAdd() {
  createMessage.info('新增培训记录(联调任务发布后由系统自动生成,手工新增待后端接口)');
}
function handleEdit() {
  const row = requireOneRow();
  if (!row) return;
  if (row.archiveStatus === 'archived' || isArchivedView.value) {
    createMessage.warning('已归档记录不可修改');
    return;
  }
  router.push(`/tms/record/edit/${row.id}`);
}
@@ -164,10 +188,8 @@
  });
}
function handleSign() {
  const row = requireOneRow();
  if (!row) return;
  router.push(`/tms/record/sign/${row.id}`);
function handleAdminSignUnavailable() {
  createMessage.info('管理端补签/扫码签到即将接入,请学员在「个人培训任务」中完成签到');
}
function handleDelete() {
@@ -207,14 +229,7 @@
}
function getTableActions(record: TmsRecordListItem): ActionItem[] {
  const actions: ActionItem[] = [{ label: '查看', onClick: handleView.bind(null, record) }];
  if (isMainView.value) {
    actions.push({
      label: '签到',
      onClick: () => router.push(`/tms/record/sign/${record.id}`),
    });
  }
  return actions;
  return [{ label: TMS_BTN.detail, onClick: handleView.bind(null, record) }];
}
</script>
@@ -226,33 +241,36 @@
          <template #tableTitle>
            <!-- 1. 培训记录 -->
            <a-space v-if="isMainView" wrap>
              <a-button pre-icon="icon-ym icon-ym-generator-qrcode" @click="handleQrcode">生成二维码</a-button>
              <a-button pre-icon="icon-ym icon-ym-btn-edit" @click="handleEdit">修改</a-button>
              <a-button pre-icon="icon-ym icon-ym-btn-preview" @click="handleView()">查看</a-button>
              <a-button pre-icon="icon-ym icon-ym-extend-form" @click="handleSign">签到</a-button>
              <a-button pre-icon="icon-ym icon-ym-btn-edit" @click="handleEdit">{{ TMS_BTN.edit }}</a-button>
              <a-button pre-icon="icon-ym icon-ym-btn-preview" @click="handleView()">{{ TMS_BTN.detail }}</a-button>
              <a-button pre-icon="icon-ym icon-ym-extend-form" @click="handleAdminSignUnavailable">
                {{ TMS_BTN.sign }}
              </a-button>
              <a-button pre-icon="icon-ym icon-ym-file-text" @click="goList('ready')">可归档列表</a-button>
              <a-button pre-icon="icon-ym icon-ym-extend-folder" @click="goList('archived')">已归档列表</a-button>
              <span class="text-gray-400 text-sm">当前:{{ listModeTitle }}</span>
              <span class="tms-toolbar-hint">当前:{{ listModeTitle }} · 记录由任务发布自动生成</span>
            </a-space>
            <!-- 2. 可归档列表 -->
            <a-space v-else-if="isReadyView" wrap>
              <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleAdd">新增</a-button>
              <a-button pre-icon="icon-ym icon-ym-btn-edit" @click="handleEdit">修改</a-button>
              <a-button danger pre-icon="icon-ym icon-ym-btn-clearn" @click="handleDelete">删除</a-button>
              <a-button pre-icon="icon-ym icon-ym-extend-folder" @click="handleArchive">归档</a-button>
              <a-button @click="goList('main')">返回</a-button>
              <a-button pre-icon="icon-ym icon-ym-btn-edit" @click="handleEdit">{{ TMS_BTN.edit }}</a-button>
              <a-button danger pre-icon="icon-ym icon-ym-btn-clearn" @click="handleDelete">
                {{ TMS_BTN.delete }}
              </a-button>
              <a-button type="primary" pre-icon="icon-ym icon-ym-extend-folder" @click="handleArchive">
                {{ TMS_BTN.archive }}
              </a-button>
              <a-button @click="goList('main')">{{ TMS_BTN.back }}</a-button>
              <a-button pre-icon="icon-ym icon-ym-file-text" @click="goList('archived')">已归档列表</a-button>
              <span class="text-gray-400 text-sm">当前:{{ listModeTitle }}</span>
              <span class="tms-toolbar-hint">当前:{{ listModeTitle }}</span>
            </a-space>
            <!-- 3. 已归档列表 -->
            <a-space v-else wrap>
              <a-button @click="handleView()">查看</a-button>
              <a-button @click="handleEdit">修改</a-button>
              <a-button @click="goList('main')">返回</a-button>
              <a-button type="primary" @click="goList('ready')">可归档列表</a-button>
              <span class="text-gray-400 text-sm">当前:{{ listModeTitle }}</span>
              <a-button @click="handleView()">{{ TMS_BTN.detail }}</a-button>
              <a-button @click="goList('main')">{{ TMS_BTN.back }}</a-button>
              <a-button type="primary" ghost @click="goList('ready')">可归档列表</a-button>
              <span class="tms-toolbar-hint">当前:{{ listModeTitle }} · 已归档不可修改</span>
            </a-space>
          </template>
          <template #subject="{ record }">
apps/jnpf-web-apps-main/src/views/x/tms/record/types.ts
@@ -77,5 +77,5 @@
  attachments?: TmsRecordAttachment[];
  /** 参加人员名单 */
  participants?: TmsRecordParticipant[];
  signList: TmsRecordSignItem[];
  signList?: TmsRecordSignItem[];
}
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/Detail.vue
New file
@@ -0,0 +1,214 @@
<script lang="ts" setup>
import type { SelfTestPaper, SelfTestQuestionItem } from './types';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { useTabbarStore } from '@vben/stores';
import { getSelfTestInfo } from '#/api/x/tms/selfTest';
import { labelOfType } from '#/views/x/tms/question/constants';
defineOptions({ name: 'TmsSelfTestDetail' });
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const tabbarStore = useTabbarStore();
const loading = ref(false);
const detail = ref<SelfTestPaper | null>(null);
/** all | wrong */
const filterMode = ref<'all' | 'wrong'>('all');
const wrongCount = computed(
  () => detail.value?.questions?.filter((q) => q.isRight !== '1').length || 0,
);
const displayQuestions = computed(() => {
  const list = detail.value?.questions || [];
  if (filterMode.value === 'wrong') {
    return list.filter((q) => q.isRight !== '1');
  }
  return list;
});
function stripHtml(html?: string) {
  if (!html) return '';
  return html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim();
}
function formatAnswer(ans?: string) {
  if (!ans) return '未作答';
  return ans.split(',').filter(Boolean).join('、');
}
function optionClass(q: SelfTestQuestionItem, label: string) {
  const correctSet = new Set((q.correctAnswer || '').split(',').filter(Boolean));
  const userSet = new Set((q.userAnswer || '').split(',').filter(Boolean));
  const isCorrectOpt = correctSet.has(label);
  const isUserOpt = userSet.has(label);
  if (isCorrectOpt) return 'opt-correct';
  if (isUserOpt && !isCorrectOpt) return 'opt-wrong';
  return '';
}
async function loadDetail() {
  const id = String(route.params.id || '');
  if (!id) {
    router.replace('/tms/selfTest/records');
    return;
  }
  loading.value = true;
  try {
    detail.value = await getSelfTestInfo(id);
  } catch (e: any) {
    createMessage.error(e?.message || '加载检测详情失败');
    router.replace('/tms/selfTest/records');
  } finally {
    loading.value = false;
  }
}
onMounted(() => {
  tabbarStore.renderRouteView = true;
  loadDetail();
});
function goBack() {
  tabbarStore.renderRouteView = true;
  router.push('/tms/selfTest/records');
}
</script>
<template>
  <div class="jnpf-content-wrapper">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-self-test-detail" v-loading="loading">
        <div class="page-header" v-if="detail">
          <div>
            <div class="text-base font-medium">检测详情 · {{ detail.bankName }}</div>
            <div class="mt-1 text-gray-400 text-sm">
              得分:{{ detail.correctCount ?? 0 }} / {{ detail.totalCount ?? detail.questions?.length ?? 0 }}
              <span v-if="detail.scoreRate != null" class="ml-2">正确率 {{ detail.scoreRate }}%</span>
              <span v-if="detail.submitTime" class="ml-3">交卷时间:{{ detail.submitTime }}</span>
            </div>
          </div>
          <a-space>
            <a-radio-group v-model:value="filterMode" button-style="solid" size="small">
              <a-radio-button value="all">全部题目</a-radio-button>
              <a-radio-button value="wrong">仅错题({{ wrongCount }})</a-radio-button>
            </a-radio-group>
            <a-button @click="goBack">返回记录</a-button>
          </a-space>
        </div>
        <a-empty v-if="!loading && detail && !displayQuestions.length" description="暂无题目" />
        <div v-for="(q, idx) in displayQuestions" :key="`${q.id}_${idx}`" class="question-card">
          <div class="q-title">
            <span class="q-index">{{ q.sortNo || idx + 1 }}.</span>
            <span class="q-type">{{ labelOfType(q.questionType) }}</span>
            <a-tag :color="q.isRight === '1' ? 'success' : 'error'" class="!ml-2">
              {{ q.isRight === '1' ? '正确' : '错误' }}
            </a-tag>
          </div>
          <div class="q-stem">{{ stripHtml(q.stem) }}</div>
          <div class="q-options">
            <div
              v-for="opt in q.options"
              :key="opt.optionLabel"
              class="q-option"
              :class="optionClass(q, opt.optionLabel)"
            >
              {{ opt.optionLabel }}. {{ opt.optionContent }}
            </div>
          </div>
          <div class="q-answer">
            <span>你的答案:{{ formatAnswer(q.userAnswer) }}</span>
            <span class="ml-4">正确答案:{{ formatAnswer(q.correctAnswer) }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-self-test-detail {
  background: #fff;
  padding: 20px 24px;
  height: 100%;
  overflow: auto;
}
.page-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 16px;
  margin-bottom: 20px;
  padding-bottom: 12px;
  border-bottom: 1px solid #f0f0f0;
}
.question-card {
  padding: 16px 0;
  border-bottom: 1px solid #f5f5f5;
}
.q-title {
  display: flex;
  align-items: center;
  margin-bottom: 8px;
}
.q-index {
  font-weight: 600;
  margin-right: 6px;
}
.q-type {
  color: rgba(0, 0, 0, 0.45);
  font-size: 13px;
}
.q-stem {
  font-size: 15px;
  line-height: 1.7;
  margin-bottom: 12px;
  color: rgba(0, 0, 0, 0.88);
}
.q-options {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 10px;
}
.q-option {
  padding: 6px 10px;
  border-radius: 4px;
  background: #fafafa;
  color: rgba(0, 0, 0, 0.75);
}
.q-option.opt-correct {
  background: #f6ffed;
  color: #389e0d;
  border: 1px solid #b7eb8f;
}
.q-option.opt-wrong {
  background: #fff2f0;
  color: #cf1322;
  border: 1px solid #ffccc7;
}
.q-answer {
  font-size: 13px;
  color: rgba(0, 0, 0, 0.55);
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/exam.vue
@@ -1,45 +1,138 @@
<script lang="ts" setup>
import type { SelfTestPaper, SelfTestQuestionItem } from './types';
import type { SelfTestPaper, SelfTestQuestionItem, SelfTestSubmitResult } from './types';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
  computed,
  nextTick,
  onDeactivated,
  onMounted,
  onUnmounted,
  reactive,
  ref,
  watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { useTabbarStore } from '@vben/stores';
import { AppstoreOutlined } from '@ant-design/icons-vue';
import { labelOfType } from '#/views/x/tms/question/constants';
import { saveSelfTest, submitSelfTest } from '#/api/x/tms/selfTest';
import { labelOfType, loadQuestionDics } from '#/views/x/tms/question/constants';
defineOptions({ name: 'TmsSelfTestExam' });
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const tabbarStore = useTabbarStore();
const paper = ref<SelfTestPaper | null>(null);
const currentIndex = ref(0);
/** questionId -> answer: single/judge 存 optionLabel;multi 存 label[] */
const answers = reactive<Record<string, string | string[]>>({});
const submitted = ref(false);
const submitting = ref(false);
const saving = ref(false);
const answersReady = ref(false);
const scoreText = ref('');
const resultModalOpen = ref(false);
const submitResult = ref<SelfTestSubmitResult | null>(null);
const resultFilter = ref<'all' | 'wrong'>('all');
const sheetOpen = ref(false);
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const current = computed(() => paper.value?.questions?.[currentIndex.value]);
const total = computed(() => paper.value?.questions?.length || 0);
onMounted(() => {
const wrongCount = computed(
  () => paper.value?.questions?.filter((q) => !isCorrect(q)).length || 0,
);
const rightCount = computed(() => submitResult.value?.correctCount ?? 0);
const resultQuestions = computed(() => {
  const list = paper.value?.questions || [];
  if (resultFilter.value === 'wrong') {
    return list.filter((q) => !isCorrect(q));
  }
  return list;
});
const answeredCount = computed(
  () => (paper.value?.questions || []).filter((q) => isAnswered(q)).length,
);
onMounted(async () => {
  // 若此前 refreshTab 中断导致全局内容区不渲染,这里强制恢复
  tabbarStore.renderRouteView = true;
  await loadQuestionDics();
  const raw = sessionStorage.getItem('tms_self_test_paper');
  if (!raw) {
    createMessage.warning('请先设置抽题条件');
    router.replace('/tms/selfTest');
    goBack();
    return;
  }
  try {
    paper.value = JSON.parse(raw);
    // 继续考试:恢复已保存的作答
    const ansRaw = sessionStorage.getItem('tms_self_test_answers');
    if (ansRaw) {
      try {
        const saved = JSON.parse(ansRaw) as Record<string, string | string[]>;
        Object.keys(saved || {}).forEach((qid) => {
          answers[qid] = saved[qid];
        });
      } catch {
        // ignore
      }
      sessionStorage.removeItem('tms_self_test_answers');
    }
  } catch {
    router.replace('/tms/selfTest');
    goBack();
    return;
  }
  answersReady.value = true;
});
onDeactivated(() => {
  resultModalOpen.value = false;
  sheetOpen.value = false;
});
onUnmounted(() => {
  if (saveTimer) {
    clearTimeout(saveTimer);
    saveTimer = null;
  }
  resultModalOpen.value = false;
  sheetOpen.value = false;
  // 再次确保离开本页后内容区可渲染
  tabbarStore.renderRouteView = true;
});
/** 作答变更后自动暂存,方便中途离开再继续 */
watch(
  answers,
  () => {
    if (!answersReady.value || submitted.value || !paper.value) return;
    if (saveTimer) clearTimeout(saveTimer);
    saveTimer = setTimeout(() => {
      void persistAnswers(false);
    }, 1500);
  },
  { deep: true },
);
function stripHtml(html?: string) {
  if (!html) return '';
  return html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim();
}
function isAnswered(q: SelfTestQuestionItem) {
  const val = answers[q.id];
  if (Array.isArray(val)) return val.length > 0;
  return String(val ?? '').trim().length > 0;
}
function goPrev() {
@@ -50,13 +143,111 @@
  if (currentIndex.value < total.value - 1) currentIndex.value += 1;
}
function goBack() {
  router.push('/tms/selfTest');
function goToQuestion(index: number) {
  if (index < 0 || index >= total.value) {
    sheetOpen.value = false;
    return;
  }
  currentIndex.value = index;
  sheetOpen.value = false;
}
function openSheet() {
  sheetOpen.value = true;
}
function getSettingsPath() {
  return String(route.meta.currentActiveMenu || '/tms/selfTest');
}
/**
 * 与在线考试一致:按 path 跳转,不调用 closeCurrentTab。
 * closeCurrentTab 内部按 route.name 跳转,动态菜单场景容易跳到空页并影响全局内容区。
 */
async function goBack() {
  if (!submitted.value && paper.value) {
    if (saveTimer) {
      clearTimeout(saveTimer);
      saveTimer = null;
    }
    await persistAnswers(false);
  }
  resultModalOpen.value = false;
  tabbarStore.renderRouteView = true;
  router.push(getSettingsPath());
}
async function persistAnswers(showTip: boolean) {
  if (!paper.value || submitted.value || saving.value) return;
  const payload = { ...answers };
  if (!Object.keys(payload).length) {
    if (showTip) createMessage.info('暂无作答可保存');
    return;
  }
  saving.value = true;
  try {
    await saveSelfTest(paper.value.paperId, { answers: payload });
    if (showTip) createMessage.success('已暂存,可稍后继续考试');
  } catch (e: any) {
    if (showTip) createMessage.error(e?.message || '暂存失败');
  } finally {
    saving.value = false;
  }
}
async function handleSave() {
  if (saveTimer) {
    clearTimeout(saveTimer);
    saveTimer = null;
  }
  await persistAnswers(true);
}
function closeResultModal() {
  resultModalOpen.value = false;
}
async function openResultModal() {
  if (!submitResult.value && paper.value) {
    const qs = paper.value.questions || [];
    let correct = 0;
    qs.forEach((q) => {
      if (isCorrect(q)) correct += 1;
    });
    submitResult.value = {
      paperId: paper.value.paperId,
      totalCount: qs.length,
      correctCount: correct,
      scoreRate: qs.length ? Math.round((correct * 10000) / qs.length) / 100 : 0,
    };
  }
  resultFilter.value = 'all';
  await nextTick();
  resultModalOpen.value = true;
}
function goRecords() {
  resultModalOpen.value = false;
  tabbarStore.renderRouteView = true;
  router.push('/tms/selfTest/records');
}
function correctLabelsOf(q: SelfTestQuestionItem) {
  return q.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel);
}
function userAnswerText(q: SelfTestQuestionItem) {
  const ans = answers[q.id];
  if (q.questionType === 'multi') {
    const selected = Array.isArray(ans) ? [...ans].sort() : [];
    return selected.length ? selected.join('、') : '未作答';
  }
  return String(ans || '') || '未作答';
}
function isCorrect(q: SelfTestQuestionItem): boolean {
  const ans = answers[q.id];
  const correctLabels = q.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel);
  const correctLabels = correctLabelsOf(q);
  if (q.questionType === 'multi') {
    const selected = Array.isArray(ans) ? [...ans].sort() : [];
    return selected.join(',') === [...correctLabels].sort().join(',');
@@ -64,16 +255,41 @@
  return String(ans || '') === String(correctLabels[0] || '');
}
function handleSubmit() {
  if (!paper.value) return;
  const qs = paper.value.questions;
  let right = 0;
  qs.forEach((q) => {
    if (isCorrect(q)) right += 1;
  });
  submitted.value = true;
  scoreText.value = `${right} / ${qs.length}`;
  createMessage.success(`检测完成,正确 ${right} 题,共 ${qs.length} 题`);
function optionClass(q: SelfTestQuestionItem, label: string) {
  const correctSet = new Set(correctLabelsOf(q));
  const ans = answers[q.id];
  const userSet = new Set(
    q.questionType === 'multi'
      ? Array.isArray(ans)
        ? ans
        : []
      : ans
        ? [String(ans)]
        : [],
  );
  if (correctSet.has(label)) return 'opt-correct';
  if (userSet.has(label) && !correctSet.has(label)) return 'opt-wrong';
  return '';
}
async function handleSubmit() {
  if (!paper.value || submitting.value) return;
  if (saveTimer) {
    clearTimeout(saveTimer);
    saveTimer = null;
  }
  submitting.value = true;
  try {
    const result = await submitSelfTest(paper.value.paperId, { answers: { ...answers } });
    submitted.value = true;
    submitResult.value = result;
    scoreText.value = `${result.correctCount} / ${result.totalCount}`;
    await openResultModal();
  } catch (e: any) {
    createMessage.error(e?.message || '交卷失败');
  } finally {
    submitting.value = false;
  }
}
</script>
@@ -90,18 +306,36 @@
            </div>
          </div>
          <a-space>
            <a-button v-if="submitted" type="link" @click="openResultModal">查看本次结果</a-button>
            <a-button v-if="submitted" type="link" @click="goRecords">检测记录</a-button>
            <a-button @click="goBack">返回设置</a-button>
            <a-button type="primary" :disabled="submitted" @click="handleSubmit">交卷</a-button>
            <a-button
              v-if="!submitted"
              :loading="saving"
              :disabled="submitting"
              @click="handleSave"
            >
              暂存
            </a-button>
            <a-button type="primary" :disabled="submitted" :loading="submitting" @click="handleSubmit">
              交卷
            </a-button>
          </a-space>
        </div>
        <div v-if="current" class="tms-exam-body">
          <div class="mb-3 text-sm text-gray-500">
            {{ labelOfType(current.questionType) }}
          <div class="q-meta mb-3">
            <div class="text-sm text-gray-500">
              {{ labelOfType(current.questionType) }}
            </div>
            <a-tooltip title="答题卡">
              <button type="button" class="sheet-icon-btn" aria-label="答题卡" @click="openSheet">
                <AppstoreOutlined />
              </button>
            </a-tooltip>
          </div>
          <div class="stem mb-4">{{ stripHtml(current.stem) }}</div>
          <!-- 单选 / 判断 -->
          <a-radio-group
            v-if="current.questionType === 'single' || current.questionType === 'judge'"
            v-model:value="answers[current.id]"
@@ -113,7 +347,6 @@
            </a-radio>
          </a-radio-group>
          <!-- 多选 -->
          <a-checkbox-group
            v-else-if="current.questionType === 'multi'"
            v-model:value="answers[current.id]"
@@ -127,14 +360,141 @@
          <div v-if="submitted" class="mt-4 text-sm" :class="isCorrect(current) ? 'text-green-600' : 'text-red-500'">
            {{ isCorrect(current) ? '回答正确' : '回答错误' }}
            · 正确答案:
            {{ current.options.filter((o) => o.isCorrect === '1').map((o) => o.optionLabel).join('、') }}
            · 正确答案:{{ correctLabelsOf(current).join('、') }}
          </div>
        </div>
        <div class="tms-exam-footer">
          <a-button :disabled="currentIndex <= 0" @click="goPrev">上一题</a-button>
          <a-button :disabled="currentIndex >= total - 1" @click="goNext">下一题</a-button>
        </div>
      </div>
    </div>
    <div v-if="sheetOpen && paper" class="tms-sheet-overlay" @click.self="sheetOpen = false">
      <div class="tms-sheet-dialog" role="dialog" aria-modal="true">
        <div class="sheet-head">
          <div>
            <div class="sheet-title">答题卡</div>
            <div class="sheet-sub">已答 {{ answeredCount }} / {{ total }},点击题号可切换</div>
          </div>
          <button type="button" class="tms-result-close" aria-label="关闭" @click="sheetOpen = false">×</button>
        </div>
        <div class="sheet-legend">
          <span class="legend-chip current">当前</span>
          <span class="legend-chip answered">已答</span>
          <span class="legend-chip unanswered">未答</span>
        </div>
        <div class="sheet-grid">
          <button
            v-for="(q, idx) in paper.questions"
            :key="q.id"
            type="button"
            class="sheet-item"
            :class="{
              current: idx === currentIndex,
              answered: isAnswered(q),
              unanswered: !isAnswered(q),
            }"
            @click="goToQuestion(idx)"
          >
            {{ idx + 1 }}
          </button>
        </div>
      </div>
    </div>
    <!-- 不用 Teleport 到 body,避免 KeepAlive 场景下遮罩残留导致全局无法点击/白屏 -->
    <div
      v-if="resultModalOpen && submitResult && paper"
      class="tms-result-overlay"
      @click.self="closeResultModal"
    >
      <div class="tms-result-dialog" role="dialog" aria-modal="true">
        <button type="button" class="tms-result-close" aria-label="关闭" @click="closeResultModal">×</button>
        <div class="result-hero">
          <div class="hero-title">本次检测结果</div>
          <div class="hero-bank">{{ paper.bankName }}</div>
          <div class="hero-stats">
            <div class="stat-item">
              <div class="stat-value">{{ rightCount }}</div>
              <div class="stat-label">答对</div>
            </div>
            <div class="stat-divider" />
            <div class="stat-item">
              <div class="stat-value">{{ wrongCount }}</div>
              <div class="stat-label">答错</div>
            </div>
            <div class="stat-divider" />
            <div class="stat-item">
              <div class="stat-value">{{ submitResult.scoreRate }}%</div>
              <div class="stat-label">正确率</div>
            </div>
            <div class="stat-divider" />
            <div class="stat-item">
              <div class="stat-value">{{ submitResult.correctCount }}/{{ submitResult.totalCount }}</div>
              <div class="stat-label">得分</div>
            </div>
          </div>
        </div>
        <div class="result-toolbar">
          <a-radio-group v-model:value="resultFilter" button-style="solid" size="small">
            <a-radio-button value="all">全部题目({{ total }})</a-radio-button>
            <a-radio-button value="wrong">仅错题({{ wrongCount }})</a-radio-button>
          </a-radio-group>
          <div class="legend">
            <span class="legend-item correct">正确答案</span>
            <span class="legend-item wrong">你的错误选项</span>
          </div>
        </div>
        <div class="result-list">
          <a-empty v-if="!resultQuestions.length" description="暂无题目" />
          <div
            v-for="(q, idx) in resultQuestions"
            :key="q.id"
            class="question-card"
            :class="isCorrect(q) ? 'is-right' : 'is-wrong'"
          >
            <div class="q-head">
              <div class="q-head-left">
                <span class="q-index">第 {{ idx + 1 }} 题</span>
                <span class="q-type">{{ labelOfType(q.questionType) }}</span>
              </div>
              <a-tag :color="isCorrect(q) ? 'success' : 'error'">
                {{ isCorrect(q) ? '回答正确' : '回答错误' }}
              </a-tag>
            </div>
            <div class="q-stem">{{ stripHtml(q.stem) }}</div>
            <div class="q-options">
              <div
                v-for="opt in q.options"
                :key="opt.optionLabel"
                class="q-option"
                :class="optionClass(q, opt.optionLabel)"
              >
                <span class="opt-label">{{ opt.optionLabel }}</span>
                <span class="opt-content">{{ opt.optionContent }}</span>
              </div>
            </div>
            <div class="q-answer-bar">
              <div>
                <span class="ans-label">你的答案</span>
                <span :class="isCorrect(q) ? 'ans-ok' : 'ans-bad'">{{ userAnswerText(q) }}</span>
              </div>
              <div>
                <span class="ans-label">正确答案</span>
                <span class="ans-ok">{{ correctLabelsOf(q).join('、') || '-' }}</span>
              </div>
            </div>
          </div>
        </div>
        <div class="result-footer">
          <a-button @click="closeResultModal">关闭</a-button>
          <a-button type="primary" @click="goRecords">我的检测记录</a-button>
        </div>
      </div>
    </div>
@@ -167,6 +527,33 @@
  overflow: auto;
}
.q-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.sheet-icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border: 1px solid #d9d9d9;
  border-radius: 6px;
  background: #fff;
  color: #1677ff;
  font-size: 16px;
  cursor: pointer;
  flex-shrink: 0;
}
.sheet-icon-btn:hover {
  border-color: #1677ff;
  background: #e6f4ff;
}
.stem {
  font-size: 15px;
  line-height: 1.7;
@@ -181,4 +568,388 @@
  padding-top: 16px;
  border-top: 1px solid #f0f0f0;
}
.tms-sheet-overlay {
  position: fixed;
  inset: 0;
  z-index: 1900;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(0, 0, 0, 0.45);
}
.tms-sheet-dialog {
  width: min(520px, 100%);
  max-height: min(70vh, 640px);
  display: flex;
  flex-direction: column;
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
  overflow: hidden;
}
.sheet-head {
  position: relative;
  flex-shrink: 0;
  padding: 18px 48px 12px 20px;
  border-bottom: 1px solid #f0f0f0;
}
.sheet-title {
  font-size: 16px;
  font-weight: 600;
  color: rgba(0, 0, 0, 0.88);
}
.sheet-sub {
  margin-top: 4px;
  font-size: 13px;
  color: rgba(0, 0, 0, 0.45);
}
.sheet-legend {
  flex-shrink: 0;
  display: flex;
  gap: 16px;
  padding: 12px 20px 0;
  font-size: 12px;
  color: rgba(0, 0, 0, 0.55);
}
.legend-chip::before {
  content: '';
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-right: 6px;
  border-radius: 3px;
  vertical-align: -2px;
}
.legend-chip.current::before {
  background: #fff;
  border: 2px solid #1677ff;
  box-sizing: border-box;
}
.legend-chip.answered::before {
  background: #1677ff;
}
.legend-chip.unanswered::before {
  background: #f5f5f5;
  border: 1px solid #d9d9d9;
  box-sizing: border-box;
}
.sheet-grid {
  flex: 1;
  min-height: 0;
  overflow: auto;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
  gap: 10px;
  padding: 16px 20px 20px;
}
.sheet-item {
  height: 40px;
  border-radius: 8px;
  border: 1px solid #d9d9d9;
  background: #fafafa;
  color: rgba(0, 0, 0, 0.65);
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.15s ease;
}
.sheet-item.answered {
  background: #1677ff;
  border-color: #1677ff;
  color: #fff;
}
.sheet-item.unanswered {
  background: #f5f5f5;
  border-color: #d9d9d9;
  color: rgba(0, 0, 0, 0.45);
}
.sheet-item.current {
  box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.35);
}
.sheet-item.current.unanswered {
  border-color: #1677ff;
  color: #1677ff;
  background: #e6f4ff;
}
.sheet-item:hover {
  filter: brightness(0.97);
}
.tms-result-overlay {
  position: fixed;
  inset: 0;
  z-index: 2000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(0, 0, 0, 0.45);
}
.tms-result-dialog {
  position: relative;
  width: min(720px, 100%);
  max-height: min(80vh, 780px);
  display: flex;
  flex-direction: column;
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
  overflow: hidden;
}
.tms-result-close {
  position: absolute;
  top: 10px;
  right: 12px;
  z-index: 2;
  width: 32px;
  height: 32px;
  border: none;
  border-radius: 6px;
  background: transparent;
  color: rgba(0, 0, 0, 0.45);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 22px;
  line-height: 1;
}
.tms-result-close:hover {
  background: rgba(0, 0, 0, 0.06);
  color: rgba(0, 0, 0, 0.75);
}
.result-hero {
  flex-shrink: 0;
  padding: 24px 28px 20px;
  background: linear-gradient(135deg, #f0f7ff 0%, #f8fbff 55%, #ffffff 100%);
  border-bottom: 1px solid #eef2f7;
}
.hero-title {
  font-size: 18px;
  font-weight: 600;
  color: rgba(0, 0, 0, 0.88);
  padding-right: 36px;
}
.hero-bank {
  margin-top: 4px;
  font-size: 13px;
  color: rgba(0, 0, 0, 0.45);
}
.hero-stats {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 18px;
  padding: 14px 8px;
  background: #fff;
  border: 1px solid #e8eef5;
  border-radius: 10px;
}
.stat-item {
  flex: 1;
  text-align: center;
}
.stat-value {
  font-size: 22px;
  font-weight: 700;
  line-height: 1.2;
  color: #1677ff;
}
.stat-label {
  margin-top: 4px;
  font-size: 12px;
  color: rgba(0, 0, 0, 0.45);
}
.stat-divider {
  width: 1px;
  height: 28px;
  background: #eef2f7;
}
.result-toolbar {
  flex-shrink: 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  padding: 12px 28px;
  border-bottom: 1px solid #f0f0f0;
}
.legend {
  display: flex;
  gap: 12px;
  font-size: 12px;
  color: rgba(0, 0, 0, 0.45);
}
.legend-item::before {
  content: '';
  display: inline-block;
  width: 10px;
  height: 10px;
  margin-right: 6px;
  border-radius: 2px;
  vertical-align: -1px;
}
.legend-item.correct::before {
  background: #b7eb8f;
}
.legend-item.wrong::before {
  background: #ffa39e;
}
.result-list {
  flex: 1;
  min-height: 160px;
  overflow: auto;
  padding: 8px 28px 4px;
}
.question-card {
  margin-bottom: 12px;
  padding: 14px 16px;
  border: 1px solid #f0f0f0;
  border-radius: 10px;
  background: #fff;
}
.question-card.is-wrong {
  border-color: #ffccc7;
  background: #fffafa;
}
.question-card.is-right {
  border-color: #d9f7be;
  background: #fcfffb;
}
.q-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}
.q-head-left {
  display: flex;
  align-items: center;
  gap: 8px;
}
.q-index {
  font-weight: 600;
  color: rgba(0, 0, 0, 0.88);
}
.q-type {
  font-size: 12px;
  color: rgba(0, 0, 0, 0.45);
}
.q-stem {
  margin-bottom: 10px;
  font-size: 14px;
  line-height: 1.7;
  color: rgba(0, 0, 0, 0.88);
}
.q-options {
  display: grid;
  gap: 6px;
  margin-bottom: 10px;
}
.q-option {
  display: flex;
  gap: 8px;
  padding: 8px 10px;
  border-radius: 6px;
  background: #fafafa;
  border: 1px solid transparent;
  color: rgba(0, 0, 0, 0.75);
}
.q-option .opt-label {
  min-width: 18px;
  font-weight: 600;
}
.q-option.opt-correct {
  background: #f6ffed;
  color: #389e0d;
  border-color: #b7eb8f;
}
.q-option.opt-wrong {
  background: #fff2f0;
  color: #cf1322;
  border-color: #ffccc7;
}
.q-answer-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 16px 28px;
  padding-top: 8px;
  border-top: 1px dashed #f0f0f0;
  font-size: 13px;
}
.ans-label {
  margin-right: 8px;
  color: rgba(0, 0, 0, 0.45);
}
.ans-ok {
  color: #389e0d;
  font-weight: 600;
}
.ans-bad {
  color: #cf1322;
  font-weight: 600;
}
.result-footer {
  flex-shrink: 0;
  display: flex;
  justify-content: center;
  gap: 16px;
  padding: 14px 28px 18px;
  border-top: 1px solid #f0f0f0;
  background: #fafafa;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/index.vue
@@ -2,10 +2,11 @@
import type { QuestionBankOption } from '#/views/x/tms/question/types';
import type { SelfTestFormModel } from './types';
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onActivated, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { useTabbarStore } from '@vben/stores';
import { getSelfTestBanks, startSelfTest } from '#/api/x/tms/selfTest';
@@ -20,10 +21,12 @@
const router = useRouter();
const { createMessage } = useMessage();
const tabbarStore = useTabbarStore();
const banks = ref<QuestionBankOption[]>([]);
const loading = ref(false);
const formRef = ref();
const pageReady = ref(false);
function defaultSetting() {
  return { count: 0, difficulty: 'normal' as SelfTestDifficulty };
@@ -44,10 +47,28 @@
  () => Number(dataForm.single.count || 0) + Number(dataForm.multi.count || 0) + Number(dataForm.judge.count || 0),
);
onMounted(async () => {
  banks.value = (await getSelfTestBanks()) || [];
  if (banks.value.length === 1) {
    dataForm.bankId = banks.value[0].id;
async function loadBanks() {
  try {
    banks.value = (await getSelfTestBanks()) || [];
    if (!dataForm.bankId && banks.value.length === 1) {
      dataForm.bankId = banks.value[0].id;
    }
  } catch (e: any) {
    banks.value = [];
    createMessage.error(e?.message || '加载题库失败');
  } finally {
    pageReady.value = true;
  }
}
onMounted(() => {
  tabbarStore.renderRouteView = true;
  loadBanks();
});
onActivated(() => {
  tabbarStore.renderRouteView = true;
  if (!pageReady.value || !banks.value.length) {
    loadBanks();
  }
});
@@ -92,6 +113,10 @@
    loading.value = false;
  }
}
function goRecords() {
  router.push('/tms/selfTest/records');
}
</script>
<template>
@@ -99,8 +124,11 @@
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-self-test-page">
        <div class="tms-self-test-header">
          <div class="text-base font-medium">自我检测</div>
          <div class="mt-1 text-gray-400 text-sm">设置查询条件,从题库中筛选题目进行自我检测。</div>
          <div>
            <div class="text-base font-medium">自我检测</div>
            <div class="mt-1 text-gray-400 text-sm">设置查询条件,从题库中筛选题目进行自我检测。</div>
          </div>
          <a-button @click="goRecords">我的检测记录</a-button>
        </div>
        <a-form
@@ -213,6 +241,9 @@
}
.tms-self-test-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-bottom: 24px;
  padding-bottom: 12px;
  border-bottom: 1px solid #f0f0f0;
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/mock.ts
File was deleted
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/records.vue
New file
@@ -0,0 +1,355 @@
<script lang="ts" setup>
import type { QuestionBankOption } from '#/views/x/tms/question/types';
import type { SelfTestPaper, SelfTestRecordItem } from './types';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { useTabbarStore } from '@vben/stores';
import dayjs from 'dayjs';
import { getSelfTestBanks, getSelfTestInfo, getSelfTestList } from '#/api/x/tms/selfTest';
import { useBaseStore } from '#/store';
import {
  TMS_DIC,
  TMS_DIC_FIELD_NAMES,
  labelOfDic,
  loadTmsDic,
  type TmsDicOpt,
} from '#/views/x/tms/shared/dic';
defineOptions({ name: 'TmsSelfTestRecords' });
const baseStore = useBaseStore();
const statusOpts = ref<TmsDicOpt[]>([]);
const router = useRouter();
const { createMessage } = useMessage();
const tabbarStore = useTabbarStore();
const banks = ref<QuestionBankOption[]>([]);
const records = ref<SelfTestRecordItem[]>([]);
const loading = ref(false);
const continueLoadingId = ref('');
const pagination = ref({ currentPage: 1, pageSize: 10, total: 0 });
const queryForm = reactive<{
  bankId?: string;
  testStatus?: string;
  /** jnpf-date-range 值为时间戳数组 */
  timeRange?: number[];
}>({
  bankId: undefined,
  testStatus: undefined,
  timeRange: undefined,
});
async function loadBanks() {
  try {
    banks.value = (await getSelfTestBanks()) || [];
  } catch {
    banks.value = [];
  }
}
async function loadRecords() {
  loading.value = true;
  try {
    const range = queryForm.timeRange;
    const begin = range?.[0] != null ? dayjs(range[0]).format('YYYY-MM-DD') : undefined;
    const end = range?.[1] != null ? dayjs(range[1]).format('YYYY-MM-DD') : undefined;
    const res = await getSelfTestList({
      currentPage: pagination.value.currentPage,
      pageSize: pagination.value.pageSize,
      bankId: queryForm.bankId || undefined,
      testStatus: queryForm.testStatus || undefined,
      startTimeBegin: begin ? `${begin} 00:00:00` : undefined,
      startTimeEnd: end ? `${end} 23:59:59` : undefined,
    });
    records.value = res?.list || [];
    const p = res?.pagination || {};
    pagination.value.total = Number(p.total || p.totalCount || records.value.length);
  } catch (e: any) {
    records.value = [];
    createMessage.error(e?.message || '加载检测记录失败');
  } finally {
    loading.value = false;
  }
}
function handleSearch() {
  pagination.value.currentPage = 1;
  loadRecords();
}
function handleReset() {
  queryForm.bankId = undefined;
  queryForm.testStatus = undefined;
  queryForm.timeRange = undefined;
  pagination.value.currentPage = 1;
  loadRecords();
}
onMounted(async () => {
  tabbarStore.renderRouteView = true;
  const examStatus = await loadTmsDic(baseStore, TMS_DIC.examStatus);
  statusOpts.value = examStatus.filter(
    (x) => x.enCode === 'submitted' || x.enCode === 'doing',
  );
  await loadBanks();
  loadRecords();
});
function statusLabel(status?: string) {
  return labelOfDic(statusOpts.value, status);
}
function goBack() {
  tabbarStore.renderRouteView = true;
  router.push('/tms/selfTest');
}
function handleView(record: SelfTestRecordItem) {
  if (record.testStatus !== 'submitted') {
    createMessage.warning('该检测尚未交卷,暂无答题结果可查看');
    return;
  }
  router.push(`/tms/selfTest/detail/${record.id}`);
}
/** 继续未交卷的检测 */
async function handleContinue(record: SelfTestRecordItem) {
  if (record.testStatus !== 'doing') return;
  continueLoadingId.value = record.id;
  try {
    const detail = await getSelfTestInfo(record.id);
    if (detail.testStatus === 'submitted') {
      createMessage.warning('该检测已交卷,请直接查看详情');
      loadRecords();
      return;
    }
    if (!detail.questions?.length) {
      createMessage.warning('未找到可继续的试题');
      return;
    }
    const paper: SelfTestPaper = {
      paperId: detail.paperId,
      bankId: detail.bankId,
      bankName: detail.bankName,
      testStatus: detail.testStatus,
      questions: detail.questions,
    };
    // 把已作答内容一并带入答题页
    const savedAnswers: Record<string, string | string[]> = {};
    detail.questions.forEach((q) => {
      if (!q.userAnswer) return;
      if (q.questionType === 'multi') {
        savedAnswers[q.id] = q.userAnswer.split(',').filter(Boolean);
      } else {
        savedAnswers[q.id] = q.userAnswer;
      }
    });
    sessionStorage.setItem('tms_self_test_paper', JSON.stringify(paper));
    sessionStorage.setItem('tms_self_test_answers', JSON.stringify(savedAnswers));
    router.push('/tms/selfTest/exam');
  } catch (e: any) {
    createMessage.error(e?.message || '继续考试失败');
  } finally {
    continueLoadingId.value = '';
  }
}
function onPageChange(page: number, pageSize: number) {
  pagination.value.currentPage = page;
  pagination.value.pageSize = pageSize;
  loadRecords();
}
/** 弹出层挂到 body,避免被 jnpf-content-wrapper overflow:hidden 裁切 */
function popupContainer() {
  return document.body;
}
</script>
<template>
  <div class="jnpf-content-wrapper tms-self-test-records-page">
    <div class="jnpf-content-wrapper-center tms-self-test-records-center">
      <div class="jnpf-content-wrapper-content tms-self-test-records">
        <div class="records-top">
          <div class="page-header">
            <div>
              <div class="text-base font-medium">我的检测记录</div>
              <div class="mt-1 text-gray-400 text-sm">查看历史自我检测成绩与错题。</div>
            </div>
            <a-button @click="goBack">返回自我检测</a-button>
          </div>
          <div class="search-bar">
            <a-form layout="inline" class="search-fields" :model="queryForm" @finish="handleSearch">
              <a-form-item label="题库">
                <jnpf-select
                  v-model:value="queryForm.bankId"
                  :options="banks"
                  allow-clear
                  show-search
                  placeholder="请选择题库"
                  class="!w-[200px]"
                />
              </a-form-item>
              <a-form-item label="状态">
                <jnpf-select
                  v-model:value="queryForm.testStatus"
                  :options="statusOpts"
                  :field-names="TMS_DIC_FIELD_NAMES"
                  allow-clear
                  placeholder="请选择状态"
                  class="!w-[140px]"
                />
              </a-form-item>
              <a-form-item label="时间范围">
                <jnpf-date-range
                  v-model:value="queryForm.timeRange"
                  allow-clear
                  format="YYYY-MM-DD"
                  class="!w-[260px]"
                  :placeholder="['开始日期', '结束日期']"
                  :get-popup-container="popupContainer"
                />
              </a-form-item>
            </a-form>
            <a-space class="search-actions">
              <a-button type="primary" :loading="loading" @click="handleSearch">查询</a-button>
              <a-button @click="handleReset">重置</a-button>
            </a-space>
          </div>
        </div>
        <div class="records-body">
          <a-table
            :data-source="records"
            :loading="loading"
            row-key="id"
            size="middle"
            :pagination="{
              current: pagination.currentPage,
              pageSize: pagination.pageSize,
              total: pagination.total,
              showSizeChanger: true,
              showTotal: (t: number) => `共 ${t} 条`,
              onChange: onPageChange,
            }"
            :columns="[
              { title: '题库', dataIndex: 'bankName', key: 'bankName', ellipsis: true },
              { title: '题量', dataIndex: 'totalCount', key: 'totalCount', width: 80 },
              { title: '正确', dataIndex: 'correctCount', key: 'correctCount', width: 80 },
              { title: '正确率', key: 'scoreRate', width: 100 },
              { title: '状态', key: 'testStatus', width: 100 },
              { title: '开始时间', dataIndex: 'startTime', key: 'startTime', width: 170 },
              { title: '交卷时间', dataIndex: 'submitTime', key: 'submitTime', width: 170 },
              { title: '操作', key: 'action', width: 120, fixed: 'right' },
            ]"
          >
            <template #bodyCell="{ column, record }">
              <template v-if="column.key === 'scoreRate'">
                {{ record.scoreRate != null ? `${record.scoreRate}%` : '-' }}
              </template>
              <template v-else-if="column.key === 'testStatus'">
                {{ statusLabel(record.testStatus) }}
              </template>
              <template v-else-if="column.key === 'correctCount'">
                {{ record.correctCount ?? '-' }}
              </template>
              <template v-else-if="column.key === 'action'">
                <a-button
                  v-if="record.testStatus === 'doing'"
                  type="link"
                  size="small"
                  :loading="continueLoadingId === record.id"
                  @click="handleContinue(record)"
                >
                  继续考试
                </a-button>
                <a-button
                  v-else
                  type="link"
                  size="small"
                  @click="handleView(record)"
                >
                  查看
                </a-button>
              </template>
            </template>
          </a-table>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
/* 全局 jnpf-content-wrapper* 为 overflow:hidden 且无 min-height:0,滚动必须落在内层 body */
.tms-self-test-records-page {
  min-height: 0;
}
.tms-self-test-records-center {
  min-height: 0 !important;
}
.tms-self-test-records {
  display: flex !important;
  flex-direction: column;
  flex: 1 1 0 !important;
  min-height: 0 !important;
  height: auto !important;
  overflow: hidden !important;
  background: #fff;
  padding: 0;
}
.records-top {
  flex-shrink: 0;
  padding: 20px 24px 0;
}
.page-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid #f0f0f0;
}
.search-bar {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 16px;
  margin-bottom: 12px;
}
.search-fields {
  flex: 1;
  row-gap: 12px;
}
.search-actions {
  flex-shrink: 0;
  padding-top: 4px;
}
.records-body {
  flex: 1 1 0;
  min-height: 0;
  overflow-y: auto !important;
  overflow-x: hidden;
  padding: 0 24px 24px;
  -webkit-overflow-scrolling: touch;
}
.records-body :deep(.ant-table-wrapper) {
  overflow: visible !important;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/selfTest/types.ts
@@ -22,11 +22,17 @@
  judge: SelfTestTypeSetting;
}
/** 抽题结果(考试页) */
/** 抽题结果 / 检测详情 */
export interface SelfTestPaper {
  paperId: string;
  bankId: string;
  bankName: string;
  testStatus?: string;
  totalCount?: number;
  correctCount?: number;
  scoreRate?: number;
  startTime?: string;
  submitTime?: string;
  questions: SelfTestQuestionItem[];
}
@@ -37,4 +43,32 @@
  difficulty?: Difficulty;
  stem: string;
  options: { optionLabel: string; optionContent: string; isCorrect: '0' | '1' }[];
  /** 回顾字段 */
  correctAnswer?: string;
  userAnswer?: string;
  isRight?: '0' | '1';
  sortNo?: number;
}
export interface SelfTestSubmitPayload {
  answers: Record<string, string | string[]>;
}
export interface SelfTestSubmitResult {
  paperId: string;
  totalCount: number;
  correctCount: number;
  scoreRate: number;
}
export interface SelfTestRecordItem {
  id: string;
  bankId: string;
  bankName: string;
  totalCount: number;
  correctCount?: number;
  scoreRate?: number;
  testStatus: string;
  startTime?: string;
  submitTime?: string;
}
apps/jnpf-web-apps-main/src/views/x/tms/shared/dic.ts
New file
@@ -0,0 +1,167 @@
import type { useBaseStore } from '#/store';
/** 与 docs/tms/字段注释与字典规范.md 对齐 */
export const TMS_DIC = {
  yesNo: 'tmsYesNo',
  enableStatus: 'tmsEnableStatus',
  signRule: 'tmsSignRule',
  questionType: 'tmsQuestionType',
  difficulty: 'tmsDifficulty',
  questionSource: 'tmsQuestionSource',
  openClosedInvalid: 'tmsOpenClosedInvalid',
  paperSortMode: 'tmsPaperSortMode',
  courseCategory: 'tmsCourseCategory',
  trainMode: 'tmsTrainMode',
  evalMode: 'tmsEvalMode',
  archiveStatus: 'tmsArchiveStatus',
  gradeStatus: 'tmsGradeStatus',
  examStatus: 'tmsExamStatus',
  passFlag: 'tmsPassFlag',
  taskPublishStatus: 'tmsTaskPublishStatus',
  taskCategory: 'tmsTaskCategory',
  taskKind: 'tmsTaskKind',
  taskSourceType: 'tmsTaskSourceType',
  personTaskStatus: 'tmsPersonTaskStatus',
} as const;
export type TmsDicCode = (typeof TMS_DIC)[keyof typeof TMS_DIC];
export type TmsDicOpt = {
  id: string;
  enCode?: string;
  fullName: string;
  [key: string]: any;
};
/** Select 存字典 enCode(与库字段编码一致) */
export const TMS_DIC_FIELD_NAMES = { value: 'enCode', label: 'fullName' } as const;
/** 字典不可用时的兜底,仅供 loadTmsDic 内部使用 */
const FALLBACK_BY_CODE: Record<string, TmsDicOpt[]> = {
  [TMS_DIC.yesNo]: [
    { id: '1', enCode: '1', fullName: '是' },
    { id: '0', enCode: '0', fullName: '否' },
  ],
  [TMS_DIC.enableStatus]: [
    { id: '1', enCode: '1', fullName: '启用' },
    { id: '0', enCode: '0', fullName: '停用' },
  ],
  [TMS_DIC.signRule]: [
    { id: 'scan', enCode: 'scan', fullName: '仅扫码' },
    { id: 'both', enCode: 'both', fullName: '扫码+在线' },
  ],
  [TMS_DIC.questionType]: [
    { id: 'single', enCode: 'single', fullName: '单选' },
    { id: 'multi', enCode: 'multi', fullName: '多选' },
    { id: 'judge', enCode: 'judge', fullName: '判断' },
    { id: 'blank', enCode: 'blank', fullName: '填空' },
    { id: 'essay', enCode: 'essay', fullName: '问答' },
  ],
  [TMS_DIC.difficulty]: [
    { id: 'easy', enCode: 'easy', fullName: '简单' },
    { id: 'normal', enCode: 'normal', fullName: '一般' },
    { id: 'hard', enCode: 'hard', fullName: '困难' },
  ],
  [TMS_DIC.questionSource]: [
    { id: 'self', enCode: 'self', fullName: '自命题' },
    { id: 'import', enCode: 'import', fullName: '导入' },
    { id: 'external', enCode: 'external', fullName: '外购' },
  ],
  [TMS_DIC.openClosedInvalid]: [
    { id: 'open', enCode: 'open', fullName: '开放' },
    { id: 'closed', enCode: 'closed', fullName: '不开放' },
    { id: 'invalid', enCode: 'invalid', fullName: '废弃' },
  ],
  [TMS_DIC.paperSortMode]: [
    { id: 'paper', enCode: 'paper', fullName: '试卷顺序' },
    { id: 'random', enCode: 'random', fullName: '随机' },
  ],
  [TMS_DIC.courseCategory]: [
    { id: 'gmp', enCode: 'gmp', fullName: 'GMP' },
    { id: 'sop', enCode: 'sop', fullName: 'SOP' },
    { id: 'safety', enCode: 'safety', fullName: '安全' },
    { id: 'skill', enCode: 'skill', fullName: '技能' },
    { id: 'other', enCode: 'other', fullName: '其他' },
  ],
  [TMS_DIC.trainMode]: [
    { id: 'onsite', enCode: 'onsite', fullName: '集中授课' },
    { id: 'practice', enCode: 'practice', fullName: '操作授课' },
    { id: 'online', enCode: 'online', fullName: '在线学习' },
  ],
  [TMS_DIC.evalMode]: [
    { id: 'quiz', enCode: 'quiz', fullName: '提问' },
    { id: 'practice', enCode: 'practice', fullName: '现场操作' },
    { id: 'exam', enCode: 'exam', fullName: '在线考试' },
    { id: 'none', enCode: 'none', fullName: '无需考核' },
  ],
  [TMS_DIC.archiveStatus]: [
    { id: 'pending', enCode: 'pending', fullName: '待归档' },
    { id: 'ready', enCode: 'ready', fullName: '可归档' },
    { id: 'archived', enCode: 'archived', fullName: '已归档' },
    { id: 'invalid', enCode: 'invalid', fullName: '无效' },
  ],
  [TMS_DIC.gradeStatus]: [
    { id: 'auto', enCode: 'auto', fullName: '无需阅卷' },
    { id: 'pending', enCode: 'pending', fullName: '待批改' },
    { id: 'graded', enCode: 'graded', fullName: '已批改' },
  ],
  [TMS_DIC.examStatus]: [
    { id: 'doing', enCode: 'doing', fullName: '考试中' },
    { id: 'submitted', enCode: 'submitted', fullName: '已交卷' },
    { id: 'cancelled', enCode: 'cancelled', fullName: '已取消' },
  ],
  [TMS_DIC.passFlag]: [
    { id: '1', enCode: '1', fullName: '合格' },
    { id: '0', enCode: '0', fullName: '不合格' },
  ],
  [TMS_DIC.taskPublishStatus]: [
    { id: 'draft', enCode: 'draft', fullName: '未发布' },
    { id: 'published', enCode: 'published', fullName: '已发布' },
    { id: 'cancelled', enCode: 'cancelled', fullName: '已取消' },
    { id: 'done', enCode: 'done', fullName: '已完成' },
  ],
  [TMS_DIC.taskCategory]: [
    { id: 'temp', enCode: 'temp', fullName: '临时培训' },
    { id: 'post_plan', enCode: 'post_plan', fullName: '岗位计划' },
    { id: 'annual_plan', enCode: 'annual_plan', fullName: '年度计划' },
    { id: 'file_effect', enCode: 'file_effect', fullName: '文件生效' },
    { id: 'out_train', enCode: 'out_train', fullName: '外派培训' },
    { id: 'retrain', enCode: 'retrain', fullName: '再培训' },
  ],
  [TMS_DIC.taskKind]: [
    { id: 'new', enCode: 'new', fullName: '新增' },
    { id: 'continue', enCode: 'continue', fullName: '继续' },
  ],
  [TMS_DIC.taskSourceType]: [
    { id: 'annual', enCode: 'annual', fullName: '年计划' },
    { id: 'post', enCode: 'post', fullName: '岗计划' },
    { id: 'temp', enCode: 'temp', fullName: '临时' },
    { id: 'file', enCode: 'file', fullName: '文件培训' },
  ],
  [TMS_DIC.personTaskStatus]: [
    { id: 'todo', enCode: 'todo', fullName: '未完成' },
    { id: 'signed', enCode: 'signed', fullName: '已签到' },
    { id: 'done', enCode: 'done', fullName: '已完成' },
    { id: 'expired', enCode: 'expired', fullName: '已过期' },
    { id: 'cancelled', enCode: 'cancelled', fullName: '已取消' },
  ],
};
export function labelOfDic(options: TmsDicOpt[], v?: string | null) {
  if (v == null || v === '') return '-';
  const hit = options.find((x) => x.enCode === v || x.id === v);
  return hit?.fullName || v;
}
type BaseStore = ReturnType<typeof useBaseStore>;
/** 统一字典入口:按编码拉取,失败时用内置兜底 */
export async function loadTmsDic(baseStore: BaseStore, code: string): Promise<TmsDicOpt[]> {
  const fallback = FALLBACK_BY_CODE[code] ?? [];
  try {
    const res = await baseStore.getDictionaryData(code);
    return Array.isArray(res) && res.length ? (res as TmsDicOpt[]) : fallback;
  } catch {
    return fallback;
  }
}
apps/jnpf-web-apps-main/src/views/x/tms/shared/page.css
New file
@@ -0,0 +1,40 @@
/* TMS 详情/工具条通用样式,按需在页面 import */
.tms-page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  margin-bottom: 12px;
}
.tms-page-header__title {
  font-size: 16px;
  font-weight: 600;
  line-height: 1.4;
  color: rgba(0, 0, 0, 0.88);
}
.tms-page-header__sub {
  margin-top: 4px;
  font-size: 13px;
  color: rgba(0, 0, 0, 0.45);
  line-height: 1.4;
}
.tms-page-header__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 8px;
  flex-shrink: 0;
}
.tms-page-tip {
  margin-bottom: 12px;
}
.tms-toolbar-hint {
  font-size: 13px;
  color: rgba(0, 0, 0, 0.45);
}
apps/jnpf-web-apps-main/src/views/x/tms/shared/ui.ts
New file
@@ -0,0 +1,48 @@
/**
 * TMS 页面按钮 / 文案统一约定
 *
 * 颜色:
 * - primary:本页唯一主操作(新增、发布、交卷、签到、进入考试、归档)
 * - default:返回、次要、导航
 * - primary + ghost:次主操作(进入学习、保存草稿)
 * - danger:删除、废弃、取消任务
 *
 * 文案:
 * - 返回详情/列表统一用「返回」
 * - 行内查看统一「详情」
 * - 关闭弹层用「关闭」
 */
export const TMS_BTN = {
  back: '返回',
  close: '关闭',
  detail: '详情',
  edit: '编辑',
  add: '新增',
  delete: '删除',
  save: '保存',
  saveDraft: '保存草稿',
  savePublish: '保存并发布',
  search: '查询',
  reset: '重置',
  export: '导出',
  publish: '发布',
  cancel: '取消',
  archive: '归档',
  sign: '签到',
  learn: '学习',
  enterLearn: '进入学习',
  exam: '考试',
  enterExam: '进入考试',
  retake: '补考',
  continueExam: '继续考试',
  startExam: '开始考试',
  submitExam: '交卷',
  grade: '阅卷',
  viewPaper: '查看试卷',
  restore: '恢复',
  maintain: '维护',
} as const;
/** 详情页操作区按钮顺序:返回 → 次要 → 主操作 */
export type TmsHeaderActionTone = 'back' | 'secondary' | 'primary' | 'danger';
apps/jnpf-web-apps-main/src/views/x/tms/signMode/Form.vue
@@ -6,13 +6,18 @@
import { BasicModal, useModalInner } from '@jnpf/ui/modal';
import { createSignMode, getSignModeInfo, updateSignMode } from '#/api/x/tms/signMode';
import { ENABLE_OPTIONS, YES_NO_OPTIONS } from './constants';
import { useBaseStore } from '#/store';
import {
  TMS_DIC,
  TMS_DIC_FIELD_NAMES,
  loadTmsDic,
} from '#/views/x/tms/shared/dic';
defineOptions({ name: 'TmsSignModeForm' });
const emit = defineEmits(['register', 'reload']);
const { createMessage } = useMessage();
const baseStore = useBaseStore();
const state = reactive({ id: '' });
const { id } = toRefs(state);
@@ -48,7 +53,11 @@
      label: '须匹配名单',
      component: 'Select',
      defaultValue: '1',
      componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -56,7 +65,11 @@
      label: '启用状态',
      component: 'Select',
      defaultValue: '1',
      componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -69,6 +82,23 @@
});
const [registerModal, { closeModal, changeLoading, changeOkLoading }] = useModalInner(init);
async function loadDicOptions() {
  const [yesNo, enable] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.yesNo),
    loadTmsDic(baseStore, TMS_DIC.enableStatus),
  ]);
  updateSchema([
    {
      field: 'matchRoster',
      componentProps: { placeholder: '请选择', options: yesNo, fieldNames: TMS_DIC_FIELD_NAMES },
    },
    {
      field: 'enabled',
      componentProps: { placeholder: '请选择', options: enable, fieldNames: TMS_DIC_FIELD_NAMES },
    },
  ]);
}
async function init(data: { id?: string }) {
  changeLoading(true);
@@ -84,9 +114,8 @@
    },
  });
  try {
    if (state.id) {
      setFieldsValue(await getSignModeInfo(state.id));
    }
    await loadDicOptions();
    if (state.id) setFieldsValue(await getSignModeInfo(state.id));
  } finally {
    changeLoading(false);
  }
apps/jnpf-web-apps-main/src/views/x/tms/signMode/constants.ts
File was deleted
apps/jnpf-web-apps-main/src/views/x/tms/signMode/index.vue
@@ -3,19 +3,32 @@
import type { SignModeItem } from './types';
import { onMounted, ref } from 'vue';
import { useMessage } from '@jnpf/hooks';
import { useModal } from '@jnpf/ui/modal';
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { deleteSignMode, getSignModeList, setSignModeEnabled } from '#/api/x/tms/signMode';
import { useBaseStore } from '#/store';
import {
  TMS_DIC,
  TMS_DIC_FIELD_NAMES,
  labelOfDic,
  loadTmsDic,
  type TmsDicOpt,
} from '#/views/x/tms/shared/dic';
import Form from './Form.vue';
import { ENABLE_OPTIONS, labelOfEnabled, labelOfYesNo } from './constants';
defineOptions({ name: 'TmsSignMode' });
const { createMessage } = useMessage();
const baseStore = useBaseStore();
const [registerForm, { openModal: openFormModal }] = useModal();
const yesNoOpts = ref<TmsDicOpt[]>([]);
const enableOpts = ref<TmsDicOpt[]>([]);
const columns: BasicColumn[] = [
  { title: '方式编码', dataIndex: 'modeCode', width: 140 },
@@ -32,7 +45,7 @@
    dataIndex: 'matchRoster',
    width: 120,
    align: 'center',
    customRender: ({ record }) => labelOfYesNo((record as SignModeItem).matchRoster),
    customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as SignModeItem).matchRoster),
  },
  {
    title: '状态',
@@ -44,10 +57,10 @@
  { title: '备注', dataIndex: 'remark', minWidth: 220 },
];
const [registerTable, { reload }] = useVxeTable({
const [registerTable, { reload, getForm }] = useVxeTable({
  api: fetchList,
  columns,
  immediate: true,
  immediate: false,
  rowKey: 'id',
  useSearchForm: true,
  formConfig: {
@@ -64,7 +77,12 @@
        field: 'enabled',
        label: '状态',
        component: 'Select',
        componentProps: { allowClear: true, placeholder: '请选择', options: ENABLE_OPTIONS },
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: enableOpts.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
    ],
  },
@@ -74,6 +92,21 @@
    dataIndex: 'action',
    fixed: 'right',
  },
});
onMounted(async () => {
  yesNoOpts.value = await loadTmsDic(baseStore, TMS_DIC.yesNo);
  enableOpts.value = await loadTmsDic(baseStore, TMS_DIC.enableStatus);
  getForm()?.updateSchema?.({
    field: 'enabled',
    componentProps: {
      allowClear: true,
      placeholder: '请选择',
      options: enableOpts.value,
      fieldNames: TMS_DIC_FIELD_NAMES,
    },
  });
  reload();
});
async function fetchList(params: Record<string, any>) {
@@ -101,6 +134,10 @@
}
async function handleDelete(record: SignModeItem) {
  if (record.enabled !== '0') {
    createMessage.warning('请先停用后再删除');
    return;
  }
  try {
    await deleteSignMode(record.id);
    createMessage.success('删除成功');
@@ -112,7 +149,7 @@
function getTableActions(record: SignModeItem): ActionItem[] {
  const enableLabel = record.enabled === '1' ? '停用' : '启用';
  return [
  const actions: ActionItem[] = [
    { label: '编辑', onClick: handleEdit.bind(null, record) },
    {
      label: enableLabel,
@@ -121,15 +158,18 @@
        onOk: handleToggleEnabled.bind(null, record),
      },
    },
    {
  ];
  if (record.enabled === '0') {
    actions.push({
      label: '删除',
      color: 'error',
      modelConfirm: {
        content: `确定删除签到方式「${record.modeName}」吗?`,
        onOk: handleDelete.bind(null, record),
      },
    },
  ];
    });
  }
  return actions;
}
</script>
@@ -143,7 +183,7 @@
          </template>
          <template #enabled="{ record }">
            <a-tag :color="record.enabled === '1' ? 'success' : 'default'">
              {{ labelOfEnabled(record.enabled) }}
              {{ labelOfDic(enableOpts, record.enabled) }}
            </a-tag>
          </template>
          <template #action="{ record }">
apps/jnpf-web-apps-main/src/views/x/tms/signMode/mock.ts
File was deleted
apps/jnpf-web-apps-main/src/views/x/tms/task/Detail.vue
New file
@@ -0,0 +1,192 @@
<script lang="ts" setup>
import type { TaskEntity } 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 { getUserInfoList } from '#/api/permission/user';
import { getTaskInfo } from '#/api/x/tms/task';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import {
  labelOfCategory,
  labelOfEvalMode,
  labelOfPersonStatus,
  labelOfPublishStatus,
  labelOfSourceType,
  labelOfTaskKind,
  labelOfTrainMode,
  loadTaskDics,
  publishStatusColor,
} from './constants';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsTaskDetail' });
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const loading = ref(false);
const detail = ref<TaskEntity | null>(null);
onMounted(async () => {
  await loadTaskDics();
  await loadData();
});
async function loadData() {
  const id = String(route.params.id || '');
  if (!id) {
    router.replace('/tms/task');
    return;
  }
  loading.value = true;
  try {
    const info = await getTaskInfo(id);
    await fillUserNames(info);
    detail.value = info;
  } catch (e: any) {
    createMessage.error(e?.message || '加载失败');
    router.replace('/tms/task');
  } finally {
    loading.value = false;
  }
}
async function fillUserNames(info: TaskEntity) {
  const ids = [
    info.teacherId,
    ...(info.personTasks || []).map((item) => item.userId),
  ].filter((id): id is string => !!id);
  const uniqueIds = [...new Set(ids)];
  if (!uniqueIds.length) return;
  const res = await getUserInfoList(uniqueIds);
  const list = res?.data?.list || [];
  const nameOf = new Map<string, string>();
  for (const user of list) {
    const name = user.realName || String(user.fullName || '').split('/')[0] || user.account;
    if (user.id && name) nameOf.set(user.id, name);
  }
  info.teacherName = nameOf.get(info.teacherId || '') || info.teacherId;
  for (const item of info.personTasks || []) {
    item.userName = nameOf.get(item.userId || '') || item.userId;
  }
}
function goBack() {
  router.push('/tms/task');
}
function goEdit() {
  if (!detail.value?.id || detail.value.publishStatus !== 'draft') return;
  router.push(`/tms/task/edit/${detail.value.id}`);
}
</script>
<template>
  <div class="jnpf-content-wrapper tms-task-detail-page" v-loading="loading">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-task-detail-wrap" v-if="detail">
        <a-card title="培训任务详情" :bordered="false">
          <template #extra>
            <a-space>
              <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
              <a-button
                type="primary"
                :disabled="detail.publishStatus !== 'draft'"
                @click="goEdit"
              >
                {{ TMS_BTN.edit }}
              </a-button>
            </a-space>
          </template>
          <ADescriptions :column="2" bordered size="small">
            <ADescriptionsItem label="培训编号">{{ detail.taskNo || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="状态">
              <a-tag :color="publishStatusColor(detail.publishStatus)">
                {{ labelOfPublishStatus(detail.publishStatus) }}
              </a-tag>
            </ADescriptionsItem>
            <ADescriptionsItem label="培训主题" :span="2">{{ detail.subject }}</ADescriptionsItem>
            <ADescriptionsItem label="培训分类">{{ labelOfCategory(detail.category) }}</ADescriptionsItem>
            <ADescriptionsItem label="来源类型">{{ labelOfSourceType(detail.sourceType) }}</ADescriptionsItem>
            <ADescriptionsItem label="培训方式">{{ labelOfTrainMode(detail.trainMode) }}</ADescriptionsItem>
            <ADescriptionsItem label="考核方式">{{ labelOfEvalMode(detail.evalMode) }}</ADescriptionsItem>
            <ADescriptionsItem label="任务类型">{{ labelOfTaskKind(detail.taskKind) }}</ADescriptionsItem>
            <ADescriptionsItem label="上次任务编号">{{ detail.prevTaskNo || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="开始时间">{{ detail.startTime || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="结束时间">{{ detail.endTime || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="关闭时间">{{ detail.closeTime || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="发布时间">{{ detail.publishTime || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="地点">{{ detail.placeName || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="教材编号">{{ detail.materialNo || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="附件" :span="2">
              {{ detail.files?.map((item) => item.fileName).filter(Boolean).join('、') || '-' }}
            </ADescriptionsItem>
            <ADescriptionsItem label="试卷">{{ detail.paperName || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="补考次数">{{ detail.retakeLimit ?? 1 }}</ADescriptionsItem>
            <ADescriptionsItem label="合格分">{{ detail.passScore ?? '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="考试开始">{{ detail.examStart || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="考试结束">{{ detail.examEnd || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="培训师">{{ detail.teacherName || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="培训对象数">
              {{ detail.personCount ?? detail.traineeIds?.length ?? '-' }}
            </ADescriptionsItem>
            <ADescriptionsItem label="培训要点" :span="2">{{ detail.keyPoints || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="备注" :span="2">{{ detail.remark || '-' }}</ADescriptionsItem>
            <ADescriptionsItem label="创建时间">{{ detail.creatorTime || '-' }}</ADescriptionsItem>
          </ADescriptions>
          <a-divider>个人任务</a-divider>
          <a-table
            size="small"
            :pagination="false"
            row-key="id"
            :data-source="detail.personTasks || []"
            :columns="[
              { title: '用户名称', dataIndex: 'userName', width: 160 },
              { title: '状态', dataIndex: 'bizStatus', width: 100 },
              { title: '签到时间', dataIndex: 'signTime', width: 170 },
              { title: '成绩', dataIndex: 'examScore', width: 90 },
              { title: '合格', dataIndex: 'passFlag', width: 90 },
            ]"
          >
            <template #bodyCell="{ column, record }">
              <template v-if="column.dataIndex === 'bizStatus'">
                {{ labelOfPersonStatus(record.bizStatus) }}
              </template>
              <template v-else-if="column.dataIndex === 'passFlag'">
                {{ record.passFlag === '1' ? '合格' : record.passFlag === '0' ? '不合格' : '-' }}
              </template>
            </template>
          </a-table>
        </a-card>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-task-detail-page {
  height: 100%;
  min-height: 0;
}
.tms-task-detail-wrap {
  height: 100%;
  min-height: 0;
  overflow-x: hidden;
  overflow-y: auto !important;
  background: #fff;
  box-sizing: border-box;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/task/Form.vue
New file
@@ -0,0 +1,959 @@
<script lang="ts" setup>
/**
 * 培训任务表单(对齐说明书 6.2.18)
 * - 空白新增 = 临时培训:内容字段可填
 * - 计划带出任务(年计划/岗计划等):编号/来源只读,其余内容与执行信息均可维护后发布
 */
import type { TaskEntity, TaskFile } from './types';
import { computed, h, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@jnpf/hooks';
import { BasicForm, useForm } from '@jnpf/ui/form';
import { Alert as AAlert, Steps as ASteps } from 'ant-design-vue';
import dayjs from 'dayjs';
import { getCoursePaperOptions } from '#/api/x/tms/course';
import { getPlaceOptions } from '#/api/x/tms/place';
import { createTask, getTaskInfo, publishTask, updateTask } from '#/api/x/tms/task';
import { JnpfPopupSelect } from '#/components/Jnpf/popupSelect';
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import { EVAL_MODE_OPTIONS, loadTaskDics, SOURCE_TYPE_OPTIONS, TASK_CATEGORY_OPTIONS, TASK_KIND_OPTIONS, TRAIN_MODE_OPTIONS } from './constants';
defineOptions({ name: 'TmsTaskForm' });
const DT_FMT = 'YYYY-MM-DD HH:mm:ss';
/** 日期组件校验类型是 number,字符串会被当成空值 */
function toTimeNumber(value?: null | number | string) {
  if (value == null || value === '') return undefined;
  if (typeof value === 'number' && !Number.isNaN(value)) return value;
  const text = String(value).trim();
  if (/^\d{10,13}$/.test(text)) {
    const n = Number(text);
    return text.length === 10 ? n * 1000 : n;
  }
  const date = dayjs(text);
  return date.isValid() ? date.valueOf() : undefined;
}
function formatTaskTime(value?: null | number | string) {
  if (value == null || value === '') return undefined;
  const date = dayjs(value);
  return date.isValid() ? date.format(DT_FMT) : undefined;
}
/** 可由空白新增的来源(说明书:临时;文件等由系统带出) */
const BLANK_CREATE_SOURCES = new Set(['temp']);
const route = useRoute();
const router = useRouter();
const { createMessage } = useMessage();
const loading = ref(false);
const submitting = ref(false);
const paperOptions = ref<{ fullName: string; id: string }[]>([]);
const placeOptions = ref<{ fullName: string; id: string; placeNo?: string }[]>([]);
/** 非草稿等场景锁内容;计划带出默认可改 */
const contentLocked = ref(false);
const taskFiles = ref<TaskFile[]>([]);
const uploadFiles = ref<any[]>([]);
/** 来自计划/系统带出:主题等内容只读 */
const fromPlan = ref(false);
/** 1=培训内容 2=执行安排 */
const currentStep = ref(1);
const state = reactive({ id: '', publishStatus: 'draft' as string });
const isEdit = computed(() => !!route.params.id && route.params.id !== 'create');
const pageTitle = computed(() => {
  if (!isEdit.value) return '新增临时培训任务';
  return fromPlan.value ? '维护培训任务(计划带出)' : '编辑临时培训任务';
});
const tipText = computed(() => {
  if (currentStep.value === 1) {
    if (fromPlan.value) {
      return '计划已带出主题、方式、对象等;可继续补全或修改后进入下一步。编号与来源不可改。';
    }
    return '先填写培训内容,下一步再安排时间、地点、试卷等执行信息。';
  }
  if (fromPlan.value) {
    return '请补全执行安排后保存或发布。可返回上一步修改培训内容。';
  }
  return '请填写执行安排;可返回上一步修改培训内容。';
});
const stepItems = computed(() => [{ title: fromPlan.value ? '计划内容(可维护)' : '培训内容' }, { title: fromPlan.value ? '执行安排(发布)' : '执行安排' }]);
const [registerContentForm, contentFormApi] = useForm({
  labelWidth: 120,
  baseColProps: { span: 12 },
  schemas: [
    {
      field: 'taskNo',
      label: '培训编号',
      component: 'Input',
      componentProps: { disabled: true, placeholder: '草稿号保存生成,发布后正式编号' },
    },
    {
      field: 'publishStatusLabel',
      label: '发布状态',
      component: 'Input',
      componentProps: { disabled: true },
      ifShow: () => isEdit.value,
    },
    {
      field: 'sourceType',
      label: '来源类型',
      component: 'Select',
      defaultValue: 'temp',
      componentProps: {
        disabled: true,
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'category',
      label: '培训分类',
      component: 'Select',
      defaultValue: 'temp',
      componentProps: {
        disabled: true,
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'sourcePlanNo',
      label: '来源计划号',
      component: 'Input',
      componentProps: { disabled: true, placeholder: '计划带出' },
      ifShow: () => fromPlan.value,
    },
    {
      field: 'subject',
      label: '培训主题',
      component: 'Input',
      componentProps: { placeholder: '请输入培训主题', maxlength: 200 },
      rules: [{ required: true, message: '必填', trigger: 'blur' }],
    },
    {
      field: 'trainMode',
      label: '培训方式',
      component: 'Select',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
        onChange: (val: string) => syncModeRules(val),
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
      field: 'evalMode',
      label: '考核方式',
      component: 'Select',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
        onChange: (val: string) => syncEvalRules(val),
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
      field: 'teacherId',
      label: '培训师',
      component: 'UserSelect',
      componentProps: { placeholder: '请选择培训师' },
    },
    {
      field: 'traineeIds',
      label: '培训对象',
      component: 'UserSelect',
      componentProps: { placeholder: '请选择培训对象', multiple: true },
      rules: [{ required: true, type: 'array', min: 1, message: '必填', trigger: 'change' }],
    },
    {
      field: 'materialId',
      label: '培训教材',
      component: 'Input',
      render: ({ model }) =>
        h(JnpfPopupSelect, {
          columnOptions: [
            { label: '教材编号', value: 'materialno' },
            { label: '教材名称', value: 'fullname' },
            {
              label: '文件类型',
              value: 'filetype',
              jnpfKey: 'select',
              props: { label: 'fullName', value: 'enCode' },
              __config__: {
                jnpfKey: 'select',
                dataType: 'dictionary',
                dictionaryType: 'tmsdt008',
              },
            },
          ],
          disabled: contentLocked.value,
          hasPage: true,
          pageSize: 20,
          interfaceId: '871690100000000001',
          placeholder: '请选择培训教材',
          popupTitle: '选择数据',
          popupType: 'popover',
          popupWidth: '800px',
          propsValue: 'id',
          relationField: 'fullname',
          value: model.materialId || undefined,
          'onUpdate:value': (id?: string) => {
            contentFormApi.setFieldsValue({ materialId: id || undefined });
            contentFormApi.clearValidate(['materialId']);
            if (!id) return;
          },
          onChange: (_id: string, row: any) => {
            const data = row || {};
            contentFormApi.setFieldsValue({
              materialNo: data.materialno || undefined,
            });
            ensureMainFile(data);
          },
        }),
    },
    {
      field: 'taskKind',
      label: '任务类型',
      component: 'Select',
      defaultValue: 'new',
      componentProps: {
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'prevTaskNo',
      label: '上次任务编号',
      component: 'Input',
      componentProps: { placeholder: '再维护时由系统带出', disabled: true },
      ifShow: ({ values }) => values.taskKind === 'continue',
    },
    {
      field: 'keyPoints',
      label: '培训要点',
      component: 'Textarea',
      colProps: { span: 24 },
      componentProps: { rows: 2, placeholder: '选填', maxlength: 2000 },
    },
  ],
});
const [registerExecForm, execFormApi] = useForm({
  labelWidth: 120,
  baseColProps: { span: 12 },
  schemas: [
    {
      field: 'startTime',
      label: '开始时间',
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        format: DT_FMT,
        valueFormat: DT_FMT,
        style: { width: '100%' },
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
      field: 'endTime',
      label: '结束时间',
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        format: DT_FMT,
        valueFormat: DT_FMT,
        style: { width: '100%' },
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
      field: 'closeTime',
      label: '关闭时间',
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        format: DT_FMT,
        valueFormat: DT_FMT,
        style: { width: '100%' },
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
      field: 'placeId',
      label: '培训地点',
      component: 'Select',
      componentProps: {
        allowClear: true,
        showSearch: true,
        placeholder: '集中/操作授课必选',
        options: [],
        optionFilterProp: 'fullName',
      },
    },
    {
      field: 'paperId',
      label: '考核试卷',
      component: 'Select',
      componentProps: {
        allowClear: true,
        showSearch: true,
        placeholder: '在线考试时必选',
        options: [],
        optionFilterProp: 'fullName',
      },
    },
    {
      field: 'evalMode',
      label: '考核方式',
      component: 'Input',
      ifShow: () => false,
    },
    {
      field: 'examStart',
      label: '考试开始',
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        format: DT_FMT,
        valueFormat: DT_FMT,
        style: { width: '100%' },
      },
      ifShow: ({ values }) => values.evalMode === 'exam',
    },
    {
      field: 'examEnd',
      label: '考试结束',
      component: 'DatePicker',
      componentProps: {
        showTime: true,
        format: DT_FMT,
        valueFormat: DT_FMT,
        style: { width: '100%' },
      },
      ifShow: ({ values }) => values.evalMode === 'exam',
    },
    {
      field: 'retakeLimit',
      label: '补考次数',
      component: 'InputNumber',
      defaultValue: 1,
      componentProps: { min: 0, max: 99, precision: 0, style: { width: '100%' } },
      helpMessage: '不合格可补考次数,默认 1',
    },
    {
      field: 'passScore',
      label: '合格分',
      component: 'InputNumber',
      componentProps: { min: 0, max: 999, precision: 1, style: { width: '100%' } },
    },
    {
      field: 'overdueRemind',
      label: '逾期提醒',
      component: 'Select',
      defaultValue: '0',
      componentProps: {
        options: [
          { id: '1', enCode: '1', fullName: '是' },
          { id: '0', enCode: '0', fullName: '否' },
        ],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'inviteFlag',
      label: '可邀请旁听',
      component: 'Select',
      defaultValue: '0',
      componentProps: {
        options: [
          { id: '1', enCode: '1', fullName: '是' },
          { id: '0', enCode: '0', fullName: '否' },
        ],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'remark',
      label: '备注',
      component: 'Textarea',
      colProps: { span: 24 },
      componentProps: { rows: 2, placeholder: '选填', maxlength: 500 },
    },
  ],
});
onMounted(async () => {
  loading.value = true;
  try {
    await loadTaskDics();
    await Promise.all([loadPapers(), loadPlaces()]);
    refreshOptions();
    if (isEdit.value) {
      await loadDetail(String(route.params.id));
    } else {
      fromPlan.value = false;
      contentFormApi.resetFields();
      execFormApi.resetFields();
      contentFormApi.setFieldsValue({
        sourceType: 'temp',
        category: 'temp',
        taskKind: 'new',
      });
      execFormApi.setFieldsValue({
        retakeLimit: 1,
        overdueRemind: '0',
        inviteFlag: '0',
        startTime: dayjs().valueOf(),
        endTime: dayjs().add(2, 'hour').valueOf(),
        closeTime: dayjs().add(1, 'day').valueOf(),
      });
      applyContentEditable(true);
      currentStep.value = 1;
    }
  } finally {
    loading.value = false;
  }
});
watch(
  () => route.params.id,
  async (id) => {
    if (!id || id === 'create') return;
    loading.value = true;
    try {
      await loadDetail(String(id));
    } finally {
      loading.value = false;
    }
  },
);
function isPlanSourced(info: TaskEntity) {
  if (info.sourceId || info.sourceItemId || info.sourcePlanNo) return true;
  const st = info.sourceType || '';
  return !!st && !BLANK_CREATE_SOURCES.has(st);
}
function applyContentEditable(editable: boolean) {
  contentLocked.value = !editable;
  const disabled = !editable;
  contentFormApi.updateSchema([
    { field: 'subject', componentProps: { disabled, placeholder: disabled ? '' : '请输入培训主题', maxlength: 200 } },
    {
      field: 'trainMode',
      componentProps: {
        disabled,
        allowClear: !disabled,
        options: TRAIN_MODE_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
        onChange: (val: string) => syncModeRules(val),
      },
    },
    {
      field: 'evalMode',
      componentProps: {
        disabled,
        allowClear: !disabled,
        options: EVAL_MODE_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
        onChange: (val: string) => {
          syncEvalRules(val);
        },
      },
    },
    { field: 'teacherId', componentProps: { disabled, placeholder: '请选择培训师' } },
    { field: 'traineeIds', componentProps: { disabled, placeholder: '请选择培训对象', multiple: true } },
    {
      field: 'taskKind',
      componentProps: {
        disabled,
        options: TASK_KIND_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    { field: 'keyPoints', componentProps: { disabled, rows: 2, placeholder: disabled ? '' : '选填', maxlength: 2000 } },
  ]);
}
function refreshOptions() {
  contentFormApi.updateSchema([
    {
      field: 'sourceType',
      componentProps: {
        disabled: true,
        options: SOURCE_TYPE_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'category',
      componentProps: {
        disabled: true,
        options: TASK_CATEGORY_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'trainMode',
      componentProps: {
        allowClear: true,
        options: TRAIN_MODE_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
        onChange: (val: string) => syncModeRules(val),
      },
    },
    {
      field: 'evalMode',
      componentProps: {
        allowClear: true,
        options: EVAL_MODE_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
        onChange: (val: string) => {
          syncEvalRules(val);
        },
      },
    },
    {
      field: 'taskKind',
      componentProps: {
        options: TASK_KIND_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
  ]);
  execFormApi.updateSchema([
    {
      field: 'placeId',
      componentProps: {
        allowClear: true,
        showSearch: true,
        options: placeOptions.value,
        optionFilterProp: 'fullName',
      },
    },
    {
      field: 'paperId',
      componentProps: {
        allowClear: true,
        showSearch: true,
        options: paperOptions.value,
        optionFilterProp: 'fullName',
      },
    },
  ]);
}
function syncModeRules(mode?: string) {
  const needPlace = mode === 'onsite' || mode === 'practice';
  const needMaterial = mode === 'online';
  execFormApi.updateSchema([
    {
      field: 'placeId',
      rules: needPlace ? [{ required: true, message: '现场类须选地点', trigger: 'change' }] : [],
    },
  ]);
  contentFormApi.updateSchema([
    {
      field: 'materialId',
      rules: needMaterial ? [{ required: true, message: '在线学习须选教材', trigger: 'change' }] : [],
    },
  ]);
  execFormApi.clearValidate(['placeId']);
  contentFormApi.clearValidate(['materialId']);
}
function ensureMainFile(row: any) {
  const materialId = row?.id;
  const fileName = row?.fullname;
  if (!materialId || !fileName) return;
  if (taskFiles.value.some((item) => item.materialId === materialId)) return;
  taskFiles.value = [
    {
      materialId,
      fileNo: row.materialno,
      fileName,
      canDownload: '0',
    },
    ...taskFiles.value,
  ];
}
function removeTaskFile(index: number) {
  const removed = taskFiles.value[index];
  taskFiles.value = taskFiles.value.filter((_, i) => i !== index);
  if (!removed?.fileJson) return;
  try {
    const file = JSON.parse(removed.fileJson);
    uploadFiles.value = uploadFiles.value.filter((item) => item.fileId !== file.fileId);
  } catch {
    // 非上传附件
  }
}
function onUploadChange(list: any[]) {
  const files = Array.isArray(list) ? list : [];
  uploadFiles.value = files;
  for (const file of files) {
    if (!file?.name) continue;
    const existed = taskFiles.value.some((row) => {
      if (!row.fileJson) return false;
      try {
        return JSON.parse(row.fileJson).fileId === file.fileId;
      } catch {
        return false;
      }
    });
    if (existed) continue;
    taskFiles.value = [
      ...taskFiles.value,
      {
        fileNo: file.fileId,
        fileName: file.name,
        fileJson: JSON.stringify(file),
        canDownload: '1',
      },
    ];
  }
}
function syncEvalRules(evalMode?: string) {
  execFormApi.updateSchema([
    {
      field: 'paperId',
      rules: evalMode === 'exam' ? [{ required: true, message: '在线考试须选试卷', trigger: 'change' }] : [],
    },
  ]);
  execFormApi.setFieldsValue({ evalMode });
  execFormApi.clearValidate(['paperId']);
}
async function loadPapers() {
  try {
    const list = await getCoursePaperOptions();
    paperOptions.value = (Array.isArray(list) ? list : []).map((x: any) => ({
      id: x.id,
      fullName: x.fullName || x.paperName || x.id,
    }));
  } catch {
    paperOptions.value = [];
  }
}
async function loadPlaces() {
  try {
    const list = await getPlaceOptions();
    placeOptions.value = (Array.isArray(list) ? list : []).map((x: any) => ({
      id: x.id,
      fullName: x.fullName || x.placeName || x.id,
      placeNo: x.placeNo,
    }));
  } catch {
    placeOptions.value = [];
  }
}
async function loadDetail(id: string) {
  const info = await getTaskInfo(id);
  if (info.publishStatus && info.publishStatus !== 'draft') {
    createMessage.warning('仅未发布任务可编辑,已跳转详情');
    router.replace(`/tms/task/detail/${id}`);
    return;
  }
  state.id = info.id || id;
  state.publishStatus = info.publishStatus || 'draft';
  fromPlan.value = isPlanSourced(info);
  // 计划带出也从内容步开始,便于补全培训师/教材等
  currentStep.value = 1;
  const traineeIds = Array.isArray(info.traineeIds)
    ? info.traineeIds
    : String(info.trainees || '')
        .split(/[,;,;\s]+/)
        .filter(Boolean);
  const statusMap: Record<string, string> = {
    draft: '未发布',
    published: '已发布',
    cancelled: '已取消',
    done: '已完成',
  };
  contentFormApi.setFieldsValue({
    ...info,
    traineeIds,
    materialId: String(info.materialId || '').split(',')[0] || undefined,
    publishStatusLabel: statusMap[info.publishStatus || 'draft'] || info.publishStatus,
  });
  taskFiles.value = Array.isArray(info.files) ? info.files : [];
  uploadFiles.value = taskFiles.value
    .map((row) => {
      if (!row.fileJson) return null;
      try {
        return JSON.parse(row.fileJson);
      } catch {
        return null;
      }
    })
    .filter(Boolean);
  execFormApi.setFieldsValue({
    ...info,
    evalMode: info.evalMode,
    startTime: toTimeNumber(info.startTime),
    endTime: toTimeNumber(info.endTime),
    closeTime: toTimeNumber(info.closeTime),
    examStart: toTimeNumber(info.examStart),
    examEnd: toTimeNumber(info.examEnd),
  });
  refreshOptions();
  // 计划带出:仅编号/来源类只读,业务内容可维护
  applyContentEditable(true);
  syncModeRules(info.trainMode);
  syncEvalRules(info.evalMode);
}
async function goNext() {
  try {
    await contentFormApi.validate();
    const content = contentFormApi.getFieldsValue() as TaskEntity;
    syncModeRules(content.trainMode);
    syncEvalRules(content.evalMode);
    currentStep.value = 2;
  } catch {
    // 校验失败由表单提示
  }
}
function goPrev() {
  currentStep.value = 1;
}
async function handleSubmit(andPublish = false) {
  const [content, exec] = await Promise.all([contentFormApi.validate(), execFormApi.validate()]);
  submitting.value = true;
  try {
    let traineeIds = (content as TaskEntity).traineeIds as string | string[] | undefined;
    if (typeof traineeIds === 'string') {
      traineeIds = traineeIds.split(/[,;,;\s]+/).filter(Boolean);
    }
    const teacherRaw = (content as TaskEntity).teacherId as any;
    const materialRaw = (content as TaskEntity).materialId;
    const materialId =
      String(materialRaw || '')
        .split(/[,;,;]+/)[0]
        ?.trim() || undefined;
    const contentData = content as TaskEntity;
    const execData = exec as TaskEntity;
    // 执行表单加载时写入了整条任务,后写入会盖掉第一步改过的培训方式/考核方式
    const payload: Partial<TaskEntity> = {
      ...execData,
      ...contentData,
      sourceType: fromPlan.value ? contentData.sourceType : 'temp',
      category: fromPlan.value ? contentData.category : 'temp',
      traineeIds: Array.isArray(traineeIds) ? traineeIds : [],
      teacherId: Array.isArray(teacherRaw) ? teacherRaw[0] : teacherRaw,
      materialId,
      trainMode: contentData.trainMode,
      evalMode: contentData.evalMode,
      files: taskFiles.value.filter((row) => row.fileName),
      startTime: formatTaskTime(execData.startTime),
      endTime: formatTaskTime(execData.endTime),
      closeTime: formatTaskTime(execData.closeTime),
      examStart: formatTaskTime(execData.examStart),
      examEnd: formatTaskTime(execData.examEnd),
      placeId: execData.placeId,
      placeName: execData.placeName,
      paperId: execData.paperId,
      retakeLimit: execData.retakeLimit,
      passScore: execData.passScore,
      overdueRemind: execData.overdueRemind,
      inviteFlag: execData.inviteFlag,
      remark: execData.remark,
    };
    delete (payload as any).publishStatusLabel;
    let id = state.id;
    if (isEdit.value && id) {
      await updateTask({ ...payload, id });
      createMessage.success('更新成功');
    } else {
      id = await createTask(payload);
      state.id = id;
      createMessage.success('创建成功');
    }
    if (andPublish && id) {
      await publishTask(id);
      createMessage.success('发布成功');
    }
    router.push('/tms/task');
  } catch (error: any) {
    if (error?.errorFields) return;
    createMessage.error(error?.message || '保存失败');
  } finally {
    submitting.value = false;
  }
}
function handleCancel() {
  router.push('/tms/task');
}
</script>
<template>
  <div class="jnpf-content-wrapper tms-task-form-page" v-loading="loading">
    <div class="jnpf-content-wrapper-center">
      <div class="jnpf-content-wrapper-content tms-task-form-wrap">
        <div class="tms-task-form-scroll">
          <div class="tms-task-page-title">{{ pageTitle }}</div>
          <ASteps class="tms-task-steps" size="small" :current="currentStep - 1" :items="stepItems" />
          <AAlert class="tms-task-tip" type="info" show-icon :message="tipText" />
          <a-card v-show="currentStep === 1" :title="fromPlan ? '计划带出(可维护)' : '培训内容'" :bordered="false" class="tms-task-section">
            <BasicForm @register="registerContentForm" />
            <div class="tms-task-files">
              <div class="tms-task-files-head">
                <span>教材/附件</span>
                <JnpfUploadFile
                  v-if="!contentLocked"
                  v-model:value="uploadFiles"
                  accept="*"
                  button-text="上传附件"
                  :file-size="0"
                  :limit="0"
                  :show-all-download="false"
                  :show-upload-list="false"
                  tip-text="支持文档和视频"
                  show-tip
                  @change="onUploadChange" />
              </div>
              <a-table
                size="small"
                :pagination="false"
                row-key="materialId"
                :data-source="taskFiles"
                :columns="[
                  { title: '编号', dataIndex: 'fileNo', width: 160 },
                  { title: '文件名称', dataIndex: 'fileName' },
                  { title: '可下载', dataIndex: 'canDownload', width: 90 },
                  { title: '操作', dataIndex: 'action', width: 80 },
                ]">
                <template #bodyCell="{ column, record, index }">
                  <template v-if="column.dataIndex === 'fileName'">
                    <a-input v-model:value="record.fileName" :disabled="contentLocked" maxlength="300" />
                  </template>
                  <template v-else-if="column.dataIndex === 'canDownload'">
                    <a-switch
                      :checked="record.canDownload === '1'"
                      :disabled="contentLocked"
                      checked-children="是"
                      un-checked-children="否"
                      @change="(checked) => (record.canDownload = checked ? '1' : '0')" />
                  </template>
                  <template v-else-if="column.dataIndex === 'action'">
                    <a-button type="link" danger :disabled="contentLocked" @click="removeTaskFile(index)">删除</a-button>
                  </template>
                </template>
              </a-table>
            </div>
          </a-card>
          <a-card v-show="currentStep === 2" :title="fromPlan ? '执行安排(补全后发布)' : '执行安排'" :bordered="false" class="tms-task-section">
            <BasicForm @register="registerExecForm" />
          </a-card>
        </div>
        <div class="tms-task-form-footer">
          <a-space>
            <a-button @click="handleCancel">{{ TMS_BTN.cancel }}</a-button>
            <a-button v-if="currentStep === 2" @click="goPrev">上一步</a-button>
            <a-button v-if="currentStep === 1" type="primary" @click="goNext">下一步</a-button>
            <template v-if="currentStep === 2">
              <a-button type="primary" ghost :loading="submitting" @click="handleSubmit(false)">
                {{ TMS_BTN.saveDraft }}
              </a-button>
              <a-button type="primary" :loading="submitting" @click="handleSubmit(true)">
                {{ TMS_BTN.savePublish }}
              </a-button>
            </template>
          </a-space>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-task-form-page {
  height: 100%;
  min-height: 0;
}
.tms-task-form-wrap {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
  overflow: hidden;
  background: #fff;
}
.tms-task-form-scroll {
  flex: 1;
  min-height: 0;
  overflow-x: hidden;
  overflow-y: auto;
  padding: 16px 16px 8px;
}
.tms-task-page-title {
  margin-bottom: 12px;
  font-size: 16px;
  font-weight: 600;
}
.tms-task-steps {
  max-width: 480px;
  margin-bottom: 12px;
}
.tms-task-tip {
  margin-bottom: 12px;
}
.tms-task-section {
  margin-bottom: 12px;
}
.tms-task-files {
  margin-top: 8px;
}
.tms-task-files-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8px;
  font-weight: 600;
}
.tms-task-form-footer {
  flex-shrink: 0;
  padding: 12px 16px;
  border-top: 1px solid #f0f0f0;
  text-align: right;
  background: #fff;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/task/constants.ts
New file
@@ -0,0 +1,71 @@
import type { DicOpt } from './types';
import { ref } from 'vue';
import { useBaseStore } from '#/store';
import { TMS_DIC, labelOfDic, loadTmsDic } from '#/views/x/tms/shared/dic';
export const PUBLISH_STATUS_OPTIONS = ref<DicOpt[]>([]);
export const TASK_CATEGORY_OPTIONS = ref<DicOpt[]>([]);
export const TASK_KIND_OPTIONS = ref<DicOpt[]>([]);
export const SOURCE_TYPE_OPTIONS = ref<DicOpt[]>([]);
export const TRAIN_MODE_OPTIONS = ref<DicOpt[]>([]);
export const EVAL_MODE_OPTIONS = ref<DicOpt[]>([]);
export const PERSON_STATUS_OPTIONS = ref<DicOpt[]>([]);
export async function loadTaskDics() {
  const baseStore = useBaseStore();
  const [publish, category, kind, source, train, evalMode, person] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.taskPublishStatus),
    loadTmsDic(baseStore, TMS_DIC.taskCategory),
    loadTmsDic(baseStore, TMS_DIC.taskKind),
    loadTmsDic(baseStore, TMS_DIC.taskSourceType),
    loadTmsDic(baseStore, TMS_DIC.trainMode),
    loadTmsDic(baseStore, TMS_DIC.evalMode),
    loadTmsDic(baseStore, TMS_DIC.personTaskStatus),
  ]);
  PUBLISH_STATUS_OPTIONS.value = publish;
  TASK_CATEGORY_OPTIONS.value = category;
  TASK_KIND_OPTIONS.value = kind;
  SOURCE_TYPE_OPTIONS.value = source;
  TRAIN_MODE_OPTIONS.value = train;
  EVAL_MODE_OPTIONS.value = evalMode;
  PERSON_STATUS_OPTIONS.value = person;
}
export function labelOfPublishStatus(v?: string) {
  return labelOfDic(PUBLISH_STATUS_OPTIONS.value, v);
}
export function labelOfCategory(v?: string) {
  return labelOfDic(TASK_CATEGORY_OPTIONS.value, v);
}
export function labelOfSourceType(v?: string) {
  return labelOfDic(SOURCE_TYPE_OPTIONS.value, v);
}
export function labelOfTrainMode(v?: string) {
  return labelOfDic(TRAIN_MODE_OPTIONS.value, v);
}
export function labelOfEvalMode(v?: string) {
  return labelOfDic(EVAL_MODE_OPTIONS.value, v);
}
export function labelOfTaskKind(v?: string) {
  return labelOfDic(TASK_KIND_OPTIONS.value, v);
}
export function labelOfPersonStatus(v?: string) {
  return labelOfDic(PERSON_STATUS_OPTIONS.value, v);
}
export function publishStatusColor(status?: string) {
  switch (status) {
    case 'draft':
      return 'default';
    case 'published':
      return 'processing';
    case 'done':
      return 'success';
    case 'cancelled':
      return 'error';
    default:
      return 'default';
  }
}
apps/jnpf-web-apps-main/src/views/x/tms/task/index.vue
New file
@@ -0,0 +1,325 @@
<script lang="ts" setup>
import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
import type { TaskEntity } from './types';
import { onMounted } 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 {
  cancelTask,
  cancelTaskBatch,
  deleteTask,
  getTaskList,
  publishTask,
  restoreTask,
} from '#/api/x/tms/task';
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import {
  PUBLISH_STATUS_OPTIONS,
  SOURCE_TYPE_OPTIONS,
  TRAIN_MODE_OPTIONS,
  labelOfPublishStatus,
  labelOfSourceType,
  labelOfTrainMode,
  loadTaskDics,
  publishStatusColor,
} from './constants';
defineOptions({ name: 'TmsTaskList' });
const router = useRouter();
const { createMessage } = useMessage();
const columns: BasicColumn[] = [
  { title: '培训编号', dataIndex: 'taskNo', width: 140 },
  { title: '培训主题', dataIndex: 'subject', minWidth: 200 },
  {
    title: '来源类型',
    dataIndex: 'sourceType',
    width: 110,
    customRender: ({ record }) => labelOfSourceType((record as TaskEntity).sourceType),
  },
  {
    title: '培训方式',
    dataIndex: 'trainMode',
    width: 110,
    customRender: ({ record }) => labelOfTrainMode((record as TaskEntity).trainMode),
  },
  { title: '开始时间', dataIndex: 'startTime', width: 170 },
  { title: '结束时间', dataIndex: 'endTime', width: 170 },
  { title: '地点', dataIndex: 'placeName', width: 140 },
  {
    title: '状态',
    dataIndex: 'publishStatus',
    width: 100,
    align: 'center',
    slots: { default: 'publishStatus' },
  },
  { title: '创建时间', dataIndex: 'creatorTime', width: 170 },
];
const [registerTable, { reload, getForm, getSelectRows }] = useVxeTable({
  api: fetchList,
  columns,
  immediate: false,
  rowKey: 'id',
  useSearchForm: true,
  formConfig: {
    baseColProps: { span: 6 },
    compact: true,
    schemas: [
      {
        field: 'keyword',
        label: '关键词',
        component: 'Input',
        componentProps: { placeholder: '编号/主题', submitOnPressEnter: true },
      },
      {
        field: 'publishStatus',
        label: '状态',
        component: 'Select',
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: PUBLISH_STATUS_OPTIONS.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
      {
        field: 'trainMode',
        label: '培训方式',
        component: 'Select',
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: TRAIN_MODE_OPTIONS.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
      {
        field: 'sourceType',
        label: '来源类型',
        component: 'Select',
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: SOURCE_TYPE_OPTIONS.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
      {
        field: 'startTimeRange',
        label: '开始时间',
        component: 'DateRange',
        componentProps: {
          format: 'YYYY-MM-DD',
          placeholder: ['开始', '结束'],
        },
      },
    ],
  },
  rowSelection: { type: 'checkbox' },
  actionColumn: {
    width: 260,
    title: '操作',
    dataIndex: 'action',
    fixed: 'right',
  },
});
onMounted(async () => {
  await loadTaskDics();
  getForm()?.updateSchema?.([
    {
      field: 'publishStatus',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: PUBLISH_STATUS_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'trainMode',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: TRAIN_MODE_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'sourceType',
      componentProps: {
        allowClear: true,
        placeholder: '请选择',
        options: SOURCE_TYPE_OPTIONS.value,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
  ]);
  reload();
});
async function fetchList(params: Record<string, any>) {
  const query = { ...params };
  const range = query.startTimeRange;
  if (Array.isArray(range) && range.length === 2) {
    query.startTimeFrom = range[0];
    query.startTimeTo = range[1];
  }
  delete query.startTimeRange;
  const page = await getTaskList(query);
  return {
    data: {
      list: Array.isArray(page?.list) ? page.list : [],
      pagination: page?.pagination || { total: 0 },
    },
  };
}
function handleCreate() {
  router.push('/tms/task/create');
}
function handleEdit(record: TaskEntity) {
  if (record.publishStatus !== 'draft') {
    createMessage.warning('仅未发布任务可维护');
    return;
  }
  router.push(`/tms/task/edit/${record.id}`);
}
function handleDetail(record: TaskEntity) {
  router.push(`/tms/task/detail/${record.id}`);
}
async function handlePublish(record: TaskEntity) {
  await publishTask(record.id!);
  createMessage.success('发布成功');
  reload();
}
async function handleCancel(record: TaskEntity) {
  await cancelTask(record.id!);
  createMessage.success('已取消');
  reload();
}
async function handleRestore(record: TaskEntity) {
  await restoreTask(record.id!);
  createMessage.success('已恢复为草稿');
  reload();
}
async function handleDelete(record: TaskEntity) {
  await deleteTask(record.id!);
  createMessage.success('删除成功');
  reload();
}
async function handleBatchCancel() {
  const rows = (getSelectRows?.() || []) as TaskEntity[];
  const ids = rows.filter((r) => r.publishStatus === 'published').map((r) => r.id!).filter(Boolean);
  if (!ids.length) {
    createMessage.warning('请勾选已发布的任务');
    return;
  }
  Modal.confirm({
    title: '批量取消',
    content: `确定取消选中的 ${ids.length} 个已发布任务?已有签到人员的任务将失败。`,
    onOk: async () => {
      await cancelTaskBatch(ids);
      createMessage.success('批量取消成功');
      reload();
    },
  });
}
function getTableActions(record: TaskEntity): ActionItem[] {
  const actions: ActionItem[] = [{ label: TMS_BTN.detail, onClick: handleDetail.bind(null, record) }];
  if (record.publishStatus === 'draft') {
    actions.unshift(
      {
        label: TMS_BTN.publish,
        modelConfirm: {
          content: `确定发布「${record.subject}」?将生成个人任务与培训记录。`,
          onOk: handlePublish.bind(null, record),
        },
      },
      {
        label: TMS_BTN.maintain,
        onClick: handleEdit.bind(null, record),
      },
      {
        label: TMS_BTN.delete,
        color: 'error',
        modelConfirm: {
          content: `确定删除「${record.subject}」吗?`,
          onOk: handleDelete.bind(null, record),
        },
      },
    );
  } else if (record.publishStatus === 'published') {
    actions.unshift({
      label: TMS_BTN.cancel,
      color: 'error',
      modelConfirm: {
        content: `确定取消「${record.subject}」?已有签到人员时不可取消。`,
        onOk: handleCancel.bind(null, record),
      },
    });
  } else if (record.publishStatus === 'cancelled') {
    actions.unshift({
      label: TMS_BTN.restore,
      modelConfirm: {
        content: `确定将「${record.subject}」恢复为草稿?`,
        onOk: handleRestore.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>
            <a-space>
              <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleCreate">
                {{ TMS_BTN.add }}临时培训
              </a-button>
              <a-button danger @click="handleBatchCancel">批量{{ TMS_BTN.cancel }}</a-button>
            </a-space>
          </template>
          <template #publishStatus="{ record }">
            <a-tag :color="publishStatusColor(record.publishStatus)">
              {{ labelOfPublishStatus(record.publishStatus) }}
            </a-tag>
          </template>
          <template #action="{ record }">
            <TableAction :actions="getTableActions(record)" />
          </template>
        </BasicVxeTable>
      </div>
    </div>
  </div>
</template>
<style scoped>
.tms-task-list-hint {
  color: #999;
  font-size: 12px;
}
</style>
apps/jnpf-web-apps-main/src/views/x/tms/task/types.ts
New file
@@ -0,0 +1,82 @@
import type { TmsDicOpt } from '#/views/x/tms/shared/dic';
export interface TaskEntity {
  id?: string;
  taskNo?: string;
  category?: string;
  trainType?: string;
  sourceType?: string;
  sourcePlanNo?: string;
  sourceId?: string;
  sourceItemId?: string;
  subject?: string;
  keyPoints?: string;
  materialId?: string;
  materialNo?: string;
  placeId?: string;
  placeNo?: string;
  placeName?: string;
  startTime?: string;
  endTime?: string;
  closeTime?: string;
  paperId?: string;
  paperName?: string;
  examStart?: string;
  examEnd?: string;
  teacherId?: string;
  teacherName?: string;
  trainMode?: string;
  evalMode?: string;
  traineeIds?: string[];
  trainees?: string;
  taskKind?: string;
  prevTaskNo?: string;
  publishStatus?: string;
  publishTime?: string;
  retakeLimit?: number;
  passScore?: number;
  quizRatio?: number;
  overdueRemind?: string;
  inviteFlag?: string;
  remark?: string;
  creatorTime?: string;
  creatorUserId?: string;
  personCount?: number;
  personTasks?: PersonTaskItem[];
  files?: TaskFile[];
}
export interface PersonTaskItem {
  id?: string;
  userId?: string;
  userName?: string;
  deptId?: string;
  bizStatus?: string;
  signTime?: string;
  examScore?: number;
  passFlag?: string;
  guestFlag?: string;
}
export interface TaskFile {
  id?: string;
  fileNo?: string;
  fileName?: string;
  materialId?: string;
  fileJson?: string;
  canDownload?: string;
}
export interface TaskPageQuery {
  keyword?: string;
  publishStatus?: string;
  trainMode?: string;
  sourceType?: string;
  startTimeFrom?: string;
  startTimeTo?: string;
  currentPage?: number;
  pageSize?: number;
  [key: string]: any;
}
export type DicOpt = TmsDicOpt;
apps/jnpf-web-apps-main/src/views/x/tms/trainArchive/index.vue
@@ -1,10 +1,9 @@
<script lang="ts" setup>
import type { TrainArchiveDetail } from './types';
import { computed, onMounted, ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useMessage } from '@jnpf/hooks';
import { useUserStore } from '@vben/stores';
import {
  Descriptions as ADescriptions,
  DescriptionsItem as ADescriptionsItem,
@@ -16,12 +15,9 @@
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 },
@@ -48,25 +44,10 @@
  loadArchive();
});
function resolvePositionName() {
  const list = userInfo.value.positionList;
  if (Array.isArray(list) && list.length) {
    return list.map((o: any) => o.treeName || o.fullName || o.name).filter(Boolean).join('、') || '无';
  }
  return userInfo.value.positionName || '无';
}
async function loadArchive() {
  loading.value = true;
  try {
    const u = userInfo.value;
    detail.value = await getMyTrainArchive({
      userId: u.userId || u.id,
      userName: u.userName,
      userAccount: u.userAccount,
      organizeName: u.organizeName || u.departmentName,
      positionName: resolvePositionName(),
    });
    detail.value = await getMyTrainArchive();
  } catch (e: any) {
    createMessage.error(e?.message || '加载培训档案失败');
  } finally {
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/Form.vue
@@ -6,13 +6,14 @@
import { BasicModal, useModalInner } from '@jnpf/ui/modal';
import { createTrainMode, getTrainModeInfo, updateTrainMode } from '#/api/x/tms/trainMode';
import { ENABLE_OPTIONS, SIGN_RULE_OPTIONS, YES_NO_OPTIONS } from './constants';
import { useBaseStore } from '#/store';
import { TMS_DIC, TMS_DIC_FIELD_NAMES, loadTmsDic } from '#/views/x/tms/shared/dic';
defineOptions({ name: 'TmsTrainModeForm' });
const emit = defineEmits(['register', 'reload']);
const { createMessage } = useMessage();
const baseStore = useBaseStore();
const state = reactive({ id: '' });
const { id } = toRefs(state);
@@ -41,7 +42,11 @@
      label: '签到规则',
      component: 'Select',
      defaultValue: 'both',
      componentProps: { placeholder: '请选择', options: SIGN_RULE_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -49,7 +54,11 @@
      label: '允许名单外签到',
      component: 'Select',
      defaultValue: '0',
      componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -57,7 +66,11 @@
      label: '起止须同一天',
      component: 'Select',
      defaultValue: '0',
      componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -65,7 +78,11 @@
      label: '地点必填',
      component: 'Select',
      defaultValue: '1',
      componentProps: { placeholder: '请选择', options: YES_NO_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -80,7 +97,11 @@
      label: '启用状态',
      component: 'Select',
      defaultValue: '1',
      componentProps: { placeholder: '请选择', options: ENABLE_OPTIONS },
      componentProps: {
        placeholder: '请选择',
        options: [],
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
      rules: [{ required: true, message: '必填', trigger: 'change' }],
    },
    {
@@ -93,6 +114,56 @@
});
const [registerModal, { closeModal, changeLoading, changeOkLoading }] = useModalInner(init);
async function loadDicOptions() {
  const [signRule, yesNo, enable] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.signRule),
    loadTmsDic(baseStore, TMS_DIC.yesNo),
    loadTmsDic(baseStore, TMS_DIC.enableStatus),
  ]);
  updateSchema([
    {
      field: 'signRule',
      componentProps: {
        placeholder: '请选择',
        options: signRule,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'allowGuestSign',
      componentProps: {
        placeholder: '请选择',
        options: yesNo,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'sameDayRequired',
      componentProps: {
        placeholder: '请选择',
        options: yesNo,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'placeRequired',
      componentProps: {
        placeholder: '请选择',
        options: yesNo,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
    {
      field: 'enabled',
      componentProps: {
        placeholder: '请选择',
        options: enable,
        fieldNames: TMS_DIC_FIELD_NAMES,
      },
    },
  ]);
}
async function init(data: { id?: string }) {
  changeLoading(true);
@@ -108,9 +179,9 @@
    },
  });
  try {
    await loadDicOptions();
    if (state.id) {
      const info = await getTrainModeInfo(state.id);
      setFieldsValue(info);
      setFieldsValue(await getTrainModeInfo(state.id));
    }
  } finally {
    changeLoading(false);
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/constants.ts
File was deleted
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/index.vue
@@ -3,24 +3,33 @@
import type { TrainModeItem } from './types';
import { onMounted, ref } from 'vue';
import { useMessage } from '@jnpf/hooks';
import { useModal } from '@jnpf/ui/modal';
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { deleteTrainMode, getTrainModeList, setTrainModeEnabled } from '#/api/x/tms/trainMode';
import { useBaseStore } from '#/store';
import Form from './Form.vue';
import {
  ENABLE_OPTIONS,
  labelOfEnabled,
  labelOfSignRule,
  labelOfYesNo,
} from './constants';
  TMS_DIC,
  TMS_DIC_FIELD_NAMES,
  labelOfDic,
  loadTmsDic,
  type TmsDicOpt,
} from '#/views/x/tms/shared/dic';
defineOptions({ name: 'TmsTrainMode' });
const { createMessage } = useMessage();
const baseStore = useBaseStore();
const [registerForm, { openModal: openFormModal }] = useModal();
const signRuleOpts = ref<TmsDicOpt[]>([]);
const yesNoOpts = ref<TmsDicOpt[]>([]);
const enableOpts = ref<TmsDicOpt[]>([]);
const columns: BasicColumn[] = [
  { title: '方式编码', dataIndex: 'modeCode', width: 120 },
@@ -29,28 +38,28 @@
    title: '签到规则',
    dataIndex: 'signRule',
    width: 120,
    customRender: ({ record }) => labelOfSignRule((record as TrainModeItem).signRule),
    customRender: ({ record }) => labelOfDic(signRuleOpts.value, (record as TrainModeItem).signRule),
  },
  {
    title: '允许名单外签到',
    dataIndex: 'allowGuestSign',
    width: 130,
    align: 'center',
    customRender: ({ record }) => labelOfYesNo((record as TrainModeItem).allowGuestSign),
    customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as TrainModeItem).allowGuestSign),
  },
  {
    title: '起止须同一天',
    dataIndex: 'sameDayRequired',
    width: 120,
    align: 'center',
    customRender: ({ record }) => labelOfYesNo((record as TrainModeItem).sameDayRequired),
    customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as TrainModeItem).sameDayRequired),
  },
  {
    title: '地点必填',
    dataIndex: 'placeRequired',
    width: 100,
    align: 'center',
    customRender: ({ record }) => labelOfYesNo((record as TrainModeItem).placeRequired),
    customRender: ({ record }) => labelOfDic(yesNoOpts.value, (record as TrainModeItem).placeRequired),
  },
  { title: '排序', dataIndex: 'sortNo', width: 80, align: 'center' },
  {
@@ -63,10 +72,10 @@
  { title: '备注', dataIndex: 'remark', minWidth: 180 },
];
const [registerTable, { reload }] = useVxeTable({
const [registerTable, { reload, getForm }] = useVxeTable({
  api: fetchList,
  columns,
  immediate: true,
  immediate: false,
  rowKey: 'id',
  useSearchForm: true,
  formConfig: {
@@ -86,7 +95,8 @@
        componentProps: {
          allowClear: true,
          placeholder: '请选择',
          options: ENABLE_OPTIONS,
          options: enableOpts.value,
          fieldNames: TMS_DIC_FIELD_NAMES,
        },
      },
    ],
@@ -97,6 +107,32 @@
    dataIndex: 'action',
    fixed: 'right',
  },
});
async function loadDic() {
  const [signRule, yesNo, enable] = await Promise.all([
    loadTmsDic(baseStore, TMS_DIC.signRule),
    loadTmsDic(baseStore, TMS_DIC.yesNo),
    loadTmsDic(baseStore, TMS_DIC.enableStatus),
  ]);
  signRuleOpts.value = signRule;
  yesNoOpts.value = yesNo;
  enableOpts.value = enable;
  getForm()?.updateSchema?.({
    field: 'enabled',
    componentProps: {
      allowClear: true,
      placeholder: '请选择',
      options: enableOpts.value,
      fieldNames: TMS_DIC_FIELD_NAMES,
    },
  });
}
onMounted(async () => {
  await loadDic();
  reload();
});
async function fetchList(params: Record<string, any>) {
@@ -124,6 +160,10 @@
}
async function handleDelete(record: TrainModeItem) {
  if (record.enabled !== '0') {
    createMessage.warning('请先停用后再删除');
    return;
  }
  try {
    await deleteTrainMode(record.id);
    createMessage.success('删除成功');
@@ -135,7 +175,7 @@
function getTableActions(record: TrainModeItem): ActionItem[] {
  const enableLabel = record.enabled === '1' ? '停用' : '启用';
  return [
  const actions: ActionItem[] = [
    { label: '编辑', onClick: handleEdit.bind(null, record) },
    {
      label: enableLabel,
@@ -144,15 +184,18 @@
        onOk: handleToggleEnabled.bind(null, record),
      },
    },
    {
  ];
  if (record.enabled === '0') {
    actions.push({
      label: '删除',
      color: 'error',
      modelConfirm: {
        content: `确定删除培训方式「${record.modeName}」吗?`,
        onOk: handleDelete.bind(null, record),
      },
    },
  ];
    });
  }
  return actions;
}
</script>
@@ -166,7 +209,7 @@
          </template>
          <template #enabled="{ record }">
            <a-tag :color="record.enabled === '1' ? 'success' : 'default'">
              {{ labelOfEnabled(record.enabled) }}
              {{ labelOfDic(enableOpts, record.enabled) }}
            </a-tag>
          </template>
          <template #action="{ record }">
apps/jnpf-web-apps-main/src/views/x/tms/trainMode/mock.ts
@@ -111,6 +111,7 @@
  await delay(null);
  const idx = store.findIndex((x) => x.id === id);
  if (idx < 0) throw new Error('培训方式不存在');
  if (store[idx]!.enabled !== '0') throw new Error('请先停用后再删除');
  store.splice(idx, 1);
  return true;
}
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/Catalog.vue
@@ -9,6 +9,7 @@
import dayjs from 'dayjs';
import { getTrainRecordCatalog } from '#/api/x/tms/trainRecord';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
defineOptions({ name: 'TmsTrainRecordCatalog' });
@@ -84,7 +85,39 @@
    createMessage.warning('暂无数据可导出');
    return;
  }
  createMessage.success(`已准备导出 ${list.length} 条(导出接口联调后生效)`);
  const headers = ['培训记录编号', '培训类型', '培训内容', '培训方式', '培训师', '考核方式', '培训结果', '是否合格', '具体培训时间'];
  const escape = (v: unknown) => {
    const s = v === null || v === undefined ? '' : String(v);
    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };
  const passText = (v: string) => (v === '1' ? '合格' : v === '0' ? '不合格' : '');
  const lines = [
    headers.join(','),
    ...list.map((row) =>
      [
        row.recordNo,
        row.trainType,
        row.trainContent,
        row.trainMode,
        row.trainerName,
        row.evalMode,
        resultText(row),
        passText(row.passFlag),
        row.trainDate || '',
      ]
        .map(escape)
        .join(','),
    ),
  ];
  const bom = '\uFEFF';
  const blob = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `培训目录_${detail.value?.userName || detail.value?.archiveNo || 'export'}_${dayjs().format('YYYYMMDD')}.csv`;
  a.click();
  URL.revokeObjectURL(url);
  createMessage.success(`已导出 ${list.length} 条`);
}
function goBack() {
@@ -119,12 +152,12 @@
                  :placeholder="['开始日期', '结束日期']"
                  :get-popup-container="popupContainer"
                />
                <a-button type="primary" class="ml-3" @click="handleSearch">查询</a-button>
                <a-button class="ml-2" @click="handleReset">重置</a-button>
                <a-button type="primary" class="ml-3" @click="handleSearch">{{ TMS_BTN.search }}</a-button>
                <a-button class="ml-2" @click="handleReset">{{ TMS_BTN.reset }}</a-button>
              </div>
              <a-space>
                <a-button @click="handleExport">导出</a-button>
                <a-button @click="goBack">关闭</a-button>
                <a-button type="primary" ghost @click="handleExport">{{ TMS_BTN.export }}</a-button>
                <a-button @click="goBack">{{ TMS_BTN.close }}</a-button>
              </a-space>
            </div>
apps/jnpf-web-apps-main/src/views/x/tms/trainRecord/index.vue
@@ -9,6 +9,9 @@
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
import { getTrainRecordList } from '#/api/x/tms/trainRecord';
import { TMS_BTN } from '#/views/x/tms/shared/ui';
import '#/views/x/tms/shared/page.css';
defineOptions({ name: 'TmsTrainRecord' });
@@ -84,7 +87,7 @@
}
function getTableActions(record: TrainRecordListItem): ActionItem[] {
  return [{ label: '查看', onClick: handleView.bind(null, record) }];
  return [{ label: TMS_BTN.detail, onClick: handleView.bind(null, record) }];
}
</script>
@@ -95,8 +98,8 @@
        <BasicVxeTable @register="registerTable">
          <template #tableTitle>
            <a-space>
              <a-button type="primary" @click="handleViewSelected">查看</a-button>
              <span class="text-gray-400 text-sm">个人培训记录表,选择人员查看培训目录。</span>
              <a-button type="primary" @click="handleViewSelected">{{ TMS_BTN.detail }}</a-button>
              <span class="tms-toolbar-hint">选择人员查看培训目录</span>
            </a-space>
          </template>
          <template #action="{ record }">