- 兑换成功后自动调用积分查询,更新账号最新积分并写入任务结果 - 兑换图片并发生成竞态修复:同任务共享 in-flight Promise,避免 revoke 导致裂图 - 兑换图片移除积分展示
246 lines
7.9 KiB
TypeScript
246 lines
7.9 KiB
TypeScript
import type { DouyuTaskItem } from '../api/types';
|
||
|
||
const EXCHANGE_TASK_TYPES = new Set(['exchange_goods', 'exchange_esports_goods']);
|
||
const CARD_W = 320;
|
||
const CARD_H = 470;
|
||
const FONT_FAMILY = '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif';
|
||
|
||
export function isExchangeTask(task: DouyuTaskItem): boolean {
|
||
return EXCHANGE_TASK_TYPES.has(task.task_type);
|
||
}
|
||
|
||
export function exchangeTaskSucceeded(task: DouyuTaskItem): boolean {
|
||
return isExchangeTask(task) && task.status === 'success';
|
||
}
|
||
|
||
function rawString(raw: Record<string, unknown> | null | undefined, key: string): string {
|
||
const value = raw?.[key];
|
||
return typeof value === 'string' ? value : '';
|
||
}
|
||
|
||
function goodsOf(task: DouyuTaskItem): {
|
||
name: string;
|
||
pic: string;
|
||
roleName: string;
|
||
channel: string;
|
||
accountName: string;
|
||
} {
|
||
const goods = (task.result?.goods ?? null) as Record<string, unknown> | null;
|
||
const name = rawString(goods, 'commodityName')
|
||
|| rawString(goods, 'name')
|
||
|| task.message.replace(/^兑换[^::]*[::]?\s*/, '').trim()
|
||
|| rawString(task.result, 'commodity_id');
|
||
const pic = rawString(goods, 'webPic') || rawString(goods, 'pic');
|
||
const roleName = rawString(task.result, 'game_name');
|
||
const channel = rawString(task.result, 'game_channel');
|
||
const accountName = rawString(task.result, 'account_name')
|
||
|| task.account_nickname || task.account_username || task.account_uid || `#${task.account_id}`;
|
||
return { name, pic, roleName, channel, accountName };
|
||
}
|
||
|
||
function loadImage(url: string, timeoutMs = 8000): Promise<HTMLImageElement> {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = () => resolve(img);
|
||
img.onerror = () => reject(new Error('图片加载失败'));
|
||
img.src = url;
|
||
window.setTimeout(() => reject(new Error('图片加载超时')), timeoutMs);
|
||
});
|
||
}
|
||
|
||
function formatTime(iso: string | null | undefined): string {
|
||
if (!iso) return '';
|
||
const date = new Date(iso);
|
||
if (Number.isNaN(date.getTime())) return '';
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||
}
|
||
|
||
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
||
const lines: string[] = [];
|
||
let current = '';
|
||
for (const char of text) {
|
||
if (ctx.measureText(current + char).width > maxWidth && current) {
|
||
lines.push(current);
|
||
current = char;
|
||
} else {
|
||
current += char;
|
||
}
|
||
}
|
||
if (current) lines.push(current);
|
||
return lines;
|
||
}
|
||
|
||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||
ctx.arcTo(x, y + h, x, y, r);
|
||
ctx.arcTo(x, y, x + w, y, r);
|
||
ctx.closePath();
|
||
}
|
||
|
||
function truncate(text: string, max: number): string {
|
||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||
}
|
||
|
||
export async function drawExchangeImage(task: DouyuTaskItem): Promise<Blob> {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = CARD_W;
|
||
canvas.height = CARD_H;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) throw new Error('canvas 不可用');
|
||
|
||
const info = goodsOf(task);
|
||
const time = formatTime(task.finished_at || task.created_at);
|
||
|
||
// 背景渐变
|
||
const grad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
|
||
grad.addColorStop(0, '#0d1f3f');
|
||
grad.addColorStop(0.55, '#123a6e');
|
||
grad.addColorStop(1, '#1b4d8f');
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, CARD_W, CARD_H);
|
||
|
||
// 标题
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.font = `bold 28px ${FONT_FAMILY}`;
|
||
ctx.fillText('兑 换 成 功', CARD_W / 2, 56);
|
||
|
||
// 商品图(圆形裁剪)
|
||
const avatarR = 52;
|
||
const avatarY = 112;
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(CARD_W / 2, avatarY, avatarR, 0, Math.PI * 2);
|
||
ctx.clip();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.12)';
|
||
ctx.fillRect(CARD_W / 2 - avatarR, avatarY - avatarR, avatarR * 2, avatarR * 2);
|
||
if (info.pic) {
|
||
try {
|
||
const img = await loadImage(info.pic);
|
||
const size = Math.min(img.width, img.height);
|
||
ctx.drawImage(
|
||
img,
|
||
(img.width - size) / 2, (img.height - size) / 2, size, size,
|
||
CARD_W / 2 - avatarR, avatarY - avatarR, avatarR * 2, avatarR * 2,
|
||
);
|
||
} catch {
|
||
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
||
ctx.font = `44px ${FONT_FAMILY}`;
|
||
ctx.fillText('🎁', CARD_W / 2, avatarY + 4);
|
||
}
|
||
} else {
|
||
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
||
ctx.font = `44px ${FONT_FAMILY}`;
|
||
ctx.fillText('🎁', CARD_W / 2, avatarY + 4);
|
||
}
|
||
ctx.restore();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
||
ctx.lineWidth = 3;
|
||
ctx.beginPath();
|
||
ctx.arc(CARD_W / 2, avatarY, avatarR, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
// 商品名(最多两行)
|
||
const nameLines = wrapText(ctx, info.name || '未知商品', CARD_W - 56).slice(0, 2);
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.font = `bold 17px ${FONT_FAMILY}`;
|
||
let nameY = avatarY + avatarR + 26;
|
||
for (const line of nameLines) {
|
||
ctx.fillText(line, CARD_W / 2, nameY);
|
||
nameY += 24;
|
||
}
|
||
|
||
// 分隔线
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(32, 260);
|
||
ctx.lineTo(CARD_W - 32, 260);
|
||
ctx.stroke();
|
||
|
||
// 信息区
|
||
const infoRows: Array<[string, string]> = [];
|
||
if (info.roleName) infoRows.push(['游戏角色', info.roleName]);
|
||
if (info.channel) infoRows.push(['游戏区服', info.channel]);
|
||
infoRows.push(['兑换账号', info.accountName]);
|
||
if (time) infoRows.push(['兑换时间', time]);
|
||
|
||
ctx.font = `15px ${FONT_FAMILY}`;
|
||
let rowY = 288;
|
||
for (const [label, value] of infoRows) {
|
||
ctx.textAlign = 'left';
|
||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||
ctx.fillText(label, 36, rowY);
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillText(truncate(value, 16), 128, rowY);
|
||
rowY += 27;
|
||
}
|
||
|
||
// 底部状态徽章
|
||
const badgeText = '兑换成功';
|
||
ctx.font = `bold 14px ${FONT_FAMILY}`;
|
||
const badgeW = ctx.measureText(badgeText).width + 34;
|
||
const badgeX = (CARD_W - badgeW) / 2;
|
||
ctx.fillStyle = '#2bb673';
|
||
roundRect(ctx, badgeX, 428, badgeW, 32, 16);
|
||
ctx.fill();
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(badgeText, CARD_W / 2, 444);
|
||
|
||
return new Promise<Blob>((resolve, reject) => {
|
||
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('图片导出失败'))), 'image/png');
|
||
});
|
||
}
|
||
|
||
export async function copyImageToClipboard(blob: Blob): Promise<void> {
|
||
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) {
|
||
throw new Error('当前环境不支持复制图片(需要 HTTPS)');
|
||
}
|
||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||
}
|
||
|
||
export function clipboardImageSupported(): boolean {
|
||
return typeof ClipboardItem !== 'undefined' && Boolean(navigator.clipboard?.write);
|
||
}
|
||
|
||
interface CachedImage {
|
||
url: string;
|
||
blob: Blob;
|
||
}
|
||
|
||
const imageCache = new Map<number, CachedImage>();
|
||
const inflightImages = new Map<number, Promise<CachedImage>>();
|
||
|
||
export async function getExchangeImage(task: DouyuTaskItem): Promise<CachedImage> {
|
||
const cached = imageCache.get(task.id);
|
||
if (cached) return cached;
|
||
const pending = inflightImages.get(task.id);
|
||
if (pending) return pending;
|
||
const creating = (async () => {
|
||
const blob = await drawExchangeImage(task);
|
||
const entry = { url: URL.createObjectURL(blob), blob };
|
||
const previous = imageCache.get(task.id);
|
||
if (previous) URL.revokeObjectURL(previous.url);
|
||
imageCache.set(task.id, entry);
|
||
return entry;
|
||
})();
|
||
inflightImages.set(task.id, creating);
|
||
try {
|
||
return await creating;
|
||
} finally {
|
||
inflightImages.delete(task.id);
|
||
}
|
||
}
|
||
|
||
export function clearExchangeImageCache(): void {
|
||
for (const entry of imageCache.values()) URL.revokeObjectURL(entry.url);
|
||
imageCache.clear();
|
||
}
|