<script lang="ts" setup>
|
import type {
|
TmsQuizAnswerPaper,
|
TmsRecordAttachment,
|
TmsRecordDetail,
|
TmsRecordIncomplete,
|
TmsRecordParticipant,
|
TmsRecordProgress,
|
} 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,
|
Modal as AModal,
|
} from 'ant-design-vue';
|
|
import {
|
getTmsRecordDetail,
|
getTmsRecordIncomplete,
|
getTmsRecordProgress,
|
getTmsRecordQuizAnswers,
|
getTmsRecordQuizCount,
|
} from '#/api/x/tms/record';
|
import { TmsAttachmentPreview } from '#/views/x/tms/shared/attachmentPreview';
|
import QuizPaperView from '#/views/x/tms/shared/QuizPaperView.vue';
|
import { TMS_BTN } from '#/views/x/tms/shared/ui';
|
|
import {
|
colorOfArchiveStatus,
|
labelOfArchiveStatus,
|
labelOfCategory,
|
labelOfEvalMode,
|
labelOfPassFlag,
|
labelOfTrainMode,
|
loadRecordDics,
|
} from './constants';
|
|
import '#/views/x/tms/shared/page.css';
|
|
defineOptions({ name: 'TmsRecordDetail' });
|
|
const route = useRoute();
|
const router = useRouter();
|
const { createMessage } = useMessage();
|
|
const loading = ref(false);
|
const detail = ref<TmsRecordDetail | null>(null);
|
const progress = ref<TmsRecordProgress | null>(null);
|
const incompleteList = ref<TmsRecordIncomplete[]>([]);
|
const quizCount = ref<number | null>(null);
|
const previewOpen = ref(false);
|
const previewFileId = ref<string>();
|
const quizPaperOpen = ref(false);
|
const quizPaperLoading = ref(false);
|
const quizPaper = ref<TmsQuizAnswerPaper | null>(null);
|
|
const participantColumns = computed(() => {
|
const cols: Array<{ title: string; dataIndex: string; width?: number }> = [
|
{ title: '姓名', dataIndex: 'userName', width: 120 },
|
{ title: '部门', dataIndex: 'deptName', width: 160 },
|
{ title: '签到日期', dataIndex: 'signDate', width: 160 },
|
];
|
if (detail.value?.evalMode === 'quiz') {
|
cols.push({ title: '提问成绩', dataIndex: 'quizScore', width: 100 });
|
} else if (detail.value?.evalMode === 'exam') {
|
cols.push({ title: '考试成绩', dataIndex: 'examScore', width: 100 });
|
} else {
|
cols.push({ title: '考试成绩', dataIndex: 'examScore', width: 100 });
|
}
|
cols.push({ title: '是否合格', dataIndex: 'passFlag', width: 90 });
|
if (detail.value?.evalMode === 'quiz') {
|
cols.push({ title: '操作', dataIndex: 'action', width: 110 });
|
}
|
return cols;
|
});
|
|
const incompleteColumns = [
|
{ title: '部门', dataIndex: 'deptName', width: 180 },
|
{ title: '姓名', dataIndex: 'userName', width: 140 },
|
{ title: '未完成原因', dataIndex: 'reason', width: 160 },
|
{ title: '签到时间', dataIndex: 'signTime', width: 170 },
|
{ title: '合格标识', dataIndex: 'passFlag', width: 100 },
|
];
|
|
const showQuizHint = computed(() => detail.value?.evalMode === 'quiz');
|
const showPracticeEntry = computed(() => detail.value?.evalMode === 'practice');
|
const canRefresh = computed(() => Boolean(detail.value?.id));
|
|
onMounted(async () => {
|
await loadRecordDics();
|
await loadData();
|
});
|
|
async function loadData() {
|
const id = String(route.params.id || '');
|
if (!id) {
|
router.replace('/tms/record');
|
return;
|
}
|
loading.value = true;
|
try {
|
detail.value = await getTmsRecordDetail(id);
|
await loadProgress(id);
|
} catch (e: any) {
|
createMessage.error(e?.message || '加载培训记录失败');
|
router.replace('/tms/record');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function loadProgress(id?: string) {
|
const recordId = id || String(detail.value?.id || route.params.id || '');
|
if (!recordId) return;
|
const [p, incomplete, qc] = await Promise.all([
|
getTmsRecordProgress(recordId).catch(() => null),
|
getTmsRecordIncomplete(recordId).catch(() => [] as TmsRecordIncomplete[]),
|
getTmsRecordQuizCount(recordId).catch(() => null),
|
]);
|
progress.value = p;
|
incompleteList.value = Array.isArray(incomplete) ? incomplete : [];
|
quizCount.value = typeof qc === 'number' ? qc : null;
|
}
|
|
function goBack() {
|
const from = String(route.query.from || '');
|
if (from === 'archived') {
|
router.push('/tms/record/archived');
|
return;
|
}
|
if (from === 'ready') {
|
router.push('/tms/record/ready');
|
return;
|
}
|
if (from === 'main') {
|
router.push('/tms/record');
|
return;
|
}
|
router.back();
|
}
|
|
function goPractice() {
|
const id = String(detail.value?.id || route.params.id || '');
|
if (!id) return;
|
router.push(`/tms/record/practice/${id}`);
|
}
|
|
function goSign() {
|
const id = String(detail.value?.id || route.params.id || '');
|
if (!id) return;
|
router.push(`/tms/record/sign/${id}`);
|
}
|
|
async function handleRefreshProgress() {
|
loading.value = true;
|
try {
|
await loadProgress();
|
createMessage.success('进度已刷新');
|
} catch (e: any) {
|
createMessage.error(e?.message || '刷新失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function passText(row: TmsRecordParticipant | TmsRecordIncomplete) {
|
const text = labelOfPassFlag(row.passFlag);
|
return text === '-' ? '' : text;
|
}
|
|
function rateText(v?: number | null) {
|
if (v == null || Number.isNaN(Number(v))) return '-';
|
return `${Number(v)}%`;
|
}
|
|
function openPreview(file?: TmsRecordAttachment) {
|
if (!detail.value?.attachments?.length) {
|
createMessage.info('暂无可预览附件');
|
return;
|
}
|
previewFileId.value = file?.id || detail.value.attachments[0]?.id;
|
previewOpen.value = true;
|
}
|
|
async function openQuizPaper(row: TmsRecordParticipant) {
|
const recordId = String(detail.value?.id || route.params.id || '');
|
const userId = String(row.userId || '');
|
if (!recordId || !userId) {
|
createMessage.warning('缺少学员信息');
|
return;
|
}
|
quizPaperLoading.value = true;
|
quizPaperOpen.value = true;
|
quizPaper.value = null;
|
try {
|
quizPaper.value = await getTmsRecordQuizAnswers(recordId, userId);
|
} catch (e: any) {
|
quizPaperOpen.value = false;
|
createMessage.error(e?.message || '加载提问答卷失败');
|
} finally {
|
quizPaperLoading.value = false;
|
}
|
}
|
</script>
|
|
<template>
|
<div class="jnpf-content-wrapper tms-record-detail-page" v-loading="loading">
|
<div class="jnpf-content-wrapper-center">
|
<div class="jnpf-content-wrapper-content tms-record-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 v-if="canRefresh" @click="handleRefreshProgress">刷新进度</a-button>
|
<a-button
|
v-if="detail.archiveStatus !== 'archived'"
|
@click="goSign"
|
>
|
{{ TMS_BTN.signQr }}
|
</a-button>
|
<a-button v-if="showPracticeEntry" type="primary" @click="goPractice">
|
实操打分
|
</a-button>
|
</a-space>
|
</template>
|
|
<a-divider orientation="left">参与进度(实时)</a-divider>
|
<ADescriptions v-if="progress" :column="4" bordered size="small" class="mb-desc">
|
<ADescriptionsItem label="应到人数">{{ progress.totalCount ?? 0 }}</ADescriptionsItem>
|
<ADescriptionsItem label="已签到">{{ progress.signedCount ?? 0 }}</ADescriptionsItem>
|
<ADescriptionsItem label="合格人数">{{ progress.passedCount ?? 0 }}</ADescriptionsItem>
|
<ADescriptionsItem label="未完成人数">{{ progress.incompleteCount ?? 0 }}</ADescriptionsItem>
|
<ADescriptionsItem label="签到率">{{ rateText(progress.signRate) }}</ADescriptionsItem>
|
<ADescriptionsItem label="合格率">{{ rateText(progress.passRate) }}</ADescriptionsItem>
|
<ADescriptionsItem label="未完成率">{{ rateText(progress.incompleteRate) }}</ADescriptionsItem>
|
<ADescriptionsItem label="考核方式">{{ labelOfEvalMode(progress.evalMode || detail.evalMode) }}</ADescriptionsItem>
|
<ADescriptionsItem v-if="showQuizHint" label="应提问人数" :span="4">
|
{{ quizCount == null ? '-' : quizCount }}
|
</ADescriptionsItem>
|
</ADescriptions>
|
<a-empty v-else description="暂无进度数据" />
|
|
<a-divider orientation="left">未完成人员(按部门排序)</a-divider>
|
<a-table
|
size="small"
|
bordered
|
row-key="personTaskId"
|
:pagination="false"
|
:columns="incompleteColumns"
|
:data-source="incompleteList"
|
:locale="{ emptyText: '暂无未完成人员' }"
|
>
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.dataIndex === 'passFlag'">
|
{{ passText(record) }}
|
</template>
|
</template>
|
</a-table>
|
|
<a-divider orientation="left">基本信息</a-divider>
|
<ADescriptions :column="2" bordered size="small">
|
<ADescriptionsItem label="培训编号">{{ detail.recordNo }}</ADescriptionsItem>
|
<ADescriptionsItem label="归档状态">
|
<a-tag :color="colorOfArchiveStatus(detail.archiveStatus)">
|
{{ labelOfArchiveStatus(detail.archiveStatus) }}
|
</a-tag>
|
</ADescriptionsItem>
|
<ADescriptionsItem label="任务关闭时间">{{ detail.closeTime || '-' }}</ADescriptionsItem>
|
<ADescriptionsItem label="培训主题" :span="2">
|
<div class="pre-line">{{ detail.subject }}</div>
|
</ADescriptionsItem>
|
<ADescriptionsItem label="培训要点" :span="2">
|
<div class="pre-line">{{ detail.keyPoints || '-' }}</div>
|
</ADescriptionsItem>
|
<ADescriptionsItem label="培训开始时间">{{ detail.startTime }}</ADescriptionsItem>
|
<ADescriptionsItem label="培训结束时间">{{ detail.endTime || '-' }}</ADescriptionsItem>
|
<ADescriptionsItem label="培训地点" :span="2">{{ detail.placeName || '-' }}</ADescriptionsItem>
|
<ADescriptionsItem label="培训分类">{{ labelOfCategory(detail.category) }}</ADescriptionsItem>
|
<ADescriptionsItem label="培训方式">{{ labelOfTrainMode(detail.trainMode || detail.trainType) }}</ADescriptionsItem>
|
<ADescriptionsItem label="培训师">{{ detail.trainerName }}</ADescriptionsItem>
|
<ADescriptionsItem label="培训考核方式">{{ labelOfEvalMode(detail.evalMode) }}</ADescriptionsItem>
|
<ADescriptionsItem label="培训对象" :span="2">{{ detail.trainees || '-' }}</ADescriptionsItem>
|
<ADescriptionsItem label="附件" :span="2">
|
<div v-if="detail.attachments?.length" class="attach-list">
|
<div v-for="file in detail.attachments" :key="file.id" class="attach-item">
|
<span>{{ file.name }}</span>
|
<a class="attach-link" @click.prevent="openPreview(file)">预览</a>
|
</div>
|
</div>
|
<span v-else>-</span>
|
</ADescriptionsItem>
|
<ADescriptionsItem label="备注" :span="2">{{ detail.remark || '-' }}</ADescriptionsItem>
|
</ADescriptions>
|
|
<a-divider orientation="left">参加人员名单</a-divider>
|
<a-table
|
size="small"
|
bordered
|
row-key="id"
|
:pagination="false"
|
:columns="participantColumns"
|
:data-source="detail.participants || []"
|
:locale="{ emptyText: '暂无参加人员' }"
|
>
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.dataIndex === 'passFlag'">
|
{{ passText(record) }}
|
</template>
|
<template v-else-if="column.dataIndex === 'quizScore'">
|
{{ record.quizScore ?? (record.hasQuizAnswer ? '-' : '未作答') }}
|
</template>
|
<template v-else-if="column.dataIndex === 'action'">
|
<a
|
v-if="record.hasQuizAnswer && record.userId"
|
class="attach-link"
|
@click.prevent="openQuizPaper(record)"
|
>
|
查看答卷
|
</a>
|
<span v-else class="text-muted">-</span>
|
</template>
|
</template>
|
</a-table>
|
</a-card>
|
</div>
|
</div>
|
|
<AModal
|
v-model:open="quizPaperOpen"
|
:title="`提问答卷${quizPaper?.userName ? ` · ${quizPaper.userName}` : ''}`"
|
width="800px"
|
:footer="null"
|
:destroy-on-close="true"
|
wrap-class-name="tms-quiz-paper-modal"
|
>
|
<QuizPaperView :paper="quizPaper" :loading="quizPaperLoading" answer-label="学员答案" />
|
</AModal>
|
|
<AModal
|
v-model:open="previewOpen"
|
title="附件预览"
|
width="90%"
|
:footer="null"
|
:destroy-on-close="true"
|
wrap-class-name="tms-attach-preview-modal"
|
>
|
<div class="preview-modal-body">
|
<TmsAttachmentPreview
|
v-model="previewFileId"
|
:files="detail?.attachments || []"
|
list-title="附件列表"
|
empty-preview-text="请选择附件预览"
|
/>
|
</div>
|
</AModal>
|
</div>
|
</template>
|
|
<style scoped>
|
.tms-record-detail-page {
|
height: 100%;
|
min-height: 0;
|
}
|
|
.tms-record-detail-wrap {
|
height: 100%;
|
min-height: 0;
|
overflow-x: hidden;
|
overflow-y: auto !important;
|
background: #fff;
|
box-sizing: border-box;
|
}
|
|
.mb-desc {
|
margin-bottom: 8px;
|
}
|
|
.pre-line {
|
white-space: pre-wrap;
|
line-height: 1.5;
|
}
|
|
.attach-list {
|
display: flex;
|
flex-direction: column;
|
gap: 6px;
|
}
|
|
.attach-item {
|
display: flex;
|
align-items: flex-start;
|
gap: 12px;
|
}
|
|
.text-muted {
|
color: rgba(0, 0, 0, 0.45);
|
}
|
|
.attach-link {
|
flex-shrink: 0;
|
color: var(--primary-color, #1677ff);
|
cursor: pointer;
|
}
|
|
.attach-link:hover {
|
text-decoration: underline;
|
}
|
|
.preview-modal-body {
|
height: min(72vh, 720px);
|
min-height: 420px;
|
display: flex;
|
flex-direction: column;
|
overflow: hidden;
|
}
|
|
.preview-modal-body :deep(.tms-attach-preview) {
|
flex: 1 1 auto;
|
min-height: 0;
|
height: 100%;
|
}
|
</style>
|
|
<style>
|
.tms-attach-preview-modal .ant-modal-body {
|
padding: 12px 16px 16px;
|
}
|
|
.tms-quiz-paper-modal .ant-modal-body {
|
padding: 20px 24px 24px;
|
}
|
</style>
|