<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>
|