Selaa lähdekoodia

feat: 添加当前用户培训信息和学习详情查询功能,优化培训计划详情展示

xieyong 1 viikko sitten
vanhempi
commit
359b33aacd

+ 24 - 0
src/api/onlineTrainingPlan/index.js

@@ -11,6 +11,19 @@ export async function getPage(params) {
   return Promise.reject(new Error(res.data.message));
 }
 
+/**
+ * 分页查询当前用户培训信息(我的培训)
+ */
+export async function getMyPage(params) {
+  const res = await request.get('/ehs/onlineTrainingPlan/myPage', {
+    params
+  });
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
 /**
  * 查询线上培训计划详情
  */
@@ -81,6 +94,17 @@ export async function getMaterialProgress(params) {
   return Promise.reject(new Error(res.data.message));
 }
 
+/**
+ * 查询当前用户培训任务学习详情(含资料及各资料学习进度)
+ */
+export async function getLearnDetail(taskId) {
+  const res = await request.get(`/ehs/onlineTrainingPlan/learn/${taskId}`);
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
 /**
  * 修改当前用户学习进度
  */

+ 584 - 0
src/views/safetyTraining/components/startLearning.vue

@@ -0,0 +1,584 @@
+<template>
+  <ele-modal
+    :visible.sync="visible"
+    title="开始学习"
+    width="90vw"
+    :fullscreen="true"
+    append-to-body
+    :close-on-click-modal="false"
+    :maxable="true"
+    @close="cancel"
+  >
+    <div v-loading="loading" class="preview-container">
+      <!-- 左侧树形 -->
+      <div class="left-tree">
+        <div class="tree-header">{{ treeTitle }}</div>
+        <div class="tree-meta">
+          <span>总时长:{{ totalMinutes }} 分钟</span>
+          <span>已学时长:{{ totalLearnedMinutes }} 分钟</span>
+        </div>
+        <el-tree
+          ref="treeRef"
+          :data="treeData"
+          :props="defaultProps"
+          node-key="id"
+          default-expand-all
+          highlight-current
+          @node-click="handleNodeClick"
+        >
+          <span slot-scope="{ node, data }" class="custom-tree-node">
+            <span :class="{ 'not-learned': data.isLeaf }">{{
+              node.label
+            }}</span>
+            <span v-if="data.isMaterial" class="node-meta">
+              应学 {{ data.requiredMinutes }} 分钟 / 已学
+              {{ data.completedMinutes }} 分钟
+            </span>
+            <span v-else-if="data.durationMinutes" class="node-duration">{{
+              data.duration
+            }}</span>
+          </span>
+        </el-tree>
+      </div>
+
+      <!-- 右侧内容 -->
+      <div class="right-content">
+        <div class="content-title">{{ currentTitle }}</div>
+        <div class="content-meta">
+          <span>本节时长:{{ currentDuration }} 分钟</span>
+          <span>已学时长:{{ learnedMinutes }} 分钟</span>
+          <span>剩余时长:{{ remainMinutes }} 分钟</span>
+        </div>
+        <div class="iframe-wrapper">
+          <!-- 附件预览:按 视频 / 图片 / 文档 分别渲染,学习中不提供新窗口打开与下载 -->
+          <file-preview
+            ref="filePreviewRef"
+            :ids="currentFileIds"
+            :show-actions="false"
+            class="preview-files"
+            @play="handleMediaPlay"
+            @pause="handleMediaPause"
+          />
+
+          <!-- 开始学习遮罩 -->
+          <div v-if="showOverlay" class="start-overlay">
+            <el-button type="primary" @click="handleStartLearn">
+              开始学习
+            </el-button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <template v-slot:footer>
+      <el-button @click="cancel">关闭</el-button>
+    </template>
+  </ele-modal>
+</template>
+
+<script>
+  import FilePreview from '@/components/FilePreview/index.vue';
+  import { learn, getLearnDetail } from '@/api/onlineTrainingPlan/index.js';
+
+  export default {
+    components: {
+      FilePreview
+    },
+    data() {
+      return {
+        visible: false,
+        treeTitle: '',
+        currentTitle: '',
+        // 当前选中培训资源的附件id集合
+        currentFileIds: [],
+        showOverlay: true,
+        defaultProps: {
+          children: 'children',
+          label: 'label'
+        },
+        treeData: [],
+        // 培训任务ID
+        currentTaskId: null,
+        // 当前学习的培训资料ID(learn接口必填)
+        currentMaterialId: null,
+        // 学习详情加载中
+        loading: false,
+        // 每分钟上报学习进度的定时器
+        learnTimer: null,
+        // 学习倒计时:总时长(秒) / 剩余时长(秒)
+        totalSeconds: 0,
+        // 应学总时长(秒),所有资料应学时长之和,用于判断是否完成
+        totalRequiredSeconds: 0,
+        // 是否已提示过学习完成
+        completedNotified: false,
+        remainingSeconds: 0,
+        // 倒计时定时器
+        countdownTimer: null,
+        // 总时长(分钟),所有资料应学时长之和
+        totalMinutesValue: 0,
+        // 总已学时长(秒),所有资料已完成时长之和
+        totalCompletedSeconds: 0,
+        // 当前选中资料的已学时长(秒)
+        currentCompletedSeconds: 0,
+        // 当前选中节点的时长(分钟),取自 suggestDuration
+        currentDuration: 0
+      };
+    },
+    computed: {
+      // 总时长(分钟)
+      totalMinutes() {
+        return this.totalMinutesValue;
+      },
+      // 总已学时长(分钟),所有资料已完成时长之和
+      totalLearnedMinutes() {
+        return this.toMinutes(this.totalCompletedSeconds);
+      },
+      // 当前选中资料的已学时长(分钟)
+      learnedMinutes() {
+        return this.toMinutes(this.currentCompletedSeconds);
+      },
+      // 剩余时长(分钟),本节时长减已学时长,最小为0
+      remainMinutes() {
+        return Math.max(this.currentDuration - this.learnedMinutes, 0);
+      }
+    },
+    mounted() {
+      document.addEventListener('visibilitychange', this.handleVisibilityChange);
+    },
+    beforeDestroy() {
+      document.removeEventListener(
+        'visibilitychange',
+        this.handleVisibilityChange
+      );
+      this.stopLearnTimer();
+      this.stopCountdown();
+    },
+    methods: {
+      async open(row) {
+        this.visible = true;
+        this.currentTaskId = row && row.id ? row.id : null;
+        this.loading = true;
+        let detail = row || {};
+        // 优先取学习详情(含资料及各资料学习进度),失败时回退到行数据
+        if (this.currentTaskId) {
+          try {
+            detail = await getLearnDetail(this.currentTaskId);
+          } catch (error) {
+            this.$message.error(error.message || '获取学习详情失败');
+          }
+        }
+        this.buildTree(detail);
+        this.loading = false;
+      },
+      // 资料节点展示应学/已学时长,资源节点展示建议时长
+      buildTree(detail) {
+        const materialList = detail.materialList || [];
+        const treeData = materialList.map((material, mIndex) => {
+          const resources = material.trainingResourceList || [];
+          const children = resources.map((item, index) => ({
+            id: item.id || `material${mIndex}-resource${index}`,
+            label: item.resourceName || `培训资源${index + 1}`,
+            duration: item.suggestDuration
+              ? `总时长: ${item.suggestDuration} 分钟`
+              : '',
+            durationMinutes: Number(item.suggestDuration) || 0,
+            // 资源继承所属资料的ID,上报学习进度时使用
+            materialId: material.materialId || null,
+            attachment: this.parseAttachment(item.attachment),
+            isLeaf: true
+          }));
+          return {
+            id: material.taskMaterialId || `material-${mIndex}`,
+            label: material.materialName || `培训资料${mIndex + 1}`,
+            // 资料节点:应学/已学时长,秒转分钟
+            isMaterial: true,
+            // 培训资料ID,上报学习进度时必填
+            materialId: material.materialId || null,
+            requiredMinutes: this.toMinutes(material.requiredDurationSeconds),
+            completedMinutes: this.toMinutes(material.completedDurationSeconds),
+            // 资料已学时长同时作为该资料下「本节时长」的参考
+            completedSeconds: Number(material.completedDurationSeconds) || 0,
+            children
+          };
+        });
+        this.treeTitle = detail.trainingName || '培训资料';
+        // 总时长为所有资料应学时长之和,已学时长为所有资料已完成时长之和
+        this.totalRequiredSeconds = materialList.reduce(
+          (sum, material) =>
+            sum + (Number(material.requiredDurationSeconds) || 0),
+          0
+        );
+        this.totalMinutesValue = this.toMinutes(this.totalRequiredSeconds);
+        this.totalCompletedSeconds = materialList.reduce(
+          (sum, material) =>
+            sum + (Number(material.completedDurationSeconds) || 0),
+          0
+        );
+        this.treeData = treeData;
+        this.totalSeconds = this.totalRequiredSeconds;
+        this.remainingSeconds = this.totalSeconds;
+        this.showOverlay = true;
+        // 默认选中第一个资源节点
+        const firstChild = treeData.length ? treeData[0].children[0] : null;
+        if (firstChild) {
+          const firstMaterial = treeData[0];
+          this.currentTitle = firstChild.label;
+          this.currentFileIds = firstChild.attachment;
+          this.currentDuration = firstChild.durationMinutes;
+          this.currentCompletedSeconds = firstMaterial.completedSeconds;
+          this.currentMaterialId = firstChild.materialId || null;
+        } else {
+          this.currentTitle = this.treeTitle;
+          this.currentFileIds = [];
+          this.currentDuration = 0;
+          this.currentCompletedSeconds = 0;
+        }
+        this.$nextTick(() => {
+          if (firstChild) {
+            this.$refs.treeRef.setCurrentKey(firstChild.id);
+          }
+        });
+        // 打开时若已完成学习,直接提示
+        this.checkLearnComplete();
+      },
+      // 秒转分钟,向上取整
+      toMinutes(seconds) {
+        const value = Number(seconds);
+        if (!value || value <= 0) {
+          return 0;
+        }
+        return Math.ceil(value / 60);
+      },
+      // 附件id标准化,兼容字符串和数组
+      parseAttachment(attachment) {
+        if (!attachment) {
+          return [];
+        }
+        const list = Array.isArray(attachment)
+          ? attachment
+          : String(attachment).split(',');
+        return list
+          .map((item) => String(item || '').trim())
+          .filter((item) => item);
+      },
+      handleNodeClick(data) {
+        this.currentTitle = data.label;
+        this.currentFileIds = data.attachment || [];
+        // 本节时长取当前选中节点的 suggestDuration
+        this.currentDuration = Number(data.durationMinutes) || 0;
+        // 已学时长取所属资料的已完成时长(秒),以资料节点为唯一数据源
+        const material = data.materialId
+          ? this.treeData.find((node) => node.materialId === data.materialId)
+          : null;
+        this.currentCompletedSeconds = material
+          ? material.completedSeconds
+          : data.completedSeconds || 0;
+        // 只在选中具体资源时记录资料ID(learn接口materialId必填)
+        this.currentMaterialId = data.isLeaf
+          ? data.materialId || null
+          : null;
+        // 切换资源后停止计时,等待重新开始学习
+        this.stopLearnTimer();
+        this.stopCountdown();
+        this.showOverlay = true;
+      },
+      // 点击「开始学习」后才开始计时与上报
+      handleStartLearn() {
+        this.showOverlay = false;
+        // 重新开始学习时,倒计时归位到总时长
+        if (this.countdownTimer) {
+          this.remainingSeconds = this.totalSeconds;
+        }
+        // 视频类附件在播放时才开始计时
+        if (!this.isCurrentVideo()) {
+          this.startLearnTimer();
+          this.startCountdown();
+        }
+      },
+      // 当前附件是否为视频
+      isCurrentVideo() {
+        const preview = this.$refs.filePreviewRef;
+        return !!(
+          preview &&
+          preview.currentFile &&
+          preview.currentFile.category === 'video'
+        );
+      },
+      // 视频开始播放:开始计时与上报
+      handleMediaPlay() {
+        if (!this.isCurrentVideo()) {
+          return;
+        }
+        this.startLearnTimer();
+        this.startCountdown();
+      },
+      // 视频暂停或播放结束:停止计时与上报
+      handleMediaPause() {
+        if (!this.isCurrentVideo()) {
+          return;
+        }
+        this.stopLearnTimer();
+        this.stopCountdown();
+      },
+      // 浏览器标签页不可见(切到其它标签页/最小化)时停止计时并开启学习遮罩
+      handleVisibilityChange() {
+        if (document.hidden) {
+          this.stopLearnTimer();
+          this.stopCountdown();
+          this.showOverlay = true;
+          // 暂停正在播放的视频
+          this.pauseVideo();
+        }
+      },
+      // 暂停预览中的视频
+      pauseVideo() {
+        const preview = this.$refs.filePreviewRef;
+        if (preview && typeof preview.pauseVideo === 'function') {
+          preview.pauseVideo();
+        }
+      },
+      // 每分钟上报一次学习进度
+      startLearnTimer() {
+        this.stopLearnTimer();
+        if (!this.currentTaskId) {
+          return;
+        }
+        this.learnTimer = setInterval(() => {
+          this.reportLearn();
+        }, 60 * 1000);
+      },
+      stopLearnTimer() {
+        if (this.learnTimer) {
+          clearInterval(this.learnTimer);
+          this.learnTimer = null;
+        }
+      },
+      // 上报学习进度:任务ID + 培训资料ID + 本次学习秒数 + 事件ID(幂等)
+      async reportLearn() {
+        // materialId 为必填,未选中具体资源时跳过本次上报
+        if (!this.currentTaskId || !this.currentMaterialId) {
+          return;
+        }
+        const watchedSeconds = 60;
+        try {
+          await learn({
+            taskId: this.currentTaskId,
+            materialId: this.currentMaterialId,
+            watchedSeconds,
+            eventId: this.createEventId()
+          });
+          // 上报成功后本地同步已学时长,无需等待下次重新查询
+          this.addLocalProgress(watchedSeconds);
+        } catch (error) {
+          this.$message.error(error.message || '学习进度上报失败');
+        }
+      },
+      // 生成学习事件ID,用于接口重试幂等(优先使用浏览器原生 randomUUID)
+      createEventId() {
+        if (window.crypto && typeof window.crypto.randomUUID === 'function') {
+          return window.crypto.randomUUID();
+        }
+        return `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
+      },
+      // 上报成功后本地累加已学时长,并同步树上的资料节点显示
+      addLocalProgress(watchedSeconds) {
+        this.totalCompletedSeconds += watchedSeconds;
+        this.currentCompletedSeconds += watchedSeconds;
+        const material = this.currentMaterialId
+          ? this.treeData.find(
+              (node) => node.materialId === this.currentMaterialId
+            )
+          : null;
+        if (material) {
+          material.completedSeconds += watchedSeconds;
+          material.completedMinutes = this.toMinutes(material.completedSeconds);
+        }
+        this.checkLearnComplete();
+      },
+      // 已学时长达到应学总时长时提示用户已完成培训
+      checkLearnComplete() {
+        if (this.completedNotified || !this.totalRequiredSeconds) {
+          return;
+        }
+        if (this.totalCompletedSeconds >= this.totalRequiredSeconds) {
+          this.completedNotified = true;
+          this.$message.success('恭喜,您已完成本次培训');
+        }
+      },
+      // 启动学习倒计时(每秒递减)
+      startCountdown() {
+        this.stopCountdown();
+        if (!this.totalSeconds) {
+          return;
+        }
+        this.countdownTimer = setInterval(() => {
+          this.remainingSeconds -= 1;
+          if (this.remainingSeconds <= 0) {
+            this.remainingSeconds = 0;
+            this.handleCountdownEnd();
+          }
+        }, 1000);
+      },
+      stopCountdown() {
+        if (this.countdownTimer) {
+          clearInterval(this.countdownTimer);
+          this.countdownTimer = null;
+        }
+      },
+      // 倒计时结束:停止计时并提示,弹窗保持打开
+      handleCountdownEnd() {
+        this.stopCountdown();
+        this.stopLearnTimer();
+        this.$message.warning('学习时长已到,已停止计时');
+      },
+      cancel() {
+        this.stopLearnTimer();
+        this.stopCountdown();
+        this.visible = false;
+        this.showOverlay = true;
+        this.currentFileIds = [];
+        this.currentTaskId = null;
+        this.currentMaterialId = null;
+        this.totalSeconds = 0;
+        this.remainingSeconds = 0;
+        this.totalMinutesValue = 0;
+        this.totalRequiredSeconds = 0;
+        this.completedNotified = false;
+        this.totalCompletedSeconds = 0;
+        this.currentCompletedSeconds = 0;
+        this.currentDuration = 0;
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .preview-container {
+    display: flex;
+    height: calc(90vh - 160px);
+    min-height: 500px;
+  }
+
+  .left-tree {
+    width: 280px;
+    flex-shrink: 0;
+    border-right: 1px solid #ebeef5;
+    overflow-y: auto;
+
+    .tree-header {
+      padding: 12px 15px;
+      font-weight: bold;
+      font-size: 14px;
+      border-bottom: 1px solid #ebeef5;
+    }
+
+    .tree-meta {
+      display: flex;
+      justify-content: space-between;
+      padding: 10px 15px;
+      font-size: 12px;
+      color: #909399;
+      background-color: #fafafa;
+      border-bottom: 1px solid #ebeef5;
+    }
+
+    ::v-deep .el-tree {
+      padding: 10px;
+
+      .el-tree-node__content {
+        height: 36px;
+      }
+    }
+  }
+
+  .custom-tree-node {
+    flex: 1;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    font-size: 13px;
+    padding-right: 8px;
+
+    .not-learned {
+      color: #409eff;
+    }
+
+    .node-duration {
+      color: #909399;
+      font-size: 12px;
+      margin-left: 8px;
+    }
+
+    .node-meta {
+      color: #909399;
+      font-size: 12px;
+      margin-left: 8px;
+      text-align: right;
+      line-height: 1.4;
+    }
+  }
+
+  .right-content {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    padding: 0 20px;
+
+    .content-title {
+      text-align: center;
+      font-size: 16px;
+      font-weight: bold;
+      padding: 10px 0;
+    }
+
+    .content-meta {
+      display: flex;
+      justify-content: center;
+      gap: 30px;
+      padding-bottom: 16px;
+      font-size: 13px;
+      color: #909399;
+    }
+
+    .iframe-wrapper {
+      flex: 1;
+      position: relative;
+      background-color: #eef5f9;
+      border-radius: 4px;
+      overflow: hidden;
+    }
+
+    .preview-files {
+      width: 100%;
+      height: 100%;
+    }
+
+    .preview-iframe {
+      width: 100%;
+      height: 100%;
+      border: none;
+    }
+
+    .iframe-placeholder {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      color: #909399;
+    }
+
+    .start-overlay {
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background-color: rgba(238, 245, 249, 0.95);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      z-index: 10;
+    }
+  }
+</style>

+ 107 - 33
src/views/safetyTraining/myTraining/detail.vue

@@ -12,57 +12,115 @@
       <el-row class="detail-row">
         <el-col :span="12" class="detail-item">
           <span class="detail-label">培训名称:</span>
-          <span class="detail-value">{{ form.name || '-' }}</span>
+          <span class="detail-value">{{ form.trainingName || '-' }}</span>
         </el-col>
         <el-col :span="12" class="detail-item">
-          <span class="detail-label">制定人:</span>
-          <span class="detail-value">{{ form.creator || '-' }}</span>
+          <span class="detail-label">培训计划状态:</span>
+          <span class="detail-value">{{ form.planStatusName || '-' }}</span>
         </el-col>
       </el-row>
       <el-row class="detail-row">
         <el-col :span="12" class="detail-item">
-          <span class="detail-label">制定日期:</span>
-          <span class="detail-value">{{ form.createDate || '-' }}</span>
+          <span class="detail-label">培训讲师:</span>
+          <span class="detail-value">{{ form.trainingLecturer || '-' }}</span>
+        </el-col>
+        <el-col :span="12" class="detail-item">
+          <span class="detail-label">讲师资质:</span>
+          <span class="detail-value">{{
+            form.lecturerQualification || '-'
+          }}</span>
+        </el-col>
+      </el-row>
+      <el-row class="detail-row">
+        <el-col :span="12" class="detail-item">
+          <span class="detail-label">培训开始时间:</span>
+          <span class="detail-value">{{ form.trainingStartTime || '-' }}</span>
         </el-col>
         <el-col :span="12" class="detail-item">
           <span class="detail-label">要求完成时间:</span>
-          <span class="detail-value">{{ form.requiredFinishTime || '-' }}</span>
+          <span class="detail-value">{{
+            form.requiredCompleteTime || '-'
+          }}</span>
         </el-col>
       </el-row>
       <el-row class="detail-row">
         <el-col :span="12" class="detail-item">
-          <span class="detail-label">下达时间:</span>
-          <span class="detail-value">{{ form.assignTime || '-' }}</span>
+          <span class="detail-label">培训学时:</span>
+          <span class="detail-value">{{ form.trainingHours || '-' }}</span>
         </el-col>
         <el-col :span="12" class="detail-item">
-          <span class="detail-label">培训开始时间:</span>
-          <span class="detail-value">{{ form.startTime || '-' }}</span>
+          <span class="detail-label">课程时长(分钟):</span>
+          <span class="detail-value">{{
+            form.courseDurationMinutes || '-'
+          }}</span>
         </el-col>
       </el-row>
       <el-row class="detail-row">
         <el-col :span="12" class="detail-item">
-          <span class="detail-label">培训讲师:</span>
-          <span class="detail-value">{{ form.lecturer || '-' }}</span>
+          <span class="detail-label">学习状态:</span>
+          <span class="detail-value">{{ form.statusName || '-' }}</span>
         </el-col>
         <el-col :span="12" class="detail-item">
-          <span class="detail-label">讲师资质:</span>
-          <span class="detail-value">{{ form.lecturerQualification || '-' }}</span>
+          <span class="detail-label">是否已逾期:</span>
+          <span class="detail-value">{{ form.overdue ? '是' : '否' }}</span>
         </el-col>
       </el-row>
       <el-row class="detail-row">
         <el-col :span="12" class="detail-item">
-          <span class="detail-label">下达人:</span>
-          <span class="detail-value">{{ form.assigner || '-' }}</span>
+          <span class="detail-label">已完成时长(分钟):</span>
+          <span class="detail-value">{{
+            form.completedDurationMinutes || '-'
+          }}</span>
         </el-col>
         <el-col :span="12" class="detail-item">
-          <span class="detail-label">责任人:</span>
-          <span class="detail-value">{{ form.responsibleUserName || '-' }}</span>
+          <span class="detail-label">完成时间:</span>
+          <span class="detail-value">{{ form.completeTime || '-' }}</span>
+        </el-col>
+      </el-row>
+      <el-row class="detail-row">
+        <el-col :span="24" class="detail-item">
+          <span class="detail-label">相关培训资料:</span>
+          <span class="detail-value">{{
+            form.trainingMaterialNames || '-'
+          }}</span>
         </el-col>
       </el-row>
       <el-row class="detail-row">
         <el-col :span="24" class="detail-item">
-          <span class="detail-label">培训内容:</span>
-          <span class="detail-value">{{ form.content || '-' }}</span>
+          <span class="detail-label">资料学习进度:</span>
+          <span class="detail-value"
+            >{{ form.materialCompletedCount || 0 }}/{{
+              form.materialTotalCount || 0
+            }}</span
+          >
+        </el-col>
+      </el-row>
+      <el-row v-if="form.examName" class="detail-row">
+        <el-col :span="12" class="detail-item">
+          <span class="detail-label">考试名称:</span>
+          <span class="detail-value">{{ form.examName }}</span>
+        </el-col>
+        <el-col :span="12" class="detail-item">
+          <span class="detail-label">考试类型:</span>
+          <span class="detail-value">{{ examTypeText }}</span>
+        </el-col>
+      </el-row>
+      <el-row v-if="form.examName" class="detail-row">
+        <el-col :span="12" class="detail-item">
+          <span class="detail-label">考试地点:</span>
+          <span class="detail-value">{{ form.examPlace || '-' }}</span>
+        </el-col>
+        <el-col :span="12" class="detail-item">
+          <span class="detail-label">考试时长(分钟):</span>
+          <span class="detail-value">{{
+            form.examDurationMinutes || '-'
+          }}</span>
+        </el-col>
+      </el-row>
+      <el-row v-if="form.examName" class="detail-row">
+        <el-col :span="12" class="detail-item">
+          <span class="detail-label">合格分数:</span>
+          <span class="detail-value">{{ form.passScore || '-' }}</span>
         </el-col>
       </el-row>
     </div>
@@ -74,18 +132,31 @@
 </template>
 
 <script>
+  import { EXAM_TYPE_MAP } from '../constants';
+
   const defForm = {
-    name: '',
-    creator: '',
-    createDate: '',
-    requiredFinishTime: '',
-    assignTime: '',
-    startTime: '',
-    lecturer: '',
+    trainingName: '',
+    planStatus: '',
+    planStatusName: '',
+    trainingLecturer: '',
     lecturerQualification: '',
-    assigner: '',
-    responsibleUserName: '',
-    content: ''
+    trainingStartTime: '',
+    requiredCompleteTime: '',
+    trainingHours: '',
+    courseDurationMinutes: '',
+    status: '',
+    statusName: '',
+    overdue: false,
+    completedDurationMinutes: '',
+    completeTime: '',
+    trainingMaterialNames: '',
+    materialCompletedCount: '',
+    materialTotalCount: '',
+    examName: '',
+    examType: '',
+    examPlace: '',
+    examDurationMinutes: '',
+    passScore: ''
   };
 
   export default {
@@ -95,12 +166,15 @@
         form: JSON.parse(JSON.stringify(defForm))
       };
     },
+    computed: {
+      examTypeText() {
+        return EXAM_TYPE_MAP[this.form.examType] || this.form.examType || '-';
+      }
+    },
     methods: {
       open(row) {
         this.visible = true;
-        this.form = JSON.parse(
-          JSON.stringify({ ...defForm, ...(row || {}) })
-        );
+        this.form = JSON.parse(JSON.stringify({ ...defForm, ...(row || {}) }));
       },
       cancel() {
         this.form = JSON.parse(JSON.stringify(defForm));

+ 102 - 33
src/views/safetyTraining/myTraining/index.vue

@@ -22,9 +22,30 @@
             height="calc(100vh - 340px)"
             @columns-change="handleColumnChange"
           >
+            <template v-slot:planStatus="{ row }">
+              <el-tag
+                size="mini"
+                :type="PLAN_STATUS_TAG_TYPE[row.planStatus] || 'info'"
+                >{{ planStatusText(row.planStatus) }}</el-tag
+              >
+            </template>
+            <template v-slot:status="{ row }">
+              <el-tag
+                size="mini"
+                :type="
+                  row.status === 2
+                    ? 'success'
+                    : row.overdue
+                    ? 'danger'
+                    : 'warning'
+                "
+                >{{ row.statusName || '-' }}</el-tag
+              >
+            </template>
             <template v-slot:action="{ row }">
               <div class="action-links">
                 <el-link
+                  v-if="row.status !== 2 && !row.overdue"
                   type="primary"
                   :underline="false"
                   @click="handleStart(row)"
@@ -43,6 +64,8 @@
 
         <!-- 详情弹窗 -->
         <detail ref="detailRef" />
+        <!-- 开始学习弹窗 -->
+        <start-learning ref="startLearningRef" />
       </div>
     </el-card>
   </div>
@@ -50,15 +73,32 @@
 
 <script>
   import detail from './detail.vue';
+  import startLearning from '../components/startLearning.vue';
   import tabMixins from '@/mixins/tableColumnsMixin';
+  import { getMyPage } from '@/api/onlineTrainingPlan/index.js';
+  import {
+    PLAN_STATUS_OPTIONS,
+    PLAN_STATUS_TAG_TYPE,
+    getOptionLabel
+  } from '../constants';
+
+  // 当前用户学习状态:1-未完成、2-已完成
+  const LEARN_STATUS_OPTIONS = [
+    { label: '未完成', value: 1 },
+    { label: '已完成', value: 2 }
+  ];
+
   export default {
     components: {
-      detail
+      detail,
+      startLearning
     },
     mixins: [tabMixins],
 
     data() {
       return {
+        PLAN_STATUS_TAG_TYPE,
+        LEARN_STATUS_OPTIONS,
         columns: [
           {
             columnKey: 'index',
@@ -68,52 +108,74 @@
             align: 'center'
           },
           {
-            prop: 'name',
+            prop: 'trainingName',
             label: '培训名称',
             align: 'center',
             minWidth: 180,
             showOverflowTooltip: true
           },
           {
-            prop: 'responsibleUserName',
-            label: '责任人',
+            prop: 'planStatus',
+            label: '计划状态',
             align: 'center',
-            minWidth: 100
+            minWidth: 100,
+            slot: 'planStatus'
           },
           {
-            prop: 'creator',
-            label: '制定人',
+            prop: 'trainingLecturer',
+            label: '培训讲师',
             align: 'center',
-            minWidth: 100
+            minWidth: 110
+          },
+          {
+            prop: 'lecturerQualification',
+            label: '讲师资质',
+            align: 'center',
+            minWidth: 130,
+            showOverflowTooltip: true
           },
           {
-            prop: 'startTime',
-            label: '开始时间',
+            prop: 'trainingStartTime',
+            label: '培训开始时间',
             align: 'center',
             minWidth: 160
           },
           {
-            prop: 'content',
+            prop: 'requiredCompleteTime',
+            label: '要求完成时间',
+            align: 'center',
+            minWidth: 160
+          },
+          {
+            prop: 'trainingHours',
+            label: '培训学时',
+            align: 'center',
+            minWidth: 100
+          },
+          {
+            prop: 'trainingContent',
             label: '培训内容',
             align: 'center',
             minWidth: 200,
             showOverflowTooltip: true
           },
           {
-            prop: 'assigner',
-            label: '下达人',
+            prop: 'examName',
+            label: '考试名称',
             align: 'center',
-            minWidth: 100
+            minWidth: 160,
+            showOverflowTooltip: true
           },
           {
-            prop: 'assignTime',
-            label: '下达时间',
+            prop: 'status',
+            label: '学习状态',
             align: 'center',
-            minWidth: 160
+            minWidth: 110,
+            slot: 'status'
           },
           {
-            prop: 'requiredFinishTime',
-            label: '要求完成时间',
+            prop: 'completeTime',
+            label: '完成时间',
             align: 'center',
             minWidth: 160
           },
@@ -133,33 +195,36 @@
         return [
           {
             label: '培训名称:',
-            value: 'name',
+            value: 'trainingName',
             type: 'input',
             width: 220,
             placeholder: '请输入培训名称'
           },
           {
-            label: '制定人:',
-            value: 'creator',
-            type: 'input',
+            label: '学习状态:',
+            value: 'status',
+            type: 'select',
             width: 220,
-            placeholder: '请输入制定人'
+            placeholder: '请选择学习状态',
+            planList: this.LEARN_STATUS_OPTIONS
           },
           {
-            label: '开始时间:',
-            value: 'startTime',
+            label: '要求完成时间:',
+            value: 'requiredCompleteTime',
+            valueAr: ['requiredCompleteTimeStart', 'requiredCompleteTimeEnd'],
             type: 'date',
-            width: 220,
-            placeholder: '请选择开始时间'
+            width: 280
           }
         ];
       }
     },
     methods: {
+      planStatusText(status) {
+        return getOptionLabel(PLAN_STATUS_OPTIONS, status) || '-';
+      },
       // 开始学习
       handleStart(row) {
-        // TODO: 开始培训逻辑
-        console.log('开始学习', row);
+        this.$refs.startLearningRef.open(row);
       },
       // 查看详情
       handleDetail(row) {
@@ -170,14 +235,18 @@
         this.$refs.table.reload({ page: 1, where });
       },
       // 表格数据源
-      datasource({ page, limit, where, order }) {
+      datasource({ page, limit, where }) {
         const body = {
           ...where,
-          ...order,
           pageNum: page,
           size: limit
         };
-        return [{}];
+        Object.keys(body).forEach((key) => {
+          if (body[key] === '' || body[key] === null) {
+            delete body[key];
+          }
+        });
+        return getMyPage(body);
       }
     }
   };