From 592eef358251a33687164efb22ed417d8b5cc9d9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 1 Aug 2026 13:23:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(douyu):=20=E5=85=91=E6=8D=A2=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E5=90=8E=E7=94=9F=E6=88=90=E7=BB=93=E6=9E=9C=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=B9=B6=E8=87=AA=E5=8A=A8=E5=A4=8D=E5=88=B6=E5=88=B0?= =?UTF-8?q?=E5=89=AA=E8=B4=B4=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 账号表格新增兑换图片列,展示每账号最近兑换成功的结果卡片 - 新完成兑换任务自动生成图片并复制到剪贴板(批量时最后完成者覆盖) - canvas 绘制兑换卡片:商品图/商品名/游戏角色/区服/兑换账号/时间 - 后端兑换成功结果补充 game_name/game_channel/account_name 字段 - 不支持剪贴板的环境降级为点击查看大图 --- web/backend/services/douyu_runner.py | 11 +- .../src/components/ExchangeResultImage.tsx | 52 ++++ web/frontend/src/pages/DouyuTasksPage.tsx | 107 +++++++- web/frontend/src/utils/exchangeImage.ts | 238 ++++++++++++++++++ 4 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 web/frontend/src/components/ExchangeResultImage.tsx create mode 100644 web/frontend/src/utils/exchangeImage.ts 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={ + + + + + } + 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(); +}