diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py
index b63d8fa..dd29a99 100644
--- a/web/backend/services/douyu_runner.py
+++ b/web/backend/services/douyu_runner.py
@@ -1835,7 +1835,13 @@ class DouyuBatchRunner:
task,
"success",
f"兑换成功: {(goods.name if goods else '') or commodity_id}",
- {"goods": goods.raw if goods else None, **result},
+ {
+ "goods": goods.raw if goods else None,
+ "game_name": account.game_name or "",
+ "game_channel": account.game_channel or "",
+ "account_name": account.nickname or account.username or account.uid or f"#{account.id}",
+ **result,
+ },
)
def _execute_exchange_esports_goods(
@@ -1901,6 +1907,9 @@ class DouyuBatchRunner:
message += f" x{quantity}"
if account.esports_points is not None:
message += f",电竞积分: {account.esports_points}"
+ result["game_name"] = account.esports_game_name or ""
+ result["game_channel"] = account.esports_game_channel or ""
+ result["account_name"] = account.nickname or account.username or account.uid or f"#{account.id}"
self._mark_task(db, task, "success", message, result)
def _execute_query_game_name(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
diff --git a/web/frontend/src/components/ExchangeResultImage.tsx b/web/frontend/src/components/ExchangeResultImage.tsx
new file mode 100644
index 0000000..79bc773
--- /dev/null
+++ b/web/frontend/src/components/ExchangeResultImage.tsx
@@ -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 (
+
+ );
+ }
+ if (!url) return ;
+ return (
+
onClick?.(task, url)}
+ style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }}
+ title="点击查看大图并复制"
+ >
+

+
+ );
+}
diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx
index ba06338..91fdee0 100644
--- a/web/frontend/src/pages/DouyuTasksPage.tsx
+++ b/web/frontend/src/pages/DouyuTasksPage.tsx
@@ -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>(new Set());
const autoOpenedEsportsBindTaskIds = useRef>(new Set());
const autoOpenedPayTaskIds = useRef>(new Set());
+ const autoCopiedExchangeTaskIds = useRef>(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();
+ 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 -;
+ return (
+ setExchangePreview({ task: t, url })}
+ />
+ );
+ },
+ },
];
const filteredAccounts = useMemo(() => {
@@ -1713,6 +1783,41 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
)}
+
+ {/* 兑换结果图片预览 Modal */}
+
+
+ 兑换结果图片
+
+ }
+ open={!!exchangePreview}
+ onCancel={() => setExchangePreview(null)}
+ footer={
+
+
+ } onClick={copyExchangePreview}>复制图片
+
+ }
+ centered
+ width={340}
+ >
+ {exchangePreview && (
+
+
+
+ {exchangePreview.task.account_nickname
+ || exchangePreview.task.account_username
+ || `#${exchangePreview.task.account_id}`}
+
+
+ )}
+
);
}
diff --git a/web/frontend/src/utils/exchangeImage.ts b/web/frontend/src/utils/exchangeImage.ts
new file mode 100644
index 0000000..ed27858
--- /dev/null
+++ b/web/frontend/src/utils/exchangeImage.ts
@@ -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 | 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 | 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 {
+ 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 (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((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();
+
+export async function getExchangeImage(task: DouyuTaskItem): Promise {
+ 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();
+}