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.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 { 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 { await downloadCanvas(renderInvitePoster(options), fileName); } export function createInvitePosterImage(options: InvitePosterOptions): string { return renderInvitePoster(options).toDataURL("image/png"); } export async function printQrCodeImages(options: PrintQrCodeOptions): Promise { 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 => `
客户二维码
`).join(""); printWindow.document.open(); printWindow.document.write(` ${escapeHTML(title)} ${imageHTML} `); 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 { const imageList = Array.from(document.images); await Promise.all(imageList.map(image => { if (image.complete) { return Promise.resolve(); } return new Promise((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 { const blob = await new Promise((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(resolve => { window.setTimeout(resolve, 80); }); URL.revokeObjectURL(url); }