feat(huya): 增加兑换记录结果图
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} 个已支付` : ''})` : ''}`}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user