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