feat(douyu): 兑换成功后生成结果图片并自动复制到剪贴板
- 账号表格新增兑换图片列,展示每账号最近兑换成功的结果卡片 - 新完成兑换任务自动生成图片并复制到剪贴板(批量时最后完成者覆盖) - canvas 绘制兑换卡片:商品图/商品名/游戏角色/区服/兑换账号/时间 - 后端兑换成功结果补充 game_name/game_channel/account_name 字段 - 不支持剪贴板的环境降级为点击查看大图
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Spin } from 'antd';
|
||||
import type { DouyuTaskItem } from '../api/types';
|
||||
import { getExchangeImage } from '../utils/exchangeImage';
|
||||
|
||||
interface Props {
|
||||
task: DouyuTaskItem;
|
||||
onClick?: (task: DouyuTaskItem, url: string) => void;
|
||||
}
|
||||
|
||||
export default function ExchangeResultImage({ task, onClick }: Props) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [gen, setGen] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setFailed(false);
|
||||
getExchangeImage(task)
|
||||
.then(({ url }) => { if (alive) setUrl(url); })
|
||||
.catch(() => { if (alive) setFailed(true); });
|
||||
return () => { alive = false; };
|
||||
}, [task, gen]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={() => setGen((g) => g + 1)}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (!url) return <Spin size="small" />;
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(task, url)}
|
||||
style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }}
|
||||
title="点击查看大图并复制"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
width={56}
|
||||
height={82}
|
||||
alt="兑换结果"
|
||||
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
|
||||
CheckCircleOutlined, CopyOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
|
||||
ImportOutlined, QrcodeOutlined, ReloadOutlined,
|
||||
SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -15,8 +15,15 @@ import {
|
||||
type DouyuTaskAccountItem,
|
||||
type DouyuTaskItem,
|
||||
} from '../api/modules';
|
||||
import ExchangeResultImage from '../components/ExchangeResultImage';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
||||
import {
|
||||
clipboardImageSupported,
|
||||
copyImageToClipboard,
|
||||
exchangeTaskSucceeded,
|
||||
getExchangeImage,
|
||||
} from '../utils/exchangeImage';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { message } from '../utils/antdMessage';
|
||||
|
||||
@@ -275,6 +282,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
const autoOpenedQrTaskIds = useRef<Set<number>>(new Set());
|
||||
const autoOpenedEsportsBindTaskIds = useRef<Set<number>>(new Set());
|
||||
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
|
||||
const autoCopiedExchangeTaskIds = useRef<Set<number>>(new Set());
|
||||
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
|
||||
const autoOpenQrReady = useRef(false);
|
||||
const autoOpenEsportsBindReady = useRef(false);
|
||||
const autoOpenPayReady = useRef(false);
|
||||
@@ -358,6 +367,17 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
return map;
|
||||
}, [visibleTasks]);
|
||||
|
||||
// 每个账号最近一条兑换成功任务,用于"兑换图片"列
|
||||
const latestExchangeTaskByAccount = useMemo(() => {
|
||||
const map = new Map<number, DouyuTaskItem>();
|
||||
for (const task of visibleTasks) {
|
||||
if (!exchangeTaskSucceeded(task)) continue;
|
||||
const previous = map.get(task.account_id);
|
||||
if (!previous || task.id > previous.id) map.set(task.account_id, task);
|
||||
}
|
||||
return map;
|
||||
}, [visibleTasks]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -380,6 +400,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
autoOpenedEsportsBindTaskIds.current.add(task.id);
|
||||
}
|
||||
if (hasPaymentQrcode(task)) autoOpenedPayTaskIds.current.add(task.id);
|
||||
// 历史兑换成功任务不触发自动复制,避免页面加载时打扰
|
||||
if (exchangeTaskSucceeded(task)) autoCopiedExchangeTaskIds.current.add(task.id);
|
||||
}
|
||||
autoOpenQrReady.current = true;
|
||||
autoOpenEsportsBindReady.current = true;
|
||||
@@ -537,6 +559,41 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
if (nextPayTask) openPayTask(nextPayTask);
|
||||
}, [openPayTask, payTask, visibleTasks]);
|
||||
|
||||
// 兑换成功后自动生成结果图片并复制到剪贴板(批量时最后完成的覆盖剪贴板)
|
||||
useEffect(() => {
|
||||
if (!autoOpenQrReady.current) return;
|
||||
const pending = visibleTasks
|
||||
.filter((task) => exchangeTaskSucceeded(task) && !autoCopiedExchangeTaskIds.current.has(task.id))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
if (pending.length === 0) return;
|
||||
for (const task of pending) autoCopiedExchangeTaskIds.current.add(task.id);
|
||||
const latest = pending[pending.length - 1];
|
||||
void getExchangeImage(latest)
|
||||
.then(({ blob }) => copyImageToClipboard(blob))
|
||||
.then(
|
||||
() => message.success(`兑换图片已复制到剪贴板:${latest.account_nickname || latest.account_username || `#${latest.account_id}`}`),
|
||||
(error) => {
|
||||
if (clipboardImageSupported()) {
|
||||
message.warning('兑换图片已生成,复制到剪贴板失败');
|
||||
} else {
|
||||
message.info('当前环境不支持复制图片,请在兑换图片列点击查看');
|
||||
}
|
||||
console.warn('复制兑换图片失败', error);
|
||||
},
|
||||
);
|
||||
}, [visibleTasks]);
|
||||
|
||||
const copyExchangePreview = async () => {
|
||||
if (!exchangePreview) return;
|
||||
try {
|
||||
const { blob } = await getExchangeImage(exchangePreview.task);
|
||||
await copyImageToClipboard(blob);
|
||||
message.success('已复制到剪贴板');
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
// qrTask 已由 useMemo 从 tasks 派生,切换 Tab / 后端 progress 写入后自动更新,无需手动跟随
|
||||
|
||||
useEffect(() => {
|
||||
@@ -993,6 +1050,19 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '兑换图片', width: 80, align: 'center',
|
||||
render: (_, record) => {
|
||||
const task = latestExchangeTaskByAccount.get(record.id);
|
||||
if (!task) return <Text type="secondary">-</Text>;
|
||||
return (
|
||||
<ExchangeResultImage
|
||||
task={task}
|
||||
onClick={(t, url) => setExchangePreview({ task: t, url })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filteredAccounts = useMemo(() => {
|
||||
@@ -1713,6 +1783,41 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 兑换结果图片预览 Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<CopyOutlined />
|
||||
<span>兑换结果图片</span>
|
||||
</Space>
|
||||
}
|
||||
open={!!exchangePreview}
|
||||
onCancel={() => setExchangePreview(null)}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={() => setExchangePreview(null)}>关闭</Button>
|
||||
<Button type="primary" icon={<CopyOutlined />} onClick={copyExchangePreview}>复制图片</Button>
|
||||
</Space>
|
||||
}
|
||||
centered
|
||||
width={340}
|
||||
>
|
||||
{exchangePreview && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} align="center" size={8}>
|
||||
<img
|
||||
src={exchangePreview.url}
|
||||
alt="兑换结果"
|
||||
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{exchangePreview.task.account_nickname
|
||||
|| exchangePreview.task.account_username
|
||||
|| `#${exchangePreview.task.account_id}`}
|
||||
</Text>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
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;
|
||||
points: number | null;
|
||||
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 pointsValue = task.result?.esports_points_after_exchange;
|
||||
const points = typeof pointsValue === 'number' && Number.isFinite(pointsValue) ? pointsValue : null;
|
||||
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, points, 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 (info.points != null) infoRows.push(['当前积分', `${info.points}`]);
|
||||
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>();
|
||||
|
||||
export async function getExchangeImage(task: DouyuTaskItem): Promise<CachedImage> {
|
||||
const cached = imageCache.get(task.id);
|
||||
if (cached) return cached;
|
||||
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;
|
||||
}
|
||||
|
||||
export function clearExchangeImageCache(): void {
|
||||
for (const entry of imageCache.values()) URL.revokeObjectURL(entry.url);
|
||||
imageCache.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user