Przeglądaj źródła

Merge branch 'xbnl_20260904(应知应会区分学生/教职工)' into SCHOOL-XiBeiNongLin

dedsudiyu 1 tydzień temu
rodzic
commit
f30e95badd

+ 1 - 0
src/views/safetyEducationExaminationNew/components/examRecords.vue

@@ -169,6 +169,7 @@
           obj.startTime = ''
           obj.endTime = ''
         }
+        obj.examScene = this.propsData.examScene;
         examElUserExamAttemptAttemptRecord(obj).then(response => {
           this.$set(this, 'dataList', response.data.records)
           this.$set(this, 'total', response.data.total)

+ 2 - 2
src/views/safetyEducationExaminationNew/safetyTest/mockExam/index.vue

@@ -49,12 +49,12 @@
           <p class="for-null-p" v-if="!dataList[0]">暂无数据</p>
           <div class="for-big-box" v-for="(item,index) in dataList" :key="index">
             <div class="top-box">
-              <p class="title-p">{{item.examTypeName}}</p>
+              <p class="title-p">{{item.examName}}</p>
               <p class="text-p" v-if="item.conditionType != 4">满足前置条件后可参加考试</p>
               <p class="text-p" v-else>已满足前置条件,可参加考试</p>
             </div>
             <div class="bottom-box">
-              <p class="text-big-p">{{item.examName}}</p>
+              <p class="text-big-p">{{item.examTypeName}}</p>
               <p class="text-p">{{parseTime(item.startTime,"{y}-{m}-{d} {h}:{i}")}} 至 {{parseTime(item.endTime,"{y}-{m}-{d} {h}:{i}")}}</p>
               <p class="time-p">时长:{{item.answerDuration}}分钟 丨 总分:{{item.totalScore}}分 丨 及格分:{{item.passScore}}分</p>
             </div>

+ 188 - 13
src/views/safetyEducationExaminationNew/safetyTest/myGrades/dataStatisticsReport.vue

@@ -104,7 +104,6 @@
 
 <script>
   import { examElLabEduRelationGraduationReport } from "@/api/safetyEducationExaminationNew/index";
-  import html2canvas from 'html2canvas'
   export default {
     name: 'dataStatisticsReport',
     props: {
@@ -113,6 +112,7 @@
     data() {
       return {
         logUrl:'https://labcontrol.nwafu.edu.cn/statics/2024/07/22/d1a9d129-ced7-49ac-b258-e5167d85232d.png',
+        logoImg: null,
         newData: {
           userName:'',
           account:'',
@@ -125,40 +125,37 @@
           issuingUnit:'',
           issuingTime:'',
         },
-        text:'该同学在校期间认真完成实验室安全教育各项学习任务,累计完成12学时安全培训,通过全部年度安全考核,具备良好的实验室安全意识和规范操作能力,特此证明。',
         downloading: false,
         showType:false,
       }
     },
     mounted() {
       this.initialize()
+      this._preloadLogo()
     },
     methods: {
       initialize() {
         this.$set(this, 'newData', this.dataStatisticsReportPropsData);
         this.$set(this, 'showType', true);
       },
+      _preloadLogo() {
+        const img = new Image()
+        img.crossOrigin = 'anonymous'
+        img.onload = () => { this.logoImg = img }
+        img.onerror = () => {}
+        img.src = this.logUrl
+      },
       async downloadPdf() {
         if (this.downloading) return
         this.downloading = true
         try {
-          const el = this.$refs.reportWrap
-          const canvas = await html2canvas(el, {
-            scale: 2,
-            useCORS: true,
-            backgroundColor: '#ffffff',
-            logging: false,
-          })
+          const canvas = await this._renderToCanvas()
           const imgW = canvas.width
           const imgH = canvas.height
-          // 将 canvas 转为 JPEG base64(去掉 data:image/jpeg;base64, 前缀)
           const jpegB64 = canvas.toDataURL('image/jpeg', 0.92).split(',')[1]
-          // atob 解码为二进制字节数组
           const jpegBytes = Uint8Array.from(atob(jpegB64), c => c.charCodeAt(0))
-          // A4 点数 595×842,按比例缩放图片到页面宽度
           const pdfW = 595
           const pdfH = Math.round((imgH / imgW) * pdfW)
-          // 构造最小合法 PDF(内嵌 JPEG,DCTDecode 不需要解码)
           const pdfBytes = this._buildPdf(jpegBytes, imgW, imgH, pdfW, pdfH)
           const blob = new Blob([pdfBytes], { type: 'application/pdf' })
           const url = URL.createObjectURL(blob)
@@ -176,6 +173,184 @@
           this.downloading = false
         }
       },
+      async _renderToCanvas() {
+        const d = this.newData
+        const studyList = d.studyHoursList || []
+        const examList = d.examPassRecords || []
+        const FONT = '"PingFang SC","Microsoft YaHei",sans-serif'
+        const SC = 2          // canvas 物理像素倍率,保证清晰度
+        const PW = 720        // 逻辑宽度
+        const BX = 20, BW = 680   // 外框 x, 宽
+        const MX = 21, MW = 678   // 内框 x, 宽(外框内 1px)
+        const CX = 69, CW = 578   // 内容 x, 宽(左 margin 48px)
+        const ROW_H = 32, HEADER_H = 34, SEC_TITLE_H = 20, SEC_GAP = 10
+
+        // 计算总高度
+        let H = MX + 40 + 88 + 56 + 9 + 99  // 顶部到用户信息框底部 = 313
+        if (studyList.length) H += 20 + SEC_TITLE_H + SEC_GAP + HEADER_H + studyList.length * ROW_H + ROW_H
+        if (examList.length)  H += 20 + SEC_TITLE_H + SEC_GAP + HEADER_H + examList.length * ROW_H
+        H += 25               // 描述文字顶部间距
+        // 描述文字行数估算(平均每行约 26 个汉字)
+        const descText = `  该同学在校期间认真完成实验室安全教育各项学习任务,累计完成 ${d.totalStudyHours} 学时安全培训,通过全部年度安全考核,具备良好的实验室安全意识和规范操作能力,特此证明。`
+        H += Math.ceil(descText.length / 26) * 24 + 24
+        H += 103 + 24 * 2 + 80 + MX  // 落款区域 + 底部空间
+
+        const canvas = document.createElement('canvas')
+        canvas.width  = PW * SC
+        canvas.height = H  * SC
+        const ctx = canvas.getContext('2d')
+        ctx.scale(SC, SC)
+
+        // 背景
+        ctx.fillStyle = '#fffdf5'
+        ctx.fillRect(0, 0, PW, H)
+
+        // 外框
+        ctx.strokeStyle = '#c9a84c'
+        ctx.lineWidth = 1
+        ctx.beginPath(); this._rrect(ctx, BX, BX, BW, H - BX * 2, 4); ctx.stroke()
+        // 内框
+        ctx.beginPath(); this._rrect(ctx, MX, MX, MW, H - MX * 2, 3); ctx.stroke()
+
+        let y = MX + 40
+
+        // ── Logo 绿色底框 ──
+        ctx.fillStyle = '#1a6e3a'
+        ctx.beginPath(); this._rrect(ctx, CX, y, CW, 88, 6); ctx.fill()
+        if (this.logoImg) {
+          try {
+            const iw = 350, ih = 76
+            ctx.drawImage(this.logoImg, CX + (CW - iw) / 2, y + 6, iw, ih)
+          } catch (e) {}
+        }
+        y += 88
+
+        // ── 标题 ──
+        ctx.fillStyle = '#8b452f'
+        ctx.font = `bold 20px ${FONT}`
+        ctx.textAlign = 'center'
+        ctx.letterSpacing = '6px'
+        ctx.fillText('实验室安全教育成绩报告', PW / 2, y + 36)
+        ctx.letterSpacing = '0px'
+        y += 56
+
+        // ── 用户信息框 ──
+        ctx.fillStyle = '#faf6ec'
+        ctx.beginPath(); this._rrect(ctx, CX, y + 9, CW, 99, 4); ctx.fill()
+        ctx.strokeStyle = '#e8dcc8'; ctx.lineWidth = 1
+        ctx.beginPath(); this._rrect(ctx, CX, y + 9, CW, 99, 4); ctx.stroke()
+
+        const userRows = [
+          [['姓名:', d.userName || ''], ['学号:',    d.account  || '']],
+          [['学院:', d.deptName || ''], ['年级:',    d.grade    || '']],
+          [['用户类型:', d.educationTypeName || '']],
+        ]
+        let uy = y + 9 + 10 + 16
+        ctx.textAlign = 'left'
+        for (const row of userRows) {
+          for (let i = 0; i < row.length; i++) {
+            const cx2 = CX + 20 + i * (CW / 2)
+            ctx.fillStyle = '#4a3c2a'
+            ctx.font = `bold 14px ${FONT}`
+            ctx.fillText(row[i][0], cx2, uy)
+            const lw = ctx.measureText(row[i][0]).width
+            ctx.font = `14px ${FONT}`
+            ctx.fillText(row[i][1], cx2 + lw, uy)
+          }
+          uy += 26
+        }
+        y += 9 + 99
+
+        // ── 学年学时表 ──
+        if (studyList.length) {
+          y += 20
+          y = this._drawTable(ctx, FONT, '学年学时完成情况', CX, y, CW,
+            ['学年', '完成学时', '要求学时'],
+            studyList.map(r => [r.academicYear, String(r.completedStudyHours), String(r.studyHoursRequirement)]),
+            ['累计', `${d.totalStudyHours} 学时`]
+          )
+        }
+
+        // ── 考试通过记录表 ──
+        if (examList.length) {
+          y += 20
+          y = this._drawTable(ctx, FONT, '考试通过记录', CX, y, CW,
+            ['学年', '考试类型', '成绩'],
+            examList.map(r => [r.academicYear, r.examTypeName, String(r.score)])
+          )
+        }
+
+        // ── 描述文字 ──
+        y += 25
+        ctx.fillStyle = '#4a3c2a'
+        ctx.font = `14px ${FONT}`
+        ctx.textAlign = 'left'
+        y = this._wrapText(ctx, descText, CX, y, CW, 24)
+
+        // ── 落款 ──
+        y += 103
+        ctx.textAlign = 'right'
+        ctx.fillText(d.issuingUnit || '', CX + CW - 132, y);     y += 24
+        ctx.fillText(d.issuingTime || '', CX + CW - 132, y)
+
+        return canvas
+      },
+      _rrect(ctx, x, y, w, h, r) {
+        ctx.moveTo(x + r, y)
+        ctx.lineTo(x + w - r, y);   ctx.quadraticCurveTo(x + w, y,     x + w, y + r)
+        ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
+        ctx.lineTo(x + r, y + h);   ctx.quadraticCurveTo(x,     y + h, x,     y + h - r)
+        ctx.lineTo(x, y + r);       ctx.quadraticCurveTo(x,     y,     x + r, y)
+        ctx.closePath()
+      },
+      _drawTable(ctx, font, title, x, y, w, headers, rows, bottomRow) {
+        const COL_W = w / headers.length
+        // 小节标题
+        ctx.fillStyle = '#c9a84c'
+        ctx.fillRect(x, y, 3, 20)
+        ctx.fillStyle = '#8b4513'
+        ctx.font = `bold 12px ${font}`
+        ctx.textAlign = 'left'
+        ctx.fillText(title, x + 11, y + 14)
+        y += 30
+        // 表头
+        ctx.fillStyle = '#f5edd8'
+        ctx.fillRect(x, y, w, 34)
+        ctx.strokeStyle = '#d4c5a0'; ctx.lineWidth = 1
+        ctx.strokeRect(x, y + 33, w, 1)
+        ctx.fillStyle = '#5a4320'; ctx.font = `bold 12px ${font}`; ctx.textAlign = 'center'
+        headers.forEach((h, i) => ctx.fillText(h, x + COL_W * i + COL_W / 2, y + 21))
+        y += 34
+        // 数据行
+        ctx.font = `12px ${font}`; ctx.fillStyle = '#4a3c2a'
+        for (const row of rows) {
+          ctx.strokeStyle = '#ebe3d0'; ctx.strokeRect(x, y + 31, w, 1)
+          row.forEach((cell, i) => ctx.fillText(String(cell), x + COL_W * i + COL_W / 2, y + 20))
+          y += 32
+        }
+        // 汇总行
+        if (bottomRow) {
+          ctx.fillStyle = '#faf6ec'; ctx.fillRect(x, y, w, 32)
+          ctx.strokeStyle = '#ebe3d0'; ctx.strokeRect(x, y + 31, w, 1)
+          ctx.fillStyle = '#4a3c2a'; ctx.font = `bold 12px ${font}`
+          ctx.fillText(bottomRow[0], x + COL_W / 2, y + 20)
+          ctx.fillText(bottomRow[1], x + COL_W + (w - COL_W) / 2, y + 20)
+          y += 32
+        }
+        return y
+      },
+      _wrapText(ctx, text, x, y, maxW, lineH) {
+        let line = ''
+        let cy = y + lineH
+        for (const ch of text) {
+          const test = line + ch
+          if (ctx.measureText(test).width > maxW && line) {
+            ctx.fillText(line, x, cy); cy += lineH; line = ch
+          } else { line = test }
+        }
+        if (line) { ctx.fillText(line, x, cy); cy += lineH }
+        return cy
+      },
       // 构造内嵌单张 JPEG 图片的最小 PDF
       _buildPdf(jpegBytes, imgW, imgH, pageW, pageH) {
         const enc = s => new TextEncoder().encode(s)

+ 5 - 2
src/views/safetyEducationExaminationNew/safetyTest/onlineExam/index.vue

@@ -49,12 +49,12 @@
           <p class="for-null-p" v-if="!dataList[0]">暂无数据</p>
           <div class="for-big-box" v-for="(item,index) in dataList" :key="index">
             <div class="top-box">
-              <p class="title-p">{{item.examTypeName}}</p>
+              <p class="title-p">{{item.examName}}</p>
               <p class="text-p" v-if="item.conditionType != 4">满足前置条件后可参加考试</p>
               <p class="text-p" v-else>已满足前置条件,可参加考试</p>
             </div>
             <div class="bottom-box">
-              <p class="text-big-p">{{item.examName}}</p>
+              <p class="text-big-p">{{item.examTypeName}}</p>
               <p class="text-p">{{parseTime(item.startTime,"{y}-{m}-{d} {h}:{i}")}} 至 {{parseTime(item.endTime,"{y}-{m}-{d} {h}:{i}")}}</p>
               <p class="time-p">时长:{{item.answerDuration}}分钟 丨 总分:{{item.totalScore}}分 丨 及格分:{{item.passScore}}分 丨 可考试次数:{{item.attemptLimit===0?'无限':item.attemptCount+'/'+item.attemptLimit}}次</p>
             </div>
@@ -219,6 +219,9 @@
       },
       examUserExamCommitmentConfirm(type,attemptId){
         examUserExamCommitmentConfirm({attemptId:attemptId}).then(response => {
+          if(this.preExamDialogType){
+            this.$set(this,'preExamDialogType',false);
+          }
           let obj = {
             examId: this.preData.examId,
             attemptId: attemptId,

+ 3 - 0
src/views/safetyEducationExaminationNew/workbench/TaskCard.vue

@@ -204,6 +204,9 @@ export default {
     //确认考试承诺书
     examUserExamCommitmentConfirm(type,attemptId){
       examUserExamCommitmentConfirm({attemptId:attemptId}).then(response => {
+        if(this.preExamDialogType){
+          this.$set(this,'preExamDialogType',false);
+        }
         let obj = {
           examId: this.preData.examId,
           attemptId: attemptId,