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