Files
live-hub-py/web/frontend/src/utils/exchangeImage.ts
T
yml2213 c95d6963b8 feat(douyu): 兑换成功后自动刷新积分 & 修复兑换图片首开裂图
- 兑换成功后自动调用积分查询,更新账号最新积分并写入任务结果
- 兑换图片并发生成竞态修复:同任务共享 in-flight Promise,避免 revoke 导致裂图
- 兑换图片移除积分展示
2026-08-01 17:28:36 +08:00

246 lines
7.9 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 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();
}