背景:小程序端 需要增加发票预览,数据都是接口返回的
先看要实现的效果
数据格式如下:
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>