uniapp 纯前端发票预览 #发票预览

背景:小程序端 需要增加发票预览,数据都是接口返回的

先看要实现的效果

数据格式如下:

const invoiceData = {
    invoiceType: '增值税专用发票',
    invoiceCode: '123456789012',
    invoiceDate: '2023-10-01',
    buyer: {
      name: '买家公司',
      taxpayerNumber: '123456789012345678'
    },
    seller: {
      name: '卖家公司',
      taxpayerNumber: '987654321012345678'
    },
    items: [
      {
        name: '商品A',
        specification: '规格A',
        unit: '个',
        quantity: 10,
        unitPrice: 100,
        amount: 1000,
        taxRate: '13%',
        tax: 130
      },
      {
        name: '商品B',
        specification: '规格B',
        unit: '件',
        quantity: 5,
        unitPrice: 200,
        amount: 1000,
        taxRate: '13%',
        tax: 130
      }
    ],
    totalAmount1: 2000,
    totalAmount2: 2000,
    taxAmount1: 260,
    taxAmount2: '贰佰陆拾元整',
    remark: '备注信息'
  };

上组件代码

<!-- InvoiceGenerator.vue -->
<template>
	<view class="container">
		<canvas canvas-id="invoiceCanvas" class="invoice-canvas"
			:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }" @error="onCanvasError">
		</canvas>
	</view>
</template>

