feat(tms): 新增试卷、培训任务、个人任务页面
Co-authored-by: Cursor <cursoragent@cursor.com>
| New file |
| | |
| | | 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 } })); |
| | | } |
| New file |
| | |
| | | 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` })); |
| | | } |
| New file |
| | |
| | | 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` })); |
| | | } |
| New file |
| | |
| | | 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` })); |
| | | } |
| | |
| | | }, |
| | | }, |
| | | { |
| | | 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'), |
| | |
| | | }, |
| | | }, |
| | | { |
| | | 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', |
| | |
| | | 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', |
| | |
| | | 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', |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | { |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | <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(/ /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> |
| New file |
| | |
| | | <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(/ /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> |
| New file |
| | |
| | | <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(/ /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> |
| New file |
| | |
| | | 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}`; |
| | | } |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | /** 试卷状态(字典 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; |
| | | } |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | 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'; |
| | | } |
| | | } |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | 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; |