Преглед изворни кода

专业版报告图片生成功能完成

htc пре 2 дана
родитељ
комит
1e2d828c6b
1 измењених фајлова са 385 додато и 42 уклоњено
  1. 385 42
      pagesHome/pdfZyb.vue

+ 385 - 42
pagesHome/pdfZyb.vue

@@ -1,5 +1,6 @@
 <template>
     <view class="page-wrappe">
+		<cus-header title=' ' bgColor="transparent"></cus-header>
 		<view id="pdfContainer" class="pdf-container" :style="{'transform':'scale('+scale+')', 'height': containerScaledHeight + 'px'}">
 			<!-- 封面 -->
 			<view class="cd_box fm2 adffc" style="margin-top: 20px;height: 868px;">
@@ -89,6 +90,7 @@
 				</view>
 			</view>
 			<!-- 多维度 -->
+			<canvas type="2d" id="table-canvas" canvas-id="table-canvas" class="offscreen-canvas"></canvas>
 			<template v-if="reportData&&reportData.dimensionAnalysis&&reportData.dimensionAnalysis.length">
 				<view class="cd_box adffc" style="border: none;" v-for="(item,index) in reportData.dimensionAnalysis" :key="index">
 					<view class="v2-top adfacjb" :style="{'background-image':'url('+'https://gitee.com/hw_0302/chuang-heng-wechat-images/raw/master/versionTwo/'+typeDict[item.title]+'_title_bg.png)'}">
@@ -100,32 +102,32 @@
 						<img class="vb-img2" :src="'https://gitee.com/hw_0302/chuang-heng-wechat-images/raw/master/versionTwo/'+typeDict[item.title]+'_box_img2.png'">
 						<view class="v2-p2">{{ item.desc }}</view>
 						<view class="v2-p2" style="margin-top: 16px;">评分总体分布</view>
-						<view class="vb-table" :style="{'border':'1px solid '+item.bcolor,'margin-top':'22px'}">
-							<view class="vbt-th adfac" :class="{'black':item.title==='目的与动机'}" :style="{'background':item.thcolor}">
-								<view class="vbtt-w1">主题</view>
-								<view class="vbtt-w2">最低分</view>
-								<view class="vbtt-w2">平均分</view>
-								<view class="vbtt-w2">最高分</view>
-								<view class="vbtt-w3">问卷陈述</view>
-							</view>
-							<view class="vbt-pre adfac" v-for="i in 5" :key="item">
-								<view class="vbtp-left vbtt-w1 adfacjc" :class="{'black':item.title==='目的与动机'}" :style="{'background':item.titlecolor,'padding':'0 16px'}">{{ '宗旨共融同心共识' }}</view>
-								<view class="vbtp-num vbtt-w2" :style="{'border-bottom':'1px solid '+item.bcolor}">{{ 3 }}</view>
-								<view class="vbtp-num vbtt-w2 green" :style="{'border-bottom':'1px solid '+item.bcolor}">{{ 8 }}</view>
-								<view class="vbtp-num vbtt-w2" :style="{'border-bottom':'1px solid '+item.bcolor}">{{ 1 }}</view>
-								<view class="vbtp-desc" :style="{'border-bottom':'1px solid '+item.bcolor}">
-									<view class="vbtpd-title">{{ '团队成员能够共同阐述其共享目的,并且在团队使命上保持高度一致。' }}</view>
-									<view class="xr_tb adfac">
-										<view class="xt_pre p1"></view>
-										<view class="xt_pre p2"></view>
-										<view class="xt_pre p3"></view>
-										<view class="xt_score adfac" :style="{'left':(4*2)+'%','width':((25-4)*2)+'%'}">
-											<view class="xts_num red">{{ 4 }}</view>
-											<view class="xts_box"></view>
-											<view class="xts_num green">{{ 25 }}</view>
-										</view>
-									</view>
-								</view>
+						<view class="vb-table" :style="{'border':'1px solid '+item.bcolor,'margin-top':'22px'}">
+							<view class="vbt-th adfac" :class="{'black':item.title==='目的与动机'}" :style="{'background':item.thcolor}">
+								<view class="vbtt-w1">主题</view>
+								<view class="vbtt-w2">最低分</view>
+								<view class="vbtt-w2">平均分</view>
+								<view class="vbtt-w2">最高分</view>
+								<view class="vbtt-w3">问卷陈述</view>
+							</view>
+							<view class="vbt-pre adfac" v-for="(tableRow, rowIndex) in tableDataSource" :key="rowIndex">
+								<view class="vbtp-left vbtt-w1 adfacjc" :class="{'black':item.title==='目的与动机'}" :style="{'background':item.titlecolor,'padding':'0 16px'}">{{ tableRow.theme }}</view>
+								<view class="vbtp-num vbtt-w2" :style="{'border-bottom':'1px solid '+item.bcolor}">{{ tableRow.minScore }}</view>
+								<view class="vbtp-num vbtt-w2 green" :style="{'border-bottom':'1px solid '+item.bcolor}">{{ tableRow.avgScore }}</view>
+								<view class="vbtp-num vbtt-w2" :style="{'border-bottom':'1px solid '+item.bcolor}">{{ tableRow.maxScore }}</view>
+								<view class="vbtp-desc" :style="{'border-bottom':'1px solid '+item.bcolor}">
+									<view class="vbtpd-title">{{ tableRow.statement }}</view>
+									<view class="xr_tb adfac">
+										<view class="xt_pre p1"></view>
+										<view class="xt_pre p2"></view>
+										<view class="xt_pre p3"></view>
+										<view class="xt_score adfac" :style="{'left':(tableRow.range[0]*2)+'%','width':((tableRow.range[1]-tableRow.range[0])*2)+'%'}">
+											<view class="xts_num red">{{ tableRow.range[0] }}</view>
+											<view class="xts_box"></view>
+											<view class="xts_num green">{{ tableRow.range[1] }}</view>
+										</view>
+									</view>
+								</view>
 							</view>
 						</view>
 					</view>
