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 | 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 | 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 { 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 { 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((resolve, reject) => { canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('图片导出失败'))), 'image/png'); }); } export async function copyImageToClipboard(blob: Blob): Promise { 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(); const inflightImages = new Map>(); export async function getExchangeImage(task: DouyuTaskItem): Promise { 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(); }