<script>
export default {
	name: 'InvoiceGenerator',
	props: {
		invoiceData: {
			type: Object,
			required: true,
			validator: function (value) {
				return value.invoiceType &&
					value.invoiceCode &&
					value.invoiceDate &&
					value.buyer &&
					value.seller &&
					value.items;
			}
		},
		width: {
			type: Number,
			default: 750
		},
		height: {
			type: Number,
			default: 600
		},
		autoPreview: {
			type: Boolean,
			default: false
		}
	},

	data() {
		return {
			canvasWidth: this.width,
			canvasHeight: this.height,
			previewImage: '',
			ctx: null,
			itemRowHeight: 40 // Height for each item row
		}
	},

	mounted() {
		this.ctx = uni.createCanvasContext('invoiceCanvas', this);
		if (this.autoPreview) {
			this.handlePreview();
		}
	},

	methods: {
		drawTextWithWrap(text, x, y, maxWidth, lineHeight) {
			const ctx = this.ctx;
			let currentText = '';
			let currentLine = 0;

			const chars = text.split('');

			for (let i = 0; i < chars.length; i++) {
				currentText += chars[i];
				const currentWidth = ctx.measureText(currentText).width;

				if (currentWidth > maxWidth) {
					currentText = currentText.slice(0, -1);
					ctx.fillText(currentText, x, y + (currentLine * lineHeight));
					currentText = chars[i];
					currentLine++;
				}
			}

			if (currentText) {
				ctx.fillText(currentText, x, y + (currentLine * lineHeight));
			}

			return currentLine + 1;
		},

		drawVerticalText(text, x, y, height) {
			const ctx = this.ctx;
			const fontSize = 14;
			const chars = text.split('');
			const textHeight = chars.length * (fontSize + 5) - 5;
			const startY = y + (height - textHeight) / 2;

			chars.forEach((char, index) => {
				ctx.fillText(char, x, startY + (index * (fontSize + 5)));
			});
		},

		drawInvoice() {
			const ctx = this.ctx;
			const width = this.canvasWidth;
			const height = this.canvasHeight;
			const contentStartX = 30;
			const contentWidth = width - 60;

			// 清空画布并设置背景色
			ctx.setFillStyle('#FFFFFF');
			ctx.fillRect(0, 0, width, height);

			let currentY = 50;

			// 绘制标题
			ctx.setFontSize(24);
			ctx.setTextAlign('center');
			ctx.setFillStyle('#000000');
			ctx.fillText(this.invoiceData.invoiceType, width / 2, currentY);

			// 计算右侧信息的起始位置
			const titleText = this.invoiceData.invoiceType;
			const approximateTitleWidth = titleText.length * 24;
			const infoStartX = (width / 2) + (approximateTitleWidth / 2) + 20;

			// 绘制发票信息
			ctx.setFontSize(14);
			ctx.setTextAlign('left');
			ctx.fillText(`发票号码:${this.invoiceData.invoiceCode}`, infoStartX, 30);
			ctx.fillText(`开票日期:${this.invoiceData.invoiceDate}`, infoStartX, 50);

			// 绘制购买方和销售方信息框
			currentY = 100;
			const partyInfoHeight = 160;
			ctx.setStrokeStyle('#936d2e');

			// 绘制外框和分隔线
			ctx.strokeRect(contentStartX, currentY, contentWidth, partyInfoHeight);
			const middleX = contentStartX + contentWidth / 2;
			ctx.beginPath();
			ctx.moveTo(middleX, currentY);
			ctx.lineTo(middleX, currentY + partyInfoHeight);
			ctx.stroke();

			const labelWidth = 40;

			// 标签分隔线
			const buyerLabelX = contentStartX + labelWidth;
			const sellerLabelX = middleX + labelWidth;
			[buyerLabelX, sellerLabelX].forEach(x => {
				ctx.beginPath();
				ctx.moveTo(x, currentY);
				ctx.lineTo(x, currentY + partyInfoHeight);
				ctx.stroke();
			});

			// 绘制购买方和销售方标签
			ctx.setFontSize(14);
			ctx.setFillStyle('#000000');
			this.drawVerticalText('购买方信息', contentStartX + 15, currentY, partyInfoHeight);
			this.drawVerticalText('销售方信息', middleX + 15, currentY, partyInfoHeight);

			// 绘制双方信息
			const contentPadding = 15;
			[
				{
					info: [
						`名称:${this.invoiceData.buyer.name}`,
						`纳税人识别号:${this.invoiceData.buyer.taxpayerNumber}`
					],
					startX: buyerLabelX
				},
				{
					info: [
						`名称:${this.invoiceData.seller.name}`,
						`纳税人识别号:${this.invoiceData.seller.taxpayerNumber}`
					],
					startX: sellerLabelX
				}
			].forEach(({ info, startX }) => {
				info.forEach((text, index) => {
					ctx.fillText(text, startX + 10, currentY + contentPadding + index * 30 + 15);
				});
			});

			currentY += partyInfoHeight;

			// 计算列宽
			const totalWidth = contentWidth;
			let remainingWidth = totalWidth;
			const columnPercentages = [0.25, 0.15, 0.08, 0.08, 0.12, 0.12, 0.08, 0.12];
			const columnWidths = columnPercentages.map((percentage, index) => {
				if (index === columnPercentages.length - 1) {
					return remainingWidth;
				}
				const width = Math.floor(totalWidth * percentage);
				remainingWidth -= width;
				return width;
			});

			// 绘制表头
			ctx.beginPath();
			ctx.moveTo(contentStartX, currentY);
			ctx.lineTo(contentStartX, currentY + 40);
			ctx.moveTo(contentStartX + contentWidth, currentY);
			ctx.lineTo(contentStartX + contentWidth, currentY + 40);
			ctx.stroke();

			let currentX = contentStartX;
			const headers = ['项目名称', '规格型号', '单位', '数量', '单价', '金额', '税率', '税额'];
			ctx.setFillStyle('#936d2e');
			// 绘制表头并处理换行
			headers.forEach((header, index) => {
				const cellWidth = columnWidths[index] - 10;
				const headerX = currentX + columnWidths[index] / 2;

				// 根据列的类型设置不同的对齐方式
				if (index <= 0) {
					ctx.setTextAlign('left');
					this.drawTextWithWrap(
						header,
						currentX + 5,
						currentY + 25,
						cellWidth,
						20
					);
				} else {
					ctx.setTextAlign('center');
					ctx.fillText(header, headerX, currentY + 30);
				}

				currentX += columnWidths[index];
			});

			// 绘制商品内容
			currentY += 40;
			const items = this.invoiceData.items;
			items.forEach((item, itemIndex) => {
				currentX = contentStartX;
				let maxLines = 1;
				const contents = [
					item.name,
					item.specification,
					item.unit,
					item.quantity,
					item.unitPrice,
					item.amount,
					item.taxRate,
					item.tax
				];
				// 预计算最大行数
				contents.forEach((content, index) => {
					if (index <= 0) {
						const cellWidth = columnWidths[index] - 10;
						const metrics = ctx.measureText(content ? content.toString() : '');
						const lines = Math.ceil(metrics.width / cellWidth);
						maxLines = Math.max(maxLines, lines);
					}
				});

				// 调整行高
				const rowHeight = Math.max(this.itemRowHeight, maxLines * 20 + 20);


				// 绘制项目行边框
				ctx.beginPath();
				ctx.moveTo(contentStartX, currentY);
				ctx.lineTo(contentStartX, currentY + this.itemRowHeight);
				ctx.moveTo(contentStartX + contentWidth, currentY);
				ctx.lineTo(contentStartX + contentWidth, currentY + this.itemRowHeight);
				ctx.stroke();

				// 绘制内容
				currentX = contentStartX;
				ctx.setFillStyle('#000000');
				contents.forEach((content, index) => {
					if (index === 0) {
						ctx.setTextAlign('left');
						ctx.fillText(content, currentX + 5, currentY + 25);
					} else {
						ctx.setTextAlign('center');
						ctx.fillText(content, currentX + columnWidths[index] / 2, currentY + 25);
					}
					currentX += columnWidths[index];
				});

				currentY += this.itemRowHeight;
			});

			// 绘制合计行
			ctx.beginPath();
			ctx.moveTo(contentStartX, currentY);
			ctx.lineTo(contentStartX, currentY + 40);
			ctx.moveTo(contentStartX + contentWidth, currentY);
			ctx.lineTo(contentStartX + contentWidth, currentY + 40);
			ctx.stroke();

			ctx.setTextAlign('left');
			ctx.fillText('合计', contentStartX + 10, currentY + 25);

			// 合计金额靠右对齐
			ctx.setTextAlign('right');
			const amountX = contentStartX + columnWidths[0] + columnWidths[1] + columnWidths[2] + columnWidths[3] + columnWidths[4];
			const taxX = contentStartX + contentWidth;
			ctx.fillText(this.invoiceData.totalAmount1, amountX + columnWidths[5] - 5, currentY + 25);
			ctx.fillText(this.invoiceData.totalAmount2, taxX - 5, currentY + 25);

			// 绘制价税合计
			currentY += 40;
			ctx.strokeRect(contentStartX, currentY, contentWidth, 40);
			ctx.setTextAlign('left');
			ctx.fillText(`价税合计(大写):${this.invoiceData.taxAmount2}`, contentStartX + 10, currentY + 25);
			ctx.setTextAlign('right');
			ctx.fillText(`(小写)${this.invoiceData.taxAmount1}`, width - 40, currentY + 25);

			// 绘制备注栏
			currentY += 40;
			const remarkHeight = 80;
			ctx.strokeRect(contentStartX, currentY, contentWidth, remarkHeight);
			this.drawVerticalText('备注', contentStartX + 20, currentY, remarkHeight);

			ctx.beginPath();
			ctx.moveTo(contentStartX + labelWidth, currentY);
			ctx.lineTo(contentStartX + labelWidth, currentY + remarkHeight);
			ctx.stroke();

			// 备注内容
			ctx.setFontSize(14);
			ctx.setTextAlign('left');
			ctx.setFillStyle('#000000');
			const availableWidth = contentWidth - labelWidth - 20;
			const remarkText = this.invoiceData.remark || '';

			this.drawTextWithWrap(
				remarkText,
				contentStartX + labelWidth + 10,
				currentY + 25,
				availableWidth,
				20
			);

			// 完成绘制
			ctx.draw(false, () => {
				this.canvasToImage();
			});
		},

		canvasToImage() {
			uni.canvasToTempFilePath({
				canvasId: 'invoiceCanvas',
				success: (res) => {
					this.previewImage = res.tempFilePath;
					this.$emit('onSuccess', {
						path: this.previewImage
					});

					uni.previewImage({
						urls: [this.previewImage],
						current: 0,
						success: () => {
							console.log('预览成功');
						},
						fail: (err) => {
							console.error('预览失败:', err);
							this.$emit('onError', {
								type: 'preview',
								error: err
							});
							uni.showToast({
								title: '预览失败',
								icon: 'none'
							});
						}
					});
				},
				fail: (error) => {
					console.error('转换图片失败:', error);
					this.$emit('onError', {
						type: 'convert',
						error: error
					});
					uni.showToast({
						title: '转换失败',
						icon: 'none'
					});
				}
			}, this);
		},

		handlePreview() {
			try {
				this.drawInvoice();
			} catch (error) {
				console.error('生成发票失败:', error);
				this.$emit('onError', {
					type: 'generate',
					error: error
				});
				uni.showToast({
					title: '生成失败',
					icon: 'none'
				});
			}
		},

		onCanvasError(e) {
			console.error('Canvas错误:', e);
			this.$emit('onError', {
				type: 'canvas',
				error: e
			});
			uni.showToast({
				title: 'Canvas错误',
				icon: 'none'
			});
		}
	}
}
</script>

