feat(huya): 浏览器兑换三目标直达并收窄扩展注入范围

- 操作列改为「直播间 / 精英宝典 / 电竞宝典」三个直达按钮,点哪个就按哪个目标写入登录态并打开,去掉弹窗内选择
- 新增 services/huya_exchange_targets.py 统一三个目标地址,接口支持可选 body {"target": "..."}(不传=精英宝典)
- 扩展内容脚本从 <all_urls> 收窄到后台站点白名单,service worker 校验 sender.tab.url,拒绝其它来源的注入
- 注入改为 Cookie 写完后回执(ok/written/failed/error),前端按回执提示成功或失败,不再静默
- 扩展图标兜底改用 world:'MAIN' 读取;一个 Cookie 都没写成功时不打开目标页
- 兑换材料签发写审计 huya:exchange:kit(含账号与目标)
- 控制台脚本增加 huya.com 域校验与 Secure,与扩展路径保持一致
- 修复兑换记录轮询的跨账号串扰与卸载后 setState;兑换请求加 epoch 防止过期结果覆盖弹窗
- 新增 tests/test_huya_browser_exchange.py(13 项)并同步 docs/HUYA_BROWSER_EXCHANGE.md
This commit is contained in:
yml2213
2026-09-11 10:31:46 +08:00
parent 874a95dd6f
commit 4518b726ac
14 changed files with 838 additions and 102 deletions
+4 -3
View File
@@ -31,6 +31,7 @@ import type {
HuyaTaskBatchResult,
HuyaTaskItem,
HuyaBrowserExchangeKit,
HuyaExchangeTargetKey,
HuyaWorkbenchScope,
MessageCountResponse,
MessageDeletedResponse,
@@ -163,7 +164,7 @@ export const huyaApi = {
api.get<HuyaTaskItem, HuyaTaskItem>(`/huya/tasks/${taskId}`),
cleanupOrphans: () =>
api.post<{ message: string; cleaned: number; success: boolean }, { message: string; cleaned: number; success: boolean }>('/huya/tasks/cleanup-orphans'),
/** 本机浏览器兑换: 获取目标页与登录脚本, 由用户在自己浏览器的控制台执行 */
createBrowserExchange: (accountId: number) =>
api.post<HuyaBrowserExchangeKit, HuyaBrowserExchangeKit>(`/huya/accounts/${accountId}/browser-exchange`, {}),
/** 本机浏览器兑换: 获取目标页与登录脚本, 由用户在自己浏览器的控制台执行 (target 不传 = elite) */
createBrowserExchange: (accountId: number, target?: HuyaExchangeTargetKey) =>
api.post<HuyaBrowserExchangeKit, HuyaBrowserExchangeKit>(`/huya/accounts/${accountId}/browser-exchange`, { target }),
};
+11 -2
View File
@@ -471,15 +471,24 @@ export interface HuyaFetchSmsCodeResult {
success: boolean;
message: string;
}
/** 工作台账号集合范围: elite=精英宝典 / browser_exchange=浏览器兑换 */
export type HuyaWorkbenchScope = 'elite' | 'browser_exchange';
/** 浏览器兑换目标页: live_room=直播间 / elite=精英宝典 / esports=电竞宝典 */
export type HuyaExchangeTargetKey = 'live_room' | 'elite' | 'esports';
export interface HuyaExchangeTargetOption {
key: HuyaExchangeTargetKey;
label: string;
url: string;
}
/** 本机浏览器人工兑换材料 (登录脚本在用户自己的 Chrome/Edge 控制台执行) */
export interface HuyaBrowserExchangeKit {
account_id: number;
username: string;
target: HuyaExchangeTargetKey;
targets: HuyaExchangeTargetOption[];
target_url: string;
cookie_string: string;
cookie_script: string;
@@ -11,16 +11,24 @@ import {
type HuyaAccountItem,
type HuyaBrowserExchangeKit,
type HuyaExchangeRecord,
type HuyaExchangeTargetKey,
} from '../api/modules';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { copyTextToClipboard } from '../utils/clipboard';
import { huyaExchangeHelperReady, openExchangeTarget, requestExtensionInject } from '../utils/huyaExchange';
import { huyaExchangeHelperReady, openExchangeTarget, requestExtensionInject, type ExchangeInjectResult } from '../utils/huyaExchange';
const { Text, Paragraph } = Typography;
const SCOPE = 'browser_exchange' as const;
const IMPORT_PAGE_SIZE = 20;
/** 操作列的三个直达目标:点哪个就按哪个目标写入登录态并打开 */
const EXCHANGE_TARGET_ACTIONS: { key: HuyaExchangeTargetKey; label: string }[] = [
{ key: 'live_room', label: '直播间' },
{ key: 'elite', label: '精英宝典' },
{ key: 'esports', label: '电竞宝典' },
];
/** 表格"兑换记录"列的单元格状态(按账号缓存最近一次查询结果) */
interface RecordsCell {
phase: 'loading' | 'done' | 'error';
@@ -45,7 +53,10 @@ export default function HuyaBrowserExchangePage() {
const [importAllLoading, setImportAllLoading] = useState(false);
const [exchangeKit, setExchangeKit] = useState<HuyaBrowserExchangeKit | null>(null);
const [exchangeTitle, setExchangeTitle] = useState('');
const [exchangeId, setExchangeId] = useState<number | null>(null);
// 操作列正在打开的目标(账号 + 目标),用于按钮 loading
const [exchangePending, setExchangePending] = useState<{ accountId: number; target: HuyaExchangeTargetKey } | null>(null);
const [exchangeInjecting, setExchangeInjecting] = useState(false);
const [injectNotice, setInjectNotice] = useState<{ type: 'success' | 'info' | 'warning' | 'error'; text: string } | null>(null);
const [recordsState, setRecordsState] = useState<{
account: HuyaAccountItem;
phase: 'loading' | 'done' | 'error';
@@ -53,8 +64,18 @@ export default function HuyaBrowserExchangePage() {
errorMsg: string;
} | null>(null);
const [recordsMap, setRecordsMap] = useState<Record<number, RecordsCell>>({});
// 查询序号:重新发起查询或关闭弹窗时自增,使进行中的轮询失效
const recordsSeqRef = useRef(0);
// 查询序号按账号隔离:重新发起该账号查询/关闭该账号弹窗/卸载时自增,使该账号进行中的轮询失效
const recordsSeqRef = useRef<Map<number, number>>(new Map());
// 在途查询账号集合:同一账号已有在途查询时不重复发起
const recordsInFlightRef = useRef<Set<number>>(new Set());
// 组件挂载标记:卸载后中断所有在途轮询且不再 setState
const mountedRef = useRef(true);
const bumpRecordsSeq = (accountId: number): number => {
const next = (recordsSeqRef.current.get(accountId) ?? 0) + 1;
recordsSeqRef.current.set(accountId, next);
return next;
};
const load = useCallback(async () => {
setLoading(true);
@@ -111,6 +132,20 @@ export default function HuyaBrowserExchangePage() {
}, [importSearch, importTag]);
useEffect(() => { setImportSelectedIds([]); }, [importPage]);
// 卸载时中断所有在途兑换记录轮询,并标记为已卸载(轮询回调不再 setState)
useEffect(() => {
mountedRef.current = true;
const seqRef = recordsSeqRef.current;
const inFlightRef = recordsInFlightRef.current;
return () => {
mountedRef.current = false;
for (const [accountId, seq] of seqRef) {
seqRef.set(accountId, seq + 1);
}
inFlightRef.clear();
};
}, []);
const persistAccounts = async (ids: number[]): Promise<number[]> => {
const saved = await huyaApi.updateWorkbenchAccounts(ids, SCOPE);
const byId = new Map(pool.map((item) => [item.id, item]));
@@ -168,17 +203,59 @@ export default function HuyaBrowserExchangePage() {
}
};
const handleExchange = async (record: HuyaAccountItem) => {
setExchangeId(record.id);
// 兑换请求/注入的代号:换账号、换目标、关弹窗都会自增,用于丢弃过期结果
const exchangeEpochRef = useRef(0);
/** 按扩展回执给出可见结果(替代原静默失败);文案带目标名,操作者可自证 */
const applyInjectResult = (result: ExchangeInjectResult, kit: HuyaBrowserExchangeKit) => {
const label = kit.targets.find((item) => item.key === kit.target)?.label || kit.target;
if (result.ok) {
setInjectNotice({ type: 'success', text: `已注入登录态并打开「${label}」` });
message.success(`已注入登录态并打开「${label}」`);
return;
}
const tip = result.error || `部分 Cookie 写入失败(${result.failed} 个)`;
setInjectNotice({ type: result.error ? 'error' : 'warning', text: tip });
if (result.error) message.error(tip);
else message.warning(tip);
};
/** 注入登录态并展示回执:点行时的自动注入与弹窗按钮共用;代号变化即丢弃过期回执 */
const injectKitWithNotice = async (kit: HuyaBrowserExchangeKit, epoch: number) => {
setExchangeInjecting(true);
try {
const kit = await huyaApi.createBrowserExchange(record.id);
requestExtensionInject(kit);
const result = await requestExtensionInject(kit);
if (epoch !== exchangeEpochRef.current) return;
applyInjectResult(result, kit);
} finally {
if (epoch === exchangeEpochRef.current) setExchangeInjecting(false);
}
};
/** 操作列三个直达按钮:按所选目标取兑换材料 → 注入并打开(未装扩展则直接打开目标页) */
const handleExchange = async (record: HuyaAccountItem, target: HuyaExchangeTargetKey) => {
const epoch = ++exchangeEpochRef.current;
setExchangePending({ accountId: record.id, target });
try {
const kit = await huyaApi.createBrowserExchange(record.id, target);
if (epoch !== exchangeEpochRef.current) return; // 已点了其它账号/目标,丢弃本次结果
setInjectNotice(null);
setExchangeKit(kit);
setExchangeTitle(record.nickname || record.username || record.yyuid || `账号#${record.id}`);
if (huyaExchangeHelperReady()) {
// 扩展会写入登录态并自己打开标签页,这里不要再 window.open,避免开两个
await injectKitWithNotice(kit, epoch);
} else {
openExchangeTarget(kit.target_url);
message.info('目标页面已打开,请复制登录脚本并在该页面控制台执行');
}
} catch (error) {
message.error(getErrorMessage(error));
if (epoch === exchangeEpochRef.current) message.error(getErrorMessage(error));
} finally {
setExchangeId(null);
// 只清自己的 loading,避免后点的按钮被先前请求清掉
setExchangePending((prev) => (
prev && prev.accountId === record.id && prev.target === target ? null : prev
));
}
};
@@ -192,9 +269,34 @@ export default function HuyaBrowserExchangePage() {
};
const queryExchangeRecords = async (record: HuyaAccountItem) => {
const seq = ++recordsSeqRef.current;
const alive = () => seq === recordsSeqRef.current;
// 同一账号已有在途查询时不重复发起
if (recordsInFlightRef.current.has(record.id)) {
message.info('该账号的兑换记录正在查询中,请稍候');
return;
}
recordsInFlightRef.current.add(record.id);
const seq = bumpRecordsSeq(record.id);
const alive = () => seq === (recordsSeqRef.current.get(record.id) ?? 0);
const previousCell = recordsMap[record.id];
/** 被中断时回退单元格:有上次结果则保留,否则回到「未查询」,不永久卡 loading */
const restoreAfterAbort = () => {
if (!mountedRef.current) return;
setRecordsMap((prev) => {
if (previousCell) return { ...prev, [record.id]: previousCell };
const next = { ...prev };
delete next[record.id];
return next;
});
setRecordsState((prev) => {
if (!prev || prev.account.id !== record.id) return prev;
if (previousCell && previousCell.phase === 'done') {
return { account: record, phase: 'done', records: previousCell.records, errorMsg: '' };
}
return null;
});
};
const patch = (update: Partial<RecordsCell>) => {
if (!alive() || !mountedRef.current) return;
setRecordsMap((prev) => {
const current = prev[record.id];
const merged: RecordsCell = {
@@ -223,6 +325,7 @@ export default function HuyaBrowserExchangePage() {
return;
}
const tasks = await huyaApi.listTasks(batch.batch_id, 'elite');
if (!alive()) return;
const task = tasks.find((item) => item.account_id === record.id);
if (!task) continue;
if (task.status === 'success') {
@@ -236,22 +339,50 @@ export default function HuyaBrowserExchangePage() {
}
}
} catch (error) {
if (alive()) patch({ phase: 'error', errorMsg: getErrorMessage(error) });
patch({ phase: 'error', errorMsg: getErrorMessage(error) });
} finally {
recordsInFlightRef.current.delete(record.id);
if (!alive()) restoreAfterAbort();
}
};
const closeRecordsModal = () => {
recordsSeqRef.current += 1;
// 只中断当前账号的轮询,不影响其它账号
if (recordsState) bumpRecordsSeq(recordsState.account.id);
setRecordsState(null);
};
/** 点击表格单元格时用缓存结果打开详情弹窗(不重新查询) */
/** 点击表格单元格时用缓存结果打开详情弹窗(不重新查询,也不中断其它在途查询) */
const openCachedRecords = (record: HuyaAccountItem, cell: RecordsCell) => {
if (cell.phase !== 'done') return;
recordsSeqRef.current += 1;
setRecordsState({ account: record, phase: 'done', records: cell.records, errorMsg: '' });
};
const closeExchangeModal = () => {
exchangeEpochRef.current += 1; // 使在途请求与注入回执失效
setExchangeKit(null);
setInjectNotice(null);
setExchangeInjecting(false);
};
/** 弹窗内手动重新注入(切换目标后走这里) */
const handleInject = async () => {
const kit = exchangeKit;
if (!kit) return;
await injectKitWithNotice(kit, ++exchangeEpochRef.current);
};
/** 扩展不可用时:只打开目标页,提示复制登录脚本手动执行 */
const handleOpenExchangeTarget = () => {
if (!exchangeKit) return;
openExchangeTarget(exchangeKit.target_url);
message.info('已打开目标页面,请复制登录脚本并在该页面控制台执行');
};
const currentExchangeTarget = exchangeKit
? exchangeKit.targets.find((item) => item.key === exchangeKit.target) || null
: null;
const exchangeHelperReady = huyaExchangeHelperReady();
const columns: TableProps<HuyaAccountItem>['columns'] = [
@@ -303,29 +434,33 @@ export default function HuyaBrowserExchangePage() {
},
{
title: '操作',
width: 200,
width: 360,
render: (_value, record) => (
<Space size={6}>
<Tooltip title={record.has_cookie
? '自动写入登录态并打开兑换页面(用你自己的 Chrome/Edge 完成)'
: '该账号没有 Cookie,请联系管理员在账号管理中完成登录'}>
<Button
size="small"
type="primary"
ghost
icon={<GlobalOutlined />}
disabled={!record.has_cookie}
loading={exchangeId === record.id}
onClick={() => void handleExchange(record)}
{EXCHANGE_TARGET_ACTIONS.map((action) => (
<Tooltip
key={action.key}
title={record.has_cookie
? `写入该账号登录态并打开「${action.label}」(在你自己的 Chrome/Edge 里完成兑换)`
: '该账号没有 Cookie,请联系管理员在账号管理中完成登录'}
>
浏览器兑换
</Button>
</Tooltip>
<Button
size="small"
type="primary"
ghost
disabled={!record.has_cookie}
loading={exchangePending?.accountId === record.id && exchangePending.target === action.key}
onClick={() => void handleExchange(record, action.key)}
>
{action.label}
</Button>
</Tooltip>
))}
<Tooltip title="查询该账号的兑换记录">
<Button
size="small"
icon={<HistoryOutlined />}
loading={recordsState?.phase === 'loading' && recordsState.account.id === record.id}
loading={recordsMap[record.id]?.phase === 'loading'}
onClick={() => void queryExchangeRecords(record)}
/>
</Tooltip>
@@ -342,7 +477,7 @@ export default function HuyaBrowserExchangePage() {
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div>
<h2 style={{ margin: 0 }}>浏览器兑换</h2>
<Text type="secondary">把账号登录态写入你自己的 Chrome/Edge 后打开兑换页,兑换手动完成;客服仅可见已分配给自己的账号。</Text>
<Text type="secondary">点操作列的目标按钮即可把该账号登录态写入你自己的 Chrome/Edge 并打开对应页面,兑换手动完成;客服仅可见已分配给自己的账号。</Text>
</div>
<Space>
<Button icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>
@@ -356,7 +491,7 @@ export default function HuyaBrowserExchangePage() {
showIcon
style={{ marginBottom: 12 }}
message="未检测到「虎牙兑换助手」扩展"
description="安装后点「浏览器兑换」即可自动写入登录态并打开兑换页(见 docs/HUYA_BROWSER_EXCHANGE.md,开发者模式加载 web/extension/huya-exchange-helper 并刷新本页);未安装时可走兑换弹窗里的手动脚本流程。"
description="安装后点操作列的任意目标按钮即可自动写入登录态并打开对应页面(见 docs/HUYA_BROWSER_EXCHANGE.md,开发者模式加载 web/extension/huya-exchange-helper 并刷新本页);未安装时会直接打开目标页面,走弹窗里的手动脚本流程。"
/>
)}
@@ -499,10 +634,29 @@ export default function HuyaBrowserExchangePage() {
open={exchangeKit !== null}
width={720}
centered
onCancel={() => setExchangeKit(null)}
onCancel={closeExchangeModal}
footer={exchangeKit ? [
<Button key="open" type="primary" icon={<GlobalOutlined />} onClick={() => openExchangeTarget(exchangeKit.target_url)}>打开兑换页面</Button>,
<Button key="done" onClick={() => { setExchangeKit(null); message.success('兑换完成后可在精英宝典任务中刷新积分'); }}>完成</Button>,
exchangeHelperReady ? (
<Button
key="inject"
type="primary"
icon={<GlobalOutlined />}
loading={exchangeInjecting}
onClick={() => void handleInject()}
>
注入登录态并打开
</Button>
) : (
<Button
key="open"
type="primary"
icon={<GlobalOutlined />}
onClick={handleOpenExchangeTarget}
>
打开目标页面
</Button>
),
<Button key="done" onClick={() => { closeExchangeModal(); message.success('兑换完成后可在对应宝典任务里刷新积分'); }}>完成</Button>,
] : null}
>
{exchangeKit && (
@@ -512,24 +666,39 @@ export default function HuyaBrowserExchangePage() {
type="success"
showIcon
message="已检测到「虎牙兑换助手」扩展"
description="点击「浏览器兑换」时已自动写入该账号登录态并在新标签页打开兑换页面。请确认页面右上角账号无误后手动完成兑换;若页面未自动打开,按下方手动步骤操作。"
description="点操作列的任意目标按钮即会写入该账号登录态并打开对应页面,结果会在这里提示;失败可直接点「注入登录态并打开」重试。"
/>
) : (
<Alert
type="warning"
showIcon
message="未检测到「虎牙兑换助手」扩展"
description="安装后可一键自动写入登录态(见 docs/HUYA_BROWSER_EXCHANGE.md)。当前请按下方手动步骤操作。"
description="安装后可一键自动写入登录态(见 docs/HUYA_BROWSER_EXCHANGE.md)。目标页面已为你打开,请按下方手动步骤操作。"
/>
)}
<div>
<Space size={8}>
<Text strong>本次目标</Text>
<Text>{currentExchangeTarget?.label || exchangeKit.target}</Text>
</Space>
<Paragraph type="secondary" style={{ marginBottom: 0, wordBreak: 'break-all' }}>
{currentExchangeTarget?.url || exchangeKit.target_url}
</Paragraph>
</div>
{injectNotice && (
<Alert type={injectNotice.type} showIcon message={injectNotice.text} />
)}
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>手动步骤 1:</Text>
点击下方「打开兑换页面」,在新标签页打开兑换活动页。
{exchangeHelperReady
? '若页面没有自动打开,点下方「注入登录态并打开」重新注入并打开当前目标'
: '点下方「打开目标页面」在新标签页打开当前目标'}
「{currentExchangeTarget?.label || '兑换'}」。
</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>手动步骤 2:</Text>
点击「复制登录脚本」,在兑换页面按 <Text code>F12</Text> 打开控制台,粘贴脚本并回车执行。
Chrome/Edge 首次粘贴需先手动输入 <Text code>allow pasting</Text> 再回车。脚本会写入该账号的登录态并自动跳转。
点击「复制登录脚本」,在目标页面按 <Text code>F12</Text> 打开控制台,粘贴脚本并回车执行。
Chrome/Edge 首次粘贴需先手动输入 <Text code>allow pasting</Text> 再回车。脚本会写入该账号的登录态并自动跳转到所选目标。
</Paragraph>
<Space wrap>
<Button icon={<CopyOutlined />} onClick={() => void copyExchangeText(exchangeKit.cookie_script, '登录脚本已复制')}>复制登录脚本</Button>
@@ -540,7 +709,7 @@ export default function HuyaBrowserExchangePage() {
</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>手动步骤 3:</Text>
确认页面右上角登录态为目标账号后完成兑换,回到此处点击「完成」。
确认页面右上角登录态为目标账号,并在「{currentExchangeTarget?.label || '兑换'}」页完成兑换,回到此处点击「完成」。
</Paragraph>
</Space>
)}
+63 -5
View File
@@ -2,6 +2,14 @@
import type { HuyaBrowserExchangeKit } from '../api/modules';
/** 扩展注入回执 (huya-exchange-result) */
export interface ExchangeInjectResult {
ok: boolean;
written: number;
failed: number;
error: string;
}
/** 解析后端 Cookie 字符串为 [name, value] 数组,供兑换扩展注入 */
export function parseCookieString(cookieString: string): [string, string][] {
return cookieString
@@ -14,16 +22,66 @@ export function parseCookieString(cookieString: string): [string, string][] {
});
}
/** 内置扩展的内容脚本会在所有页面打上此标记 */
/** 内置扩展的内容脚本会在后台站点白名单页面(见 manifest matches)打上此标记 */
export function huyaExchangeHelperReady(): boolean {
return document.documentElement.dataset.huyaExchangeHelper === '1';
}
/** 请求内置扩展自动注入登录态;同时留存到 window 供扩展图标点击兜底读取 */
export function requestExtensionInject(kit: HuyaBrowserExchangeKit): void {
const payload = JSON.stringify({ target_url: kit.target_url, cookies: parseCookieString(kit.cookie_string) });
function createRequestId(): string {
return `hx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* 请求内置扩展自动注入登录态并打开目标页,等待扩展回执(4 秒超时)。
* 同时留存到 window 供扩展图标点击兜底读取。
* 注意:事件 detail 必须是 JSON 字符串(跨 world 传递限制),不要改成对象。
*/
export function requestExtensionInject(kit: HuyaBrowserExchangeKit): Promise<ExchangeInjectResult> {
const requestId = createRequestId();
const payload = JSON.stringify({
request_id: requestId,
target_url: kit.target_url,
cookies: parseCookieString(kit.cookie_string),
});
(window as unknown as { __huyaExchangeKit?: string }).__huyaExchangeKit = payload;
window.dispatchEvent(new CustomEvent('huya-exchange-kit', { detail: payload }));
return new Promise<ExchangeInjectResult>((resolve) => {
let timer = 0;
let settled = false;
function finish(result: ExchangeInjectResult): void {
if (settled) return;
settled = true;
window.clearTimeout(timer);
window.removeEventListener('huya-exchange-result', onResult);
resolve(result);
}
function onResult(event: Event): void {
const detail = (event as CustomEvent<unknown>).detail;
if (typeof detail !== 'string') return;
let parsed: { request_id?: string; ok?: boolean; written?: number; failed?: number; error?: string };
try {
parsed = JSON.parse(detail) as typeof parsed;
} catch {
return;
}
if (!parsed || parsed.request_id !== requestId) return;
finish({
ok: parsed.ok === true,
written: typeof parsed.written === 'number' ? parsed.written : 0,
failed: typeof parsed.failed === 'number' ? parsed.failed : 0,
error: typeof parsed.error === 'string' ? parsed.error : '',
});
}
timer = window.setTimeout(() => {
finish({ ok: false, written: 0, failed: 0, error: '扩展未响应(未安装或安装后未刷新本页)' });
}, 4000);
window.addEventListener('huya-exchange-result', onResult);
window.dispatchEvent(new CustomEvent('huya-exchange-kit', { detail: payload }));
});
}
/** 在新标签页打开兑换页面(noopener 避免反向标签劫持) */