From 8534025c45b4736975730678b9ccf23570a75f95 Mon Sep 17 00:00:00 2001
From: liuyu <yu.liu@cqgbkj.com>
Date: 星期二, 22 九月 2026 09:25:48 +0800
Subject: [PATCH] feat(tms): 新增试卷、培训任务、个人任务页面
---
apps/jnpf-web-apps-main/src/views/x/tms/personTask/Learn.vue | 410 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 410 insertions(+), 0 deletions(-)
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/personTask/Learn.vue b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/Learn.vue
new file mode 100644
index 0000000..eebe494
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/Learn.vue
@@ -0,0 +1,410 @@
+<script lang="ts" setup>
+import type { PersonTaskFile, PersonTaskItem } from './types';
+
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { useMessage } from '@jnpf/hooks';
+
+import { startOnlineExam } from '#/api/x/tms/onlineExam';
+import { getPersonTaskInfo, markPersonTaskLearned, signPersonTask } from '#/api/x/tms/personTask';
+import { getAuthMediaUrl } from '#/utils/jnpf';
+import { TMS_BTN } from '#/views/x/tms/shared/ui';
+
+import '#/views/x/tms/shared/page.css';
+
+defineOptions({ name: 'TmsPersonTaskLearn' });
+
+const route = useRoute();
+const router = useRouter();
+const { createMessage } = useMessage();
+
+const loading = ref(false);
+const acting = ref(false);
+const signing = ref(false);
+const detail = ref<PersonTaskItem | null>(null);
+const activeFileId = ref<string>();
+const sessionSeconds = ref(0);
+const notifiedFull = ref(false);
+
+let learnTimer: ReturnType<typeof setInterval> | null = null;
+let flushTimer: ReturnType<typeof setInterval> | null = null;
+let pendingSeconds = 0;
+
+const files = computed(() => detail.value?.files || []);
+const activeFile = computed(() => files.value.find((f) => f.id === activeFileId.value) || files.value[0]);
+const previewUrl = computed(() => toFullUrl(activeFile.value?.url));
+const progressText = computed(() => {
+ const learned = detail.value?.learnedSeconds || 0;
+ const required = detail.value?.requiredSeconds || 0;
+ return `${formatDuration(learned)} / ${required > 0 ? formatDuration(required) : '鏈厤缃�'}`;
+});
+const progressPercent = computed(() => {
+ const required = detail.value?.requiredSeconds || 0;
+ if (required <= 0) return 0;
+ return Math.min(100, Math.round(((detail.value?.learnedSeconds || 0) / required) * 100));
+});
+const signStatusText = computed(() => {
+ if (detail.value?.signed || detail.value?.signTime) {
+ return `宸茬鍒�${detail.value.signTime ? `锛�${detail.value.signTime}锛塦 : ''}`;
+ }
+ return detail.value?.signTip || '鏈鍒�';
+});
+const showSignButton = computed(() => Boolean(detail.value?.canSign));
+
+onMounted(async () => {
+ await loadData();
+ startTimer();
+});
+
+onBeforeUnmount(() => {
+ stopTimer(true);
+});
+
+watch(
+ () => files.value,
+ (list) => {
+ if (!list.length) return;
+ if (!activeFileId.value || !list.some((f) => f.id === activeFileId.value)) {
+ activeFileId.value = list[0]?.id;
+ }
+ },
+ { immediate: true },
+);
+
+async function loadData() {
+ const id = String(route.params.id || '');
+ if (!id) {
+ router.replace('/tms/personTask');
+ return;
+ }
+ loading.value = true;
+ try {
+ detail.value = await getPersonTaskInfo(id);
+ if ((detail.value.learnedSeconds || 0) >= (detail.value.requiredSeconds || 0) && (detail.value.requiredSeconds || 0) > 0) {
+ notifiedFull.value = true;
+ }
+ } catch (e: any) {
+ createMessage.error(e?.message || '鍔犺浇澶辫触');
+ router.replace('/tms/personTask');
+ } finally {
+ loading.value = false;
+ }
+}
+
+function goBack() {
+ stopTimer(true).finally(() => {
+ router.push('/tms/personTask');
+ });
+}
+
+function selectFile(file: PersonTaskFile) {
+ activeFileId.value = file.id;
+}
+
+function toFullUrl(url?: string) {
+ if (!url) return '';
+ const apiIndex = url.indexOf('/api/');
+ const path = /^https?:\/\//i.test(url) && apiIndex >= 0 ? url.slice(apiIndex) : url;
+ // t=t 璁╂枃浠舵湇鍔$洿鎺ヨ緭鍑哄唴瀹癸紝鍚﹀垯浼� 302锛宨mg/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>
--
Gitblit v1.8.0