front/src/utils/invitePoster.ts
zzh bd97ed76fd feat: ?????????????????/???????
??????????????????????????????? H5/?????

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 15:16:55 +08:00

194 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLImageElement> | null = null;
const posterCache = new Map<string, string>();
function loadImage(src: string): Promise<HTMLImageElement> {
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<HTMLImageElement> {
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<string> {
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<string> {
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<File> {
const res = await fetch(dataUrl);
const blob = await res.blob();
return new File([blob], filename, { type: blob.type || 'image/jpeg' });
}