feat(huya): 增加兑换记录结果图

This commit is contained in:
yml
2026-11-13 21:32:34 +08:00
parent 8ba947e60f
commit 7f49bc8579
4 changed files with 204 additions and 22 deletions
+1
View File
@@ -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"
+11
View File
@@ -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,
+123 -2
View File
@@ -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<number[]>([]);
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
const [recordsTask, setRecordsTask] = useState<HuyaTaskItem | null>(null);
const [exchangeImagePreview, setExchangeImagePreview] = useState<{
data: ExchangeResultImageData;
url: string;
blob: Blob;
} | null>(null);
const [exchangeImageLoadingKey, setExchangeImageLoadingKey] = useState<string | null>(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<string, unknown>,
index: number,
): ExchangeResultImageData => {
const snapshot = (
recordsTask?.result?.account_snapshot
&& typeof recordsTask.result.account_snapshot === 'object'
)
? recordsTask.result.account_snapshot as Record<string, unknown>
: {};
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<string, unknown>,
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<Record<string, unknown>>['columns'] = [
{
@@ -683,6 +758,25 @@ export default function HuyaElitePage() {
render: (value: unknown) =>
value ? <Text copyable={{ text: String(value) }}>{String(value)}</Text> : '-',
},
{
title: '结果图',
width: 110,
align: 'center',
render: (_value: unknown, record, index) => {
const key = String(record.record_id || record.order_id || index);
return (
<Button
size="small"
type="link"
icon={<PictureOutlined />}
loading={exchangeImageLoadingKey === `huya:${recordsTask?.id || 'records'}:${key}`}
onClick={() => void generateExchangeRecordImage(record, index)}
>
结果图
</Button>
);
},
},
];
// 列宽按比例分配(percent),表头与单元格统一居中,避免左右跳着看
@@ -751,10 +845,37 @@ export default function HuyaElitePage() {
showSizeChanger: false,
showTotal: (total) => `共 ${total} 条`,
}}
scroll={{ x: 820 }}
scroll={{ x: 930 }}
/>
</Space>
</Modal>
<Modal
open={exchangeImagePreview !== null}
title="兑换结果图"
width={390}
centered
footer={null}
onCancel={() => setExchangeImagePreview(null)}
>
{exchangeImagePreview && (
<Space direction="vertical" size={12} align="center" style={{ width: '100%' }}>
<img
src={exchangeImagePreview.url}
alt="兑换结果"
style={{ width: '100%', borderRadius: 8, display: 'block' }}
/>
<Space wrap style={{ justifyContent: 'center' }}>
<Button icon={<CopyOutlined />} onClick={() => void copyExchangeRecordImage()}>
复制图片
</Button>
<Button icon={<DownloadOutlined />} onClick={downloadExchangeRecordImage}>
下载图片
</Button>
<Button onClick={() => setExchangeImagePreview(null)}>关闭</Button>
</Space>
</Space>
)}
</Modal>
<Modal
open={payOpen}
title={`支付码${payQueueItems.length ? `(${payTasks.length} 个待扫码${payQueuedCount ? ` · ${payQueuedCount} 个排队中` : ''}${payDoneCount ? ` · ${payDoneCount} 个已支付` : ''})` : ''}`}
+69 -20
View File
@@ -8,8 +8,21 @@ const EXCHANGE_TASK_TYPES = new Set([
]);
const CARD_W = 320;
const CARD_H = 470;
const PIXEL_RATIO = 2;
const FONT_FAMILY = '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif';
export interface ExchangeResultImageData {
key: string | number;
goodsName: string;
goodsPic?: string;
roleName?: string;
channel?: string;
accountName: string;
exchangeTime?: string;
statusText?: string;
footerText?: string;
}
export function isExchangeTask(task: DouyuTaskItem): boolean {
return EXCHANGE_TASK_TYPES.has(task.task_type);
}
@@ -55,7 +68,7 @@ function loadImage(url: string, timeoutMs = 8000): Promise<HTMLImageElement> {
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<Blob> {
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<Blob> {
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<Blob> {
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<Blob> {
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<Blob> {
}
// 底部状态徽章
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<Blob> {
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<Blob>((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('图片导出失败'))), 'image/png');
});
}
export async function drawExchangeImage(task: DouyuTaskItem): Promise<Blob> {
return drawExchangeResultImage(douyuImageData(task));
}
export async function copyImageToClipboard(blob: Blob): Promise<void> {
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) {
throw new Error('当前环境不支持复制图片(需要 HTTPS)');
@@ -226,30 +268,37 @@ interface CachedImage {
blob: Blob;
}
const imageCache = new Map<number, CachedImage>();
const inflightImages = new Map<number, Promise<CachedImage>>();
const imageCache = new Map<string, CachedImage>();
const inflightImages = new Map<string, Promise<CachedImage>>();
export async function getExchangeImage(task: DouyuTaskItem): Promise<CachedImage> {
const cached = imageCache.get(task.id);
export async function getExchangeResultImage(
data: ExchangeResultImageData,
): Promise<CachedImage> {
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<CachedImage> {
return getExchangeResultImage(douyuImageData(task));
}
export function clearExchangeImageCache(): void {
for (const entry of imageCache.values()) URL.revokeObjectURL(entry.url);
imageCache.clear();