@@ -147,6 +149,7 @@
 				</view>
 			</template>
 		</view>
+		<view class="pdf_btn" @click="createPdf">生成PDF</view>
    </view>
 </template>
 
@@ -166,6 +169,56 @@
 		components:{ lEchart },
         data() {
         return {
+			tableDataSource: [
+				{
+					theme: '宗旨共融同心共识',
+					minScore: 1,
+					avgScore: 8,
+					maxScore: 1,
+					statement: '团队成员能够共同阐述其共享目的,并且在团队使命上保持高度一致。',
+					range: [4, 25] // 范围条的数据
+				},
+				{
+					theme: '价值引领行践合一',
+					minScore: 3,
+					avgScore: 1,
+					maxScore: 4,
+					statement: '团队经常根据其愿景、使命、目的和价值观来评估它们所做的事情及自身的行为。',
+					range: [8, 30]
+				},
+				{
+					theme: '使命驱动热忱贡献',
+					minScore: 5,
+					avgScore: 12,
+					maxScore: 1,
+					statement: '团队对它们为实现宗旨和愿景所面临的挑战与目标充满热情,并相信它们的工作为世界带来积极的贡献。',
+					range: [10, 40]
+				},
+				{
+					theme: '团队优先人尽其才',
+					minScore: 12,
+					avgScore: 5,
+					maxScore: 1,
+					statement: '团队成员(包括领导者)将团队优先事项置于个人优先事务之上,并在分配工作任务时充分发挥每个人的优势。',
+					range: [5, 22]
+				},
+				{
+					theme: '审时度势与时俱进',
+					minScore: 1,
+					avgScore: 23,
+					maxScore: 1,
+					statement: '团队定期(每隔数月)审视目标与优先事项,以确保它们能够适应外部环境的变化。',
+					range: [15, 35]
+				},
+				{
+					theme: '快乐工作具成就感',
+					minScore: 1,
+					avgScore: 21,
+					maxScore: 1,
+					statement: '团队成员(包括领导者)对所做的工作以及与同事共事感到快乐并从中获得成就感',
+					range: [4, 24]
+				}
+			],
             reportData: null,
 			isChartReady: false,
 			scale:1,
@@ -223,7 +276,8 @@
 				  color: '#0096D8'
 				}
             ],
