From 7f49bc85791f03fa43253c3d01551eea28154c0f Mon Sep 17 00:00:00 2001 From: yml Date: Fri, 13 Nov 2026 21:32:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(huya):=20=E5=A2=9E=E5=8A=A0=E5=85=91?= =?UTF-8?q?=E6=8D=A2=E8=AE=B0=E5=BD=95=E7=BB=93=E6=9E=9C=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_huya_points_exchange.py | 1 + web/backend/services/huya_runner_goods.py | 11 ++ web/frontend/src/pages/HuyaElitePage.tsx | 125 +++++++++++++++++++++- web/frontend/src/utils/exchangeImage.ts | 89 +++++++++++---- 4 files changed, 204 insertions(+), 22 deletions(-) diff --git a/tests/test_huya_points_exchange.py b/tests/test_huya_points_exchange.py index d97a447..fc1ce1a 100644 --- a/tests/test_huya_points_exchange.py +++ b/tests/test_huya_points_exchange.py @@ -501,3 +501,4 @@ def test_exchange_record_query_discovers_current_sid(monkeypatch): assert marks[-1][0] == "success" assert marks[-1][2]["sid"] == 2277 assert marks[-1][2]["sid_source"] == "queryAllocation" + assert marks[-1][2]["account_snapshot"]["uid"] == "1199669523011" diff --git a/web/backend/services/huya_runner_goods.py b/web/backend/services/huya_runner_goods.py index 5421cf6..0d6c2df 100644 --- a/web/backend/services/huya_runner_goods.py +++ b/web/backend/services/huya_runner_goods.py @@ -336,6 +336,17 @@ class GoodsMixin: result["sid"] = sid_int result["sid_source"] = sid_source result["configured_sid"] = configured_sid + result["account_snapshot"] = { + "nickname": ( + getattr(account, "nickname", "") + or getattr(task, "account_nickname", "") + or "" + ), + "uid": str(getattr(account, "uid", "") or account_info.get("uid") or uid), + "game_name": getattr(account, "game_name", "") or "", + "game_channel": getattr(account, "game_channel", "") or "", + "game_phone": getattr(account, "game_phone", "") or "", + } if response.status != 200: self._mark_task( worker_db, diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 028d146..01eff87 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -6,7 +6,7 @@ import { } from 'antd'; import type { TableProps } from 'antd'; import { - CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined, + CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, DownloadOutlined, GiftOutlined, ImportOutlined, PictureOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, SwapOutlined, } from '@ant-design/icons'; import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules'; @@ -16,6 +16,12 @@ import PaymentQrQueue, { type PaymentQrQueueItem } from '../components/PaymentQr import RealtimeLogPanel from '../components/RealtimeLogPanel'; import { usePermissions } from '../hooks/usePermissions'; import { useWebSocketLogs } from '../hooks/useWebSocketLogs'; +import { + clipboardImageSupported, + copyImageToClipboard, + getExchangeResultImage, + type ExchangeResultImageData, +} from '../utils/exchangeImage'; import { getErrorMessage } from '../utils/error'; import { message } from '../utils/antdMessage'; import { formatTime, formatUnixSeconds } from '../utils/time'; @@ -230,6 +236,12 @@ export default function HuyaElitePage() { const [importSelected, setImportSelected] = useState([]); const [qrTask, setQrTask] = useState(null); const [recordsTask, setRecordsTask] = useState(null); + const [exchangeImagePreview, setExchangeImagePreview] = useState<{ + data: ExchangeResultImageData; + url: string; + blob: Blob; + } | null>(null); + const [exchangeImageLoadingKey, setExchangeImageLoadingKey] = useState(null); const [qrTaskLoading, setQrTaskLoading] = useState(false); // 小程序码图片独立保存:任务轮询只会更新轻量 tasks,不允许覆盖这张大图。 const [qrImage, setQrImage] = useState(''); @@ -639,6 +651,69 @@ export default function HuyaElitePage() { catch { message.error('复制失败,请手动选中复制'); } }; + const exchangeRecordImageData = ( + record: Record, + index: number, + ): ExchangeResultImageData => { + const snapshot = ( + recordsTask?.result?.account_snapshot + && typeof recordsTask.result.account_snapshot === 'object' + ) + ? recordsTask.result.account_snapshot as Record + : {}; + const recordKey = String(record.record_id || record.order_id || index); + return { + key: `huya:${recordsTask?.id || 'records'}:${recordKey}`, + goodsName: String(record.prize_name || '未知商品'), + goodsPic: String(record.icon || ''), + roleName: String(snapshot.game_name || ''), + channel: String(snapshot.game_channel || ''), + accountName: String(snapshot.uid || recordsTask?.account_uid || snapshot.nickname || recordsTask?.account_nickname || '-'), + exchangeTime: String(record.exchange_time_text || ''), + statusText: String(record.status_label || '兑换成功'), + footerText: '虎牙精英宝典兑换记录凭证 · 数据来自官方兑换记录', + }; + }; + + const generateExchangeRecordImage = async ( + record: Record, + index: number, + ) => { + const data = exchangeRecordImageData(record, index); + const key = String(data.key); + setExchangeImageLoadingKey(key); + try { + const generated = await getExchangeResultImage(data); + setExchangeImagePreview({ data, url: generated.url, blob: generated.blob }); + } catch (error) { + message.error(`兑换结果图生成失败:${getErrorMessage(error)}`); + } finally { + setExchangeImageLoadingKey(null); + } + }; + + const copyExchangeRecordImage = async () => { + if (!exchangeImagePreview) return; + try { + await copyImageToClipboard(exchangeImagePreview.blob); + message.success('兑换结果图已复制'); + } catch (error) { + if (clipboardImageSupported()) { + message.error(`复制图片失败:${getErrorMessage(error)}`); + } else { + message.warning('当前浏览器不支持复制图片,请使用“下载图片”'); + } + } + }; + + const downloadExchangeRecordImage = () => { + if (!exchangeImagePreview) return; + const link = document.createElement('a'); + link.href = exchangeImagePreview.url; + link.download = `虎牙兑换凭证-${exchangeImagePreview.data.goodsName}.png`; + link.click(); + }; + const viewedRecords = taskResultRecords(recordsTask); const exchangeRecordColumns: TableProps>['columns'] = [ { @@ -683,6 +758,25 @@ export default function HuyaElitePage() { render: (value: unknown) => value ? {String(value)} : '-', }, + { + title: '结果图', + width: 110, + align: 'center', + render: (_value: unknown, record, index) => { + const key = String(record.record_id || record.order_id || index); + return ( + + ); + }, + }, ]; // 列宽按比例分配(percent),表头与单元格统一居中,避免左右跳着看 @@ -751,10 +845,37 @@ export default function HuyaElitePage() { showSizeChanger: false, showTotal: (total) => `共 ${total} 条`, }} - scroll={{ x: 820 }} + scroll={{ x: 930 }} /> + setExchangeImagePreview(null)} + > + {exchangeImagePreview && ( + + 兑换结果 + + + + + + + )} + { img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = () => reject(new Error('图片加载失败')); - img.src = url; + img.src = url.startsWith('//') ? `https:${url}` : url; window.setTimeout(() => reject(new Error('图片加载超时')), timeoutMs); }); } @@ -97,15 +110,32 @@ function truncate(text: string, max: number): string { return text.length > max ? `${text.slice(0, max - 1)}…` : text; } -export async function drawExchangeImage(task: DouyuTaskItem): Promise { +function douyuImageData(task: DouyuTaskItem): ExchangeResultImageData { + const info = goodsOf(task); + return { + key: `douyu:${task.id}`, + goodsName: info.name || '未知商品', + goodsPic: info.pic, + roleName: info.roleName, + channel: info.channel, + accountName: info.accountName, + exchangeTime: formatTime(task.finished_at || task.created_at), + statusText: '兑换成功', + }; +} + +export async function drawExchangeResultImage( + data: ExchangeResultImageData, +): Promise { const canvas = document.createElement('canvas'); - canvas.width = CARD_W; - canvas.height = CARD_H; + canvas.width = CARD_W * PIXEL_RATIO; + canvas.height = CARD_H * PIXEL_RATIO; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('canvas 不可用'); + ctx.scale(PIXEL_RATIO, PIXEL_RATIO); - const info = goodsOf(task); - const time = formatTime(task.finished_at || task.created_at); + const info = data; + const time = data.exchangeTime || ''; // 背景渐变 const grad = ctx.createLinearGradient(0, 0, CARD_W, CARD_H); @@ -131,9 +161,9 @@ export async function drawExchangeImage(task: DouyuTaskItem): Promise { 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) { + if (info.goodsPic) { try { - const img = await loadImage(info.pic); + const img = await loadImage(info.goodsPic); const size = Math.min(img.width, img.height); ctx.drawImage( img, @@ -158,7 +188,7 @@ export async function drawExchangeImage(task: DouyuTaskItem): Promise { ctx.stroke(); // 商品名(最多两行) - const nameLines = wrapText(ctx, info.name || '未知商品', CARD_W - 56).slice(0, 2); + const nameLines = wrapText(ctx, info.goodsName || '未知商品', CARD_W - 56).slice(0, 2); ctx.fillStyle = '#ffffff'; ctx.font = `bold 17px ${FONT_FAMILY}`; let nameY = avatarY + avatarR + 26; @@ -194,7 +224,7 @@ export async function drawExchangeImage(task: DouyuTaskItem): Promise { } // 底部状态徽章 - const badgeText = '兑换成功'; + const badgeText = data.statusText || '兑换成功'; ctx.font = `bold 14px ${FONT_FAMILY}`; const badgeW = ctx.measureText(badgeText).width + 34; const badgeX = (CARD_W - badgeW) / 2; @@ -205,11 +235,23 @@ export async function drawExchangeImage(task: DouyuTaskItem): Promise { ctx.textAlign = 'center'; ctx.fillText(badgeText, CARD_W / 2, 444); + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.font = `10px ${FONT_FAMILY}`; + ctx.fillText( + data.footerText || '真实兑换记录凭证 · 仅用于订单核验', + CARD_W / 2, + CARD_H - 12, + ); + return new Promise((resolve, reject) => { canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('图片导出失败'))), 'image/png'); }); } +export async function drawExchangeImage(task: DouyuTaskItem): Promise { + return drawExchangeResultImage(douyuImageData(task)); +} + export async function copyImageToClipboard(blob: Blob): Promise { if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) { throw new Error('当前环境不支持复制图片(需要 HTTPS)'); @@ -226,30 +268,37 @@ interface CachedImage { blob: Blob; } -const imageCache = new Map(); -const inflightImages = new Map>(); +const imageCache = new Map(); +const inflightImages = new Map>(); -export async function getExchangeImage(task: DouyuTaskItem): Promise { - const cached = imageCache.get(task.id); +export async function getExchangeResultImage( + data: ExchangeResultImageData, +): Promise { + const cacheKey = String(data.key); + const cached = imageCache.get(cacheKey); if (cached) return cached; - const pending = inflightImages.get(task.id); + const pending = inflightImages.get(cacheKey); if (pending) return pending; const creating = (async () => { - const blob = await drawExchangeImage(task); + const blob = await drawExchangeResultImage(data); const entry = { url: URL.createObjectURL(blob), blob }; - const previous = imageCache.get(task.id); + const previous = imageCache.get(cacheKey); if (previous) URL.revokeObjectURL(previous.url); - imageCache.set(task.id, entry); + imageCache.set(cacheKey, entry); return entry; })(); - inflightImages.set(task.id, creating); + inflightImages.set(cacheKey, creating); try { return await creating; } finally { - inflightImages.delete(task.id); + inflightImages.delete(cacheKey); } } +export async function getExchangeImage(task: DouyuTaskItem): Promise { + return getExchangeResultImage(douyuImageData(task)); +} + export function clearExchangeImageCache(): void { for (const entry of imageCache.values()) URL.revokeObjectURL(entry.url); imageCache.clear();