<style>
.container {
	padding: 30rpx;
}

.invoice-canvas {
	position: fixed;
	left: -9999px;
	visibility: hidden;
}
</style>

父组件调用

<template>
  <view class="parent-container">
    <button @click="generateInvoice">生成发票</button>
    <invoice-generator
      v-if="showInvoice"
      :invoice-data="invoiceData"
      :width="800"
      :height="1000"
      :auto-preview="true"
      @onSuccess="handleSuccess"
      @onError="handleError"
    ></invoice-generator>
  </view>
</template>

<script>
import InvoiceGenerator from './components/InvoiceGenerator.vue';

export default {
  name: 'ParentComponent',
  components: {
    InvoiceGenerator
  },
  data() {
    return {
      showInvoice: false,
      invoiceData: {
        invoiceType: '增值税专用发票',
        invoiceCode: '123456789012',
        invoiceDate: '2023-10-01',
        buyer: {
          name: '买方公司',
          taxpayerNumber: '987654321098765432'
        },
        seller: {
          name: '卖方公司',
          taxpayerNumber: '123456789012345678'
        },
        items: [
          {
            name: '商品A',
            specification: '规格一',
            unit: '个',
            quantity: 2,
            unitPrice: 100.00,
            amount: 200.00,
            taxRate: '13%',
            tax: 26.00
          },
          // 可以添加更多商品项...
        ],
        totalAmount1: 200.00,
        totalAmount2: 26.00,
        taxAmount1: 226.00,
        taxAmount2: '贰佰贰拾陆元整',
        remark: '这是备注信息'
      }
    };
  },
  methods: {
    generateInvoice() {
      this.showInvoice = true;
    },
    handleSuccess(data) {
      console.log('发票生成成功,图片路径:', data.path);
      // 这里可以执行其他操作,比如保存图片路径到数据库等
    },
    handleError(error) {
      console.error('发票生成失败:', error);
      // 可以显示错误信息给用户或者采取其他措施
    }
  }
};
</script>

<style scoped>
.parent-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 20px;
}
</style>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值