-        };
+			pdfImages:[],
+		};
     },
     mounted() {
         // reportData.value = props.reportData;
@@ -244,13 +298,282 @@
             ]
         };
 		
-		
 		this.calculateScaleAndPosition();
 		uni.onWindowResize(() => {
 			this.calculateScaleAndPosition();
 		});
     },
     methods: {
+		async createPdf(){
+		    uni.showLoading({
+		        title:'正在生成PDF所需的图片...'
+		    })
+		    try {
+		        const ztzdfxImgPromise = this.downloadZtzdfxImg();
+		        const dimensionImagePromises = this.reportData.dimensionAnalysis.map(d => {
+		            return this.generateTableImage(d,this.tableDataSource);
+		        });
+		
+		        const allImageUrls = await Promise.all([
+		            ztzdfxImgPromise, 
+		            ...dimensionImagePromises
+		        ]);
+		        this.pdfImages = allImageUrls;
+		
+		        uni.hideLoading();
+		        this.$showToast(`生成成功,共计${this.pdfImages.length}张`);
+				console.log(this.pdfImages);
+		    } catch (error) {
+		        uni.hideLoading();
+		        console.error('生成图片过程中发生错误:', error);
+		        uni.showToast({ title: '生成图片失败,请重试', icon: 'none' });
+		    }
+		},
+		/**
+		 * @description 使用 Canvas 绘制表格并生成图片
+		 * @param {Object} dimensionData 维度数据
+		 * @param {Array} tableData 表格数据
+		 * @returns {Promise<string>} 返回生成的图片临时文件路径
+		 */
+		generateTableImage(dimensionData, tableData) {
+			return new Promise((resolve, reject) => {
+				const query = uni.createSelectorQuery().in(this);
+				query.select('#table-canvas')
+					.fields({ node: true, size: true })
+					.exec(async (res) => {
+						if (!res || !res[0] || !res[0].node) {
+							return reject('获取Canvas节点失败');
+						}
+						
+						const canvasNode = res[0].node;
+						const ctx = canvasNode.getContext('2d');
+						const dpr = uni.getSystemInfoSync().pixelRatio;
+		
+						// --- 1. 定义布局和尺寸常量
+						const TABLE_WIDTH = 548;
+						const HEADER_HEIGHT = 38;
+						const ROW_HEIGHT = 49; // 行高固定为 49px
+						const FONT_FAMILY = 'sans-serif';
+						const COL_WIDTHS = { theme: 72, min: 49, avg: 49, max: 49, statement: 329 }; // 主题宽度72px
+						const COL_POSITIONS = {
+							theme: 0,
+							min: COL_WIDTHS.theme,
+							avg: COL_WIDTHS.theme + COL_WIDTHS.min,
+							max: COL_WIDTHS.theme + COL_WIDTHS.min + COL_WIDTHS.avg,
+							statement: COL_WIDTHS.theme + COL_WIDTHS.min * 3
+						};
+		
+						const CANVAS_HEIGHT = HEADER_HEIGHT + tableData.length * ROW_HEIGHT;
+						const CANVAS_WIDTH = TABLE_WIDTH;
+						
+						canvasNode.width = CANVAS_WIDTH * dpr;
+						canvasNode.height = CANVAS_HEIGHT * dpr;
+						ctx.scale(dpr, dpr);
+						
+						ctx.fillStyle = '#FFFFFF';
+						ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
+						ctx.strokeStyle = dimensionData.bcolor;
+						ctx.lineWidth = 1;
+						ctx.strokeRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
+		
+						// --- 4. 绘制表头 ---
+						const isBlackHeader = dimensionData.title === '目的与动机';
+						ctx.fillStyle = dimensionData.thcolor;
+						ctx.fillRect(1, 1, CANVAS_WIDTH - 2, HEADER_HEIGHT - 1);
+						ctx.fillStyle = isBlackHeader ? '#000000' : '#FFFFFF';
+						ctx.font = `bold 10px ${FONT_FAMILY}`;
+						ctx.textAlign = 'center';
+						ctx.textBaseline = 'middle';
+						
+						ctx.fillText('主题', COL_POSITIONS.theme + COL_WIDTHS.theme / 2, HEADER_HEIGHT / 2);
+						ctx.fillText('最低分', COL_POSITIONS.min + COL_WIDTHS.min / 2, HEADER_HEIGHT / 2);
+						ctx.fillText('平均分', COL_POSITIONS.avg + COL_WIDTHS.avg / 2, HEADER_HEIGHT / 2);
+						ctx.fillText('最高分', COL_POSITIONS.max + COL_WIDTHS.max / 2, HEADER_HEIGHT / 2);
+						ctx.fillText('问卷陈述', COL_POSITIONS.statement + COL_WIDTHS.statement / 2, HEADER_HEIGHT / 2);
+		
+						// --- 5. 循环绘制每一行---
+						tableData.forEach((row, index) => {
+							const y = HEADER_HEIGHT + index * ROW_HEIGHT;
+							
+							ctx.fillStyle = dimensionData.titlecolor;
+							ctx.fillRect(1, y, COL_WIDTHS.theme - 1, ROW_HEIGHT);
+							// 绘制白色下边框
+							ctx.strokeStyle = '#FFFFFF';
+							ctx.lineWidth = 1;
+							ctx.beginPath();
+							ctx.moveTo(1, y + ROW_HEIGHT - 1);
+							ctx.lineTo(COL_WIDTHS.theme - 1, y + ROW_HEIGHT - 1);
+							ctx.stroke();
+		
+							// 绘制其他单元格的下边框
+							ctx.strokeStyle = dimensionData.bcolor;
+							['min', 'avg', 'max', 'statement'].forEach(key => {
+								ctx.beginPath();
+								ctx.moveTo(COL_POSITIONS[key], y + ROW_HEIGHT);
+								ctx.lineTo(COL_POSITIONS[key] + COL_WIDTHS[key], y + ROW_HEIGHT);
+								ctx.stroke();
+							});
+							
+							ctx.textAlign = 'center';
+							ctx.textBaseline = 'middle';
+							ctx.fillStyle = isBlackHeader ? '#000000' : '#FFFFFF';
+							ctx.font = `10px ${FONT_FAMILY}`; // 主题文字大小
+							this.drawWrappedText(ctx, row.theme, COL_POSITIONS.theme + COL_WIDTHS.theme / 2, y + ROW_HEIGHT / 2, 12, COL_WIDTHS.theme - 32);
+		
+							ctx.font = `bold 14px ${FONT_FAMILY}`; // 分数数字加粗
+							ctx.fillStyle = '#667E90';
+							ctx.fillText(row.minScore, COL_POSITIONS.min + COL_WIDTHS.min / 2, y + ROW_HEIGHT / 2);
+							ctx.fillStyle = '#27AE60';
+							ctx.fillText(row.avgScore, COL_POSITIONS.avg + COL_WIDTHS.avg / 2, y + ROW_HEIGHT / 2);
+							ctx.fillStyle = '#667E90';
+							ctx.fillText(row.maxScore, COL_POSITIONS.max + COL_WIDTHS.max / 2, y + ROW_HEIGHT / 2);
+							
+							// 5.3 绘制问卷陈述列
+							const statementX = COL_POSITIONS.statement;
+							const statementPadding = 10;
+							ctx.textAlign = 'left';
+							ctx.textBaseline = 'top';
+							ctx.fillStyle = '#193D59';
+							ctx.font = `9px ${FONT_FAMILY}`;
+							this.drawWrappedText(ctx, row.statement, statementX + statementPadding, y + 8, 10, COL_WIDTHS.statement - statementPadding * 2); // (请求 #4) 行高
+							
+							// 绘制范围指示器
+							const rangeBarY = y + 33; // 调整位置,使其距离标题 11px
+							const rangeBarWidth = COL_WIDTHS.statement - statementPadding * 2;
+							const rangeBarHeight = 4; // 背景高度 4px
+							const rangeBarX = statementX + statementPadding;
+		
+							// 绘制三段色背景
+							const segWidth = rangeBarWidth / 3;
+							ctx.fillStyle = '#BA8EB4'; // 颜色1
+							ctx.fillRect(rangeBarX, rangeBarY, segWidth, rangeBarHeight);
+							ctx.fillStyle = '#66BDBD'; // 颜色2
+							ctx.fillRect(rangeBarX + segWidth, rangeBarY, segWidth, rangeBarHeight);
+							ctx.fillStyle = '#AFCDF5'; // 颜色3
+							ctx.fillRect(rangeBarX + segWidth * 2, rangeBarY, segWidth, rangeBarHeight);
+		
+							// --- 开始绘制滑块 ---
+							const scaleFactor = rangeBarWidth / 50; 
+							const rangeLeft = row.range[0] * scaleFactor;
+							const rangeWidth = (row.range[1] - row.range[0]) * scaleFactor;
+							
+							// 绘制中间的连接条
+							const connectorY = rangeBarY - (8 - rangeBarHeight) / 2; // 垂直居中
+							const connectorHeight = 8;
+							ctx.fillStyle = '#199C9C';
+							ctx.fillRect(rangeBarX + rangeLeft, connectorY, rangeWidth, connectorHeight);
+		
+							// 绘制左右数字框
+							const numBoxPadding = { h: 7, v: 4 };
+							const numBoxFont = `bold 12px ${FONT_FAMILY}`;
+							
+							// 封装一个绘制数字框的函数
+							const drawNumberBox = (text, side) => {
+								ctx.font = numBoxFont;
+								const metrics = ctx.measureText(text);
+								const boxWidth = metrics.width + numBoxPadding.h * 2;
+								const boxHeight = 12 + numBoxPadding.v * 2; // 12是字号
+								
+								let x;
+								if (side === 'left') {
+									x = rangeBarX + rangeLeft - boxWidth / 2;
+								} else {
+									x = rangeBarX + rangeLeft + rangeWidth - boxWidth / 2;
+								}
+								const boxY = connectorY + (connectorHeight - boxHeight) / 2;
+								
+								// 绘制阴影
+								ctx.shadowColor = 'rgba(118, 30, 106, 0.08)';
+								ctx.shadowBlur = 10;
+								ctx.shadowOffsetY = 4;
+								
+								// 绘制圆角矩形背景
+								ctx.fillStyle = '#FFFFFF';
+								ctx.beginPath();
+								ctx.moveTo(x + 4, boxY);
+								ctx.arcTo(x + boxWidth, boxY, x + boxWidth, boxY + boxHeight, 4);
+								ctx.arcTo(x + boxWidth, boxY + boxHeight, x, boxY + boxHeight, 4);
+								ctx.arcTo(x, boxY + boxHeight, x, boxY, 4);
+								ctx.arcTo(x, boxY, x + boxWidth, boxY, 4);
+								ctx.closePath();
+								ctx.fill();
+								
+								// 重置阴影,避免影响边框
+								ctx.shadowColor = 'transparent';
+								ctx.shadowBlur = 0;
+								ctx.shadowOffsetY = 0;
+								
+								// 绘制边框
+								ctx.strokeStyle = 'rgba(131, 52, 120, 0.19)';
+								ctx.lineWidth = 1;
+								ctx.stroke();
+								
+								// 绘制文字
+								ctx.fillStyle = side === 'left' ? '#904A87' : '#199C9C';
+								ctx.textAlign = 'center';
+								ctx.textBaseline = 'middle';
+								ctx.fillText(text, x + boxWidth / 2, boxY + boxHeight / 2);
+							};
+							
+							drawNumberBox(row.range[0].toString(), 'left');
+							drawNumberBox(row.range[1].toString(), 'right');
+						});
+						
+						// --- 6. 生成图片文件 ---
+						uni.canvasToTempFilePath({
+							canvas: canvasNode,
+							success: async (result) => {
+								console.log('图片生成成功!', result.tempFilePath);
+								const fileurl = await this.uploadFilePromise(result.tempFilePath);
+								console.log(fileurl, 'fileurl');
+								resolve(fileurl);
+							},
+							fail: (err) => {
+								console.error('图片生成失败', err);
+								uni.showToast({ title: '图片生成失败', icon: 'none' });
+								reject(err);
+							}
+						}, this);
+					});
+			});
+		},
+		/**
+		 * @description 辅助函数:在Canvas中绘制可自动换行的文本
+		 * @param {CanvasRenderingContext2D} ctx
+		 * @param {string} text 要绘制的文本
+		 * @param {number} x 起始x坐标
+		 * @param {number} y 起始y坐标(对于居中对齐,这是中心y;对于top对齐,这是第一行的y)
+		 * @param {number} lineHeight 行高
+		 * @param {number} maxWidth 最大宽度
+		 */
+		drawWrappedText(ctx, text, x, y, lineHeight, maxWidth) {
+			let words = text.split('');
+			let line = '';
+			let lines = [];
+
+			for (let n = 0; n < words.length; n++) {
+				let testLine = line + words[n];
+				let metrics = ctx.measureText(testLine);
+				if (metrics.width > maxWidth && n > 0) {
+					lines.push(line);
+					line = words[n];
+				} else {
+					line = testLine;
+				}
+			}
+			lines.push(line);
+
+			let startY;
+			if (ctx.textBaseline === 'middle') {
+				startY = y - (lineHeight * (lines.length - 1)) / 2;
+			} else { // top
+				startY = y;
+			}
+			
+			for (let i = 0; i < lines.length; i++) {
+				ctx.fillText(lines[i], x, startY + (i * lineHeight));
+			}
+		},
 		calculateScaleAndPosition() {
 		    uni.getSystemInfo({
 				success: (res) => {
@@ -275,20 +598,23 @@
 			}).exec();
 		},
 		downloadZtzdfxImg(){
-			if (!this.isChartReady) return console.log('图表尚未准备好');
-			
-			const chartRef = this.$refs.ztzdfxRef;
-			if (!chartRef) return console.log('无法找到图表组件');
-			
-			chartRef.canvasToTempFilePath({
-				success: async (res) => {
-					const imgUrl = await this.uploadFilePromise(res.tempFilePath);
-					console.log(imgUrl,'imgUrl');
-				},
-				fail: (err) => {
-					console.log('生成图片失败:', err);
-				}
-			});
+			return new Promise(resolve=>{
+				if (!this.isChartReady) return console.log('图表尚未准备好');
+				
+				const chartRef = this.$refs.ztzdfxRef;
+				if (!chartRef) return console.log('无法找到图表组件');
+				
+				chartRef.canvasToTempFilePath({
+					success: async (res) => {
+						const imgUrl = await this.uploadFilePromise(res.tempFilePath);
+						console.log(imgUrl,'imgUrl');
+						resolve(imgUrl)
+					},
+					fail: (err) => {
+						console.log('生成图片失败:', err);
+					}
+				});
+			})
 		},
 		uploadFilePromise(url) {
 		  return new Promise((resolve, reject) => {
@@ -433,6 +759,23 @@
 			transform-origin: top left;
 		}
 	}
+	.offscreen-canvas {
+		position: fixed;
+		top: -9999px;
+		left: -9999px;
+	}
+
+	.pdf_btn{
+		padding: 15rpx 20rpx;
+		border-radius: 20rpx;
+		font-size: 28rpx;
+		color: #FFFFFF;
+		background: #189B9B;
+		position: fixed;
+		right: 30rpx;
+		bottom: 100rpx;
+		z-index: 1000;
+	}
 	
 	@import '../static/pdf.scss';
 </style>