356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
import { BarcodeFormat, EncodeHintType, QRCodeWriter } from "@zxing/library";
|
|
import { Time } from "timi-web";
|
|
|
|
export interface QrCodePage {
|
|
pageIndex: number;
|
|
codes: string[];
|
|
}
|
|
|
|
export interface DownloadQrCodeOptions {
|
|
codes: string[];
|
|
filePrefix: string;
|
|
maxCodeLength?: number;
|
|
}
|
|
|
|
export interface PrintQrCodeOptions {
|
|
codes: string[];
|
|
maxCodeLength?: number;
|
|
title?: string;
|
|
}
|
|
|
|
export interface QrCodeGrid {
|
|
rows: number;
|
|
columns: number;
|
|
pageSize: number;
|
|
}
|
|
|
|
export interface InvitePosterOptions {
|
|
code: string;
|
|
storeName: string;
|
|
roleName: string;
|
|
expireAt: number;
|
|
}
|
|
|
|
const pageWidth = 2480;
|
|
const pageHeight = 3508;
|
|
const pagePadding = 80;
|
|
const qrSize = 300;
|
|
const minCellGap = 18;
|
|
const codeFontSize = 34;
|
|
const codeTopGap = 3;
|
|
const gridLineWidth = 2;
|
|
const codeTextHorizontalPadding = 28;
|
|
const codeTextWidthRatio = .62;
|
|
const qrWriter = new QRCodeWriter();
|
|
const qrEncodeHints = new Map<EncodeHintType, string | number>([
|
|
[EncodeHintType.ERROR_CORRECTION, "H"],
|
|
[EncodeHintType.CHARACTER_SET, "UTF-8"],
|
|
[EncodeHintType.MARGIN, 4]
|
|
]);
|
|
|
|
export function calculateQrCodeGrid(maxCodeLength = 0): QrCodeGrid {
|
|
const codeTextMinWidth = Math.ceil(maxCodeLength * codeFontSize * codeTextWidthRatio + codeTextHorizontalPadding);
|
|
const cellMinWidth = Math.max(qrSize + minCellGap, codeTextMinWidth + minCellGap);
|
|
const cellMinHeight = qrSize + codeTopGap + codeFontSize + minCellGap;
|
|
const columns = Math.max(1, Math.floor((pageWidth - pagePadding * 2) / cellMinWidth));
|
|
const rows = Math.max(1, Math.floor((pageHeight - pagePadding * 2) / cellMinHeight));
|
|
|
|
return {
|
|
rows,
|
|
columns,
|
|
pageSize: rows * columns
|
|
};
|
|
}
|
|
|
|
export function splitQrCodePages(codes: string[], maxCodeLength = 0): QrCodePage[] {
|
|
const pages: QrCodePage[] = [];
|
|
const pageSize = calculateQrCodeGrid(maxCodeLength).pageSize;
|
|
|
|
for (let index = 0; index < codes.length; index += pageSize) {
|
|
pages.push({
|
|
pageIndex: pages.length,
|
|
codes: codes.slice(index, index + pageSize)
|
|
});
|
|
}
|
|
|
|
return pages;
|
|
}
|
|
|
|
export async function downloadQrCodeImages(options: DownloadQrCodeOptions): Promise<void> {
|
|
const pages = splitQrCodePages(options.codes, options.maxCodeLength);
|
|
|
|
for (const page of pages) {
|
|
const canvas = renderQrCodePage(page.codes, options.maxCodeLength);
|
|
await downloadCanvas(canvas, `${options.filePrefix}-${String(page.pageIndex + 1).padStart(2, "0")}.png`);
|
|
}
|
|
}
|
|
|
|
export async function downloadInvitePoster(options: InvitePosterOptions, fileName: string): Promise<void> {
|
|
await downloadCanvas(renderInvitePoster(options), fileName);
|
|
}
|
|
|
|
export function createInvitePosterImage(options: InvitePosterOptions): string {
|
|
return renderInvitePoster(options).toDataURL("image/png");
|
|
}
|
|
|
|
export async function printQrCodeImages(options: PrintQrCodeOptions): Promise<void> {
|
|
const printWindow = window.open("", "_blank");
|
|
if (!printWindow) {
|
|
throw new Error("浏览器阻止了打印窗口,请允许弹出窗口后重试");
|
|
}
|
|
|
|
const pages = splitQrCodePages(options.codes, options.maxCodeLength);
|
|
const imageList = pages.map(page => renderQrCodePage(page.codes, options.maxCodeLength).toDataURL("image/png"));
|
|
const title = options.title || "客户二维码打印";
|
|
const imageHTML = imageList.map(src => `<section class="page"><img src="${src}" alt="客户二维码" /></section>`).join("");
|
|
|
|
printWindow.document.open();
|
|
printWindow.document.write(`<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>${escapeHTML(title)}</title>
|
|
<style>
|
|
@page {
|
|
size: A4 portrait;
|
|
margin: 0;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
background: #fff;
|
|
}
|
|
|
|
.page {
|
|
margin: 0;
|
|
padding: 0;
|
|
width: 210mm;
|
|
height: 297mm;
|
|
display: flex;
|
|
overflow: hidden;
|
|
page-break-after: always;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.page:last-child {
|
|
page-break-after: auto;
|
|
}
|
|
|
|
img {
|
|
width: 210mm;
|
|
height: 297mm;
|
|
display: block;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>${imageHTML}</body>
|
|
</html>`);
|
|
printWindow.document.close();
|
|
await waitPrintImagesLoaded(printWindow.document);
|
|
printWindow.focus();
|
|
printWindow.print();
|
|
}
|
|
|
|
function renderQrCodePage(codes: string[], maxCodeLength = 0): HTMLCanvasElement {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = pageWidth;
|
|
canvas.height = pageHeight;
|
|
const grid = calculateQrCodeGrid(maxCodeLength);
|
|
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
throw new Error("当前浏览器不支持 Canvas");
|
|
}
|
|
|
|
context.fillStyle = "#ffffff";
|
|
context.fillRect(0, 0, pageWidth, pageHeight);
|
|
context.fillStyle = "#000000";
|
|
context.font = `500 ${codeFontSize}px Arial, sans-serif`;
|
|
context.textAlign = "center";
|
|
context.textBaseline = "top";
|
|
|
|
const cellWidth = (pageWidth - pagePadding * 2) / grid.columns;
|
|
const cellHeight = (pageHeight - pagePadding * 2) / grid.rows;
|
|
|
|
drawGridLines(context, grid, cellWidth, cellHeight);
|
|
|
|
codes.forEach((code, index) => {
|
|
const rowIndex = Math.floor(index / grid.columns);
|
|
const columnIndex = index % grid.columns;
|
|
const cellLeft = pagePadding + columnIndex * cellWidth;
|
|
const cellTop = pagePadding + rowIndex * cellHeight;
|
|
const qrLeft = Math.round(cellLeft + (cellWidth - qrSize) / 2);
|
|
const qrTop = Math.round(cellTop + (cellHeight - qrSize - codeTopGap - codeFontSize) / 2);
|
|
|
|
drawQrCode(context, code, qrLeft, qrTop, qrSize);
|
|
context.fillStyle = "#000000";
|
|
context.fillText(code, cellLeft + cellWidth / 2, qrTop + qrSize + codeTopGap);
|
|
});
|
|
|
|
return canvas;
|
|
}
|
|
|
|
function renderInvitePoster(options: InvitePosterOptions): HTMLCanvasElement {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 1200;
|
|
canvas.height = 1820;
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
throw new Error("当前浏览器不支持 Canvas");
|
|
}
|
|
|
|
const teal = "#0F766E";
|
|
const darkTeal = "#115E59";
|
|
context.fillStyle = "#F0FDFA";
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
context.fillStyle = teal;
|
|
context.fillRect(0, 0, canvas.width, 640);
|
|
context.fillStyle = "#CCFBF1";
|
|
context.beginPath();
|
|
context.arc(1140, 40, 220, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.fillStyle = "#FFF";
|
|
context.textAlign = "left";
|
|
context.textBaseline = "middle";
|
|
context.font = "700 80px Microsoft YaHei, sans-serif";
|
|
context.fillText("邀请您加入", 64, 120);
|
|
context.font = "700 110px Microsoft YaHei, sans-serif";
|
|
context.fillText(options.storeName, 64, 280);
|
|
context.font = "700 64px Microsoft YaHei, sans-serif";
|
|
context.fillText(`成为:${options.roleName}`, 64, 420);
|
|
|
|
context.fillStyle = "#FFFFFF";
|
|
context.shadowColor = "rgba(15, 118, 110, .18)";
|
|
context.shadowBlur = 36;
|
|
context.shadowOffsetY = 12;
|
|
context.beginPath();
|
|
context.roundRect(120, 520, 960, 960, 40);
|
|
context.fill();
|
|
context.shadowColor = "transparent";
|
|
context.shadowBlur = 0;
|
|
context.shadowOffsetY = 0;
|
|
|
|
const qrSize = 920;
|
|
const qrLeft = (canvas.width - qrSize) / 2;
|
|
const qrTop = 540;
|
|
drawQrCode(context, options.code, qrLeft, qrTop, qrSize);
|
|
context.textAlign = "center";
|
|
context.fillStyle = "#64748B";
|
|
context.font = "400 40px Microsoft YaHei, sans-serif";
|
|
context.fillText(`有效期至 ${Time.toDateTime(options.expireAt)}`, 600, 1430);
|
|
context.fillStyle = darkTeal;
|
|
context.font = "400 52px Microsoft YaHei, sans-serif";
|
|
context.fillText("糕店客户管理系统", 600, 1550);
|
|
context.fillStyle = "#64748B";
|
|
context.font = "400 40px Microsoft YaHei, sans-serif";
|
|
context.fillText("客户档案 | 事件登记 | 积分激励 | 门店协同", 600, 1645);
|
|
context.fillStyle = "#94A3B8";
|
|
context.font = "400 40px Microsoft YaHei, sans-serif";
|
|
context.fillText("本邀请仅限本人使用,请勿转发", 600, 1760);
|
|
|
|
return canvas;
|
|
}
|
|
|
|
async function waitPrintImagesLoaded(document: Document): Promise<void> {
|
|
const imageList = Array.from(document.images);
|
|
await Promise.all(imageList.map(image => {
|
|
if (image.complete) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
image.onload = () => resolve();
|
|
image.onerror = () => reject(new Error("打印图片加载失败"));
|
|
});
|
|
}));
|
|
}
|
|
|
|
function escapeHTML(value: string): string {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll("\"", """);
|
|
}
|
|
|
|
function drawGridLines(context: CanvasRenderingContext2D, grid: QrCodeGrid, cellWidth: number, cellHeight: number): void {
|
|
context.strokeStyle = "#d1d5db";
|
|
context.lineWidth = gridLineWidth;
|
|
|
|
for (let rowIndex = 0; rowIndex <= grid.rows; rowIndex++) {
|
|
const top = Math.round(pagePadding + rowIndex * cellHeight);
|
|
context.beginPath();
|
|
context.moveTo(pagePadding, top);
|
|
context.lineTo(pageWidth - pagePadding, top);
|
|
context.stroke();
|
|
}
|
|
|
|
for (let columnIndex = 0; columnIndex <= grid.columns; columnIndex++) {
|
|
const left = Math.round(pagePadding + columnIndex * cellWidth);
|
|
context.beginPath();
|
|
context.moveTo(left, pagePadding);
|
|
context.lineTo(left, pageHeight - pagePadding);
|
|
context.stroke();
|
|
}
|
|
}
|
|
|
|
function drawQrCode(context: CanvasRenderingContext2D, code: string, left: number, top: number, qrSize: number): void {
|
|
const matrix = qrWriter.encode(code, BarcodeFormat.QR_CODE, qrSize, qrSize, qrEncodeHints);
|
|
const width = matrix.getWidth();
|
|
const height = matrix.getHeight();
|
|
const sourceCanvas = document.createElement("canvas");
|
|
sourceCanvas.width = width;
|
|
sourceCanvas.height = height;
|
|
const sourceContext = sourceCanvas.getContext("2d");
|
|
|
|
if (!sourceContext) {
|
|
throw new Error("当前浏览器不支持 Canvas");
|
|
}
|
|
|
|
sourceContext.fillStyle = "#ffffff";
|
|
sourceContext.fillRect(0, 0, width, height);
|
|
sourceContext.fillStyle = "#000000";
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
if (matrix.get(x, y)) {
|
|
sourceContext.fillRect(x, y, 1, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
context.fillStyle = "#ffffff";
|
|
context.fillRect(left, top, qrSize, qrSize);
|
|
context.imageSmoothingEnabled = false;
|
|
context.drawImage(sourceCanvas, left, top, qrSize, qrSize);
|
|
context.imageSmoothingEnabled = true;
|
|
}
|
|
|
|
async function downloadCanvas(canvas: HTMLCanvasElement, fileName: string): Promise<void> {
|
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
canvas.toBlob(result => {
|
|
if (result) {
|
|
resolve(result);
|
|
return;
|
|
}
|
|
|
|
reject(new Error("图片生成失败"));
|
|
}, "image/png");
|
|
});
|
|
const link = document.createElement("a");
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
link.href = url;
|
|
link.download = fileName;
|
|
link.click();
|
|
|
|
await new Promise<void>(resolve => {
|
|
window.setTimeout(resolve, 80);
|
|
});
|
|
|
|
URL.revokeObjectURL(url);
|
|
}
|