import QRCode from 'qrcode'; import posterBgUrl from '@/assets/tg.jpg'; /** 海报原图像素尺寸(tg.jpg) */ export const INVITE_POSTER_WIDTH = 1080; export const INVITE_POSTER_HEIGHT = 1440; /** * 二维码放在下方白色空白区居中,下方留出话术空间。 * 空白区约 y=880~1360,水平中心 540。 */ const QR_SIZE = 340; const QR_CENTER_X = INVITE_POSTER_WIDTH / 2; const QR_CENTER_Y = 1120; const QR_PAD = 12; /** JPEG 对照片底图编码远快于 PNG,0.9 对二维码识别足够稳 */ const POSTER_JPEG_QUALITY = 0.9; export interface InvitePosterOptions { inviteUrl: string; inviteCode?: string; /** 覆盖默认话术行(居中绘制在二维码下方) */ captionLines?: string[]; } let bgImagePromise: Promise | null = null; const posterCache = new Map(); function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.decoding = 'async'; img.onload = () => resolve(img); img.onerror = () => reject(new Error('海报背景加载失败')); img.src = src; }); } /** 预热海报底图,打开「邀请好友」前调用可显著缩短首帧等待 */ export function preloadInvitePosterBg(): Promise { if (!bgImagePromise) { bgImagePromise = loadImage(posterBgUrl).catch((err) => { bgImagePromise = null; throw err; }); } return bgImagePromise; } function cacheKey(options: InvitePosterOptions, captions: string[]): string { return [options.inviteUrl.trim(), options.inviteCode ?? '', captions.join('\n')].join('|'); } function defaultCaptionLines(inviteCode?: string): string[] { void inviteCode; return ['微信扫码注册,双方有礼']; } function drawCenteredText( ctx: CanvasRenderingContext2D, text: string, centerX: number, y: number, options: { font: string; fillStyle: string; maxWidth: number }, ) { ctx.font = options.font; ctx.fillStyle = options.fillStyle; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, centerX, y, options.maxWidth); } function canvasToJpegDataUrl( canvas: HTMLCanvasElement, quality = POSTER_JPEG_QUALITY, ): Promise { return new Promise((resolve, reject) => { // toBlob 异步编码,避免 toDataURL 同步阻塞主线程 if (typeof canvas.toBlob === 'function') { canvas.toBlob( (blob) => { if (!blob) { reject(new Error('海报导出失败')); return; } resolve(URL.createObjectURL(blob)); }, 'image/jpeg', quality, ); return; } try { resolve(canvas.toDataURL('image/jpeg', quality)); } catch (err) { reject(err); } }); } /** * 将邀请链接二维码叠到推广海报空白处,并在下方绘制邀请话术,返回 JPEG blob URL(或 data URL)。 */ export async function buildInvitePosterDataUrl( inviteUrlOrOptions: string | InvitePosterOptions, ): Promise { const options: InvitePosterOptions = typeof inviteUrlOrOptions === 'string' ? { inviteUrl: inviteUrlOrOptions } : inviteUrlOrOptions; const inviteUrl = options.inviteUrl?.trim() ?? ''; if (!inviteUrl) { throw new Error('邀请链接为空'); } const captionLines = options.captionLines?.map((x) => x.trim()).filter(Boolean) ?? defaultCaptionLines(options.inviteCode); const key = cacheKey(options, captionLines); const cached = posterCache.get(key); if (cached) { return cached; } const [bg, qrDataUrl] = await Promise.all([ preloadInvitePosterBg(), QRCode.toDataURL(inviteUrl, { width: QR_SIZE, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#0a3373', light: '#ffffff' }, }), ]); const qr = await loadImage(qrDataUrl); const canvas = document.createElement('canvas'); canvas.width = INVITE_POSTER_WIDTH; canvas.height = INVITE_POSTER_HEIGHT; const ctx = canvas.getContext('2d', { alpha: false }); if (!ctx) { throw new Error('Canvas 不可用'); } ctx.drawImage(bg, 0, 0, INVITE_POSTER_WIDTH, INVITE_POSTER_HEIGHT); const x = Math.round(QR_CENTER_X - QR_SIZE / 2); const y = Math.round(QR_CENTER_Y - QR_SIZE / 2); // 轻微白底,避免贴合海报底色时对比不足 ctx.fillStyle = '#ffffff'; ctx.beginPath(); const r = 16; const bx = x - QR_PAD; const by = y - QR_PAD; const bw = QR_SIZE + QR_PAD * 2; const bh = QR_SIZE + QR_PAD * 2; ctx.moveTo(bx + r, by); ctx.arcTo(bx + bw, by, bx + bw, by + bh, r); ctx.arcTo(bx + bw, by + bh, bx, by + bh, r); ctx.arcTo(bx, by + bh, bx, by, r); ctx.arcTo(bx, by, bx + bw, by, r); ctx.closePath(); ctx.fill(); ctx.drawImage(qr, x, y, QR_SIZE, QR_SIZE); // 二维码下方邀请话术 const textStartY = y + QR_SIZE + QR_PAD + 28; const lineGap = 36; const maxWidth = 720; captionLines.forEach((line, index) => { drawCenteredText(ctx, line, QR_CENTER_X, textStartY + index * lineGap, { font: '500 30px "Microsoft YaHei", "PingFang SC", sans-serif', fillStyle: '#4e5969', maxWidth, }); }); const url = await canvasToJpegDataUrl(canvas); posterCache.set(key, url); return url; } export async function invitePosterToFile( dataUrl: string, filename = 'invite-poster.jpg', ): Promise { const res = await fetch(dataUrl); const blob = await res.blob(); return new File([blob], filename, { type: blob.type || 'image/jpeg' }); }