feat(douyu): 兑换成功后生成结果图片并自动复制到剪贴板

- 账号表格新增兑换图片列,展示每账号最近兑换成功的结果卡片
- 新完成兑换任务自动生成图片并复制到剪贴板(批量时最后完成者覆盖)
- canvas 绘制兑换卡片:商品图/商品名/游戏角色/区服/兑换账号/时间
- 后端兑换成功结果补充 game_name/game_channel/account_name 字段
- 不支持剪贴板的环境降级为点击查看大图
This commit is contained in:
yml2213
2026-08-01 13:23:22 +08:00
parent 3e95cf208d
commit 592eef3582
4 changed files with 406 additions and 2 deletions
+106 -1
View File
@@ -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<Set<number>>(new Set());
const autoOpenedEsportsBindTaskIds = useRef<Set<number>>(new Set());
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
const autoCopiedExchangeTaskIds = useRef<Set<number>>(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<number, DouyuTaskItem>();
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 <Text type="secondary">-</Text>;
return (
<ExchangeResultImage
task={task}
onClick={(t, url) => setExchangePreview({ task: t, url })}
/>
);
},
},
];
const filteredAccounts = useMemo(() => {
@@ -1713,6 +1783,41 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
</div>
)}
</Modal>
{/* 兑换结果图片预览 Modal */}
<Modal
title={
<Space>
<CopyOutlined />
<span></span>
</Space>
}
open={!!exchangePreview}
onCancel={() => setExchangePreview(null)}
footer={
<Space>
<Button onClick={() => setExchangePreview(null)}></Button>
<Button type="primary" icon={<CopyOutlined />} onClick={copyExchangePreview}></Button>
</Space>
}
centered
width={340}
>
{exchangePreview && (
<Space direction="vertical" style={{ width: '100%' }} align="center" size={8}>
<img
src={exchangePreview.url}
alt="兑换结果"
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{exchangePreview.task.account_nickname
|| exchangePreview.task.account_username
|| `#${exchangePreview.task.account_id}`}
</Text>
</Space>
)}
</Modal>
</div>
);
}