??????????????????????????????? H5/????? Co-authored-by: Cursor <cursoragent@cursor.com>
194 lines
5.4 KiB
TypeScript
194 lines
5.4 KiB
TypeScript
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' });
|
||
}
|