- 操作列改为「直播间 / 精英宝典 / 电竞宝典」三个直达按钮,点哪个就按哪个目标写入登录态并打开,去掉弹窗内选择
- 新增 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
720 lines
30 KiB
TypeScript
720 lines
30 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import {
|
||
Alert, Button, Card, Empty, Input, Modal, Popconfirm, Select, Space, Spin, Table, Tag, Tooltip, Typography,
|
||
} from 'antd';
|
||
import type { TableProps } from 'antd';
|
||
import {
|
||
CopyOutlined, DeleteOutlined, GlobalOutlined, HistoryOutlined, ImportOutlined, ReloadOutlined, SearchOutlined,
|
||
} from '@ant-design/icons';
|
||
import {
|
||
huyaApi,
|
||
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, 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';
|
||
records: HuyaExchangeRecord[];
|
||
errorMsg?: string;
|
||
}
|
||
|
||
export default function HuyaBrowserExchangePage() {
|
||
const [pool, setPool] = useState<HuyaAccountItem[]>([]);
|
||
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
// 导入弹窗:候选池走服务端分页筛选(搜索/标签/仅有 CK),与斗鱼操作台导入一致
|
||
const [importOpen, setImportOpen] = useState(false);
|
||
const [importPool, setImportPool] = useState<HuyaAccountItem[]>([]);
|
||
const [importTotal, setImportTotal] = useState(0);
|
||
const [importPage, setImportPage] = useState(1);
|
||
const [importLoading, setImportLoading] = useState(false);
|
||
const [importSearch, setImportSearch] = useState('');
|
||
const [importTag, setImportTag] = useState('');
|
||
const [importTags, setImportTags] = useState<string[]>([]);
|
||
const [importSelectedIds, setImportSelectedIds] = useState<number[]>([]);
|
||
const [importAllLoading, setImportAllLoading] = useState(false);
|
||
const [exchangeKit, setExchangeKit] = useState<HuyaBrowserExchangeKit | null>(null);
|
||
const [exchangeTitle, setExchangeTitle] = useState('');
|
||
// 操作列正在打开的目标(账号 + 目标),用于按钮 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';
|
||
records: HuyaExchangeRecord[];
|
||
errorMsg: string;
|
||
} | null>(null);
|
||
const [recordsMap, setRecordsMap] = useState<Record<number, RecordsCell>>({});
|
||
// 查询序号按账号隔离:重新发起该账号查询/关闭该账号弹窗/卸载时自增,使该账号进行中的轮询失效
|
||
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);
|
||
try {
|
||
const [poolResult, workbenchResult] = await Promise.all([
|
||
huyaApi.listAccounts({ include_cookie: false }),
|
||
huyaApi.listWorkbenchAccounts(SCOPE),
|
||
]);
|
||
setPool(poolResult);
|
||
const byId = new Map(poolResult.map((item) => [item.id, item]));
|
||
setAccounts(
|
||
workbenchResult.account_ids
|
||
.map((id) => byId.get(Number(id)))
|
||
.filter((item): item is HuyaAccountItem => Boolean(item)),
|
||
);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadImportPool = useCallback(async () => {
|
||
setImportLoading(true);
|
||
try {
|
||
// 候选池 = 账号库按搜索/标签过滤且必须有 Cookie,剔除已加入兑换列表的账号
|
||
const result = await huyaApi.listAccountsPaged({
|
||
search: importSearch.trim() || undefined,
|
||
tag: importTag || undefined,
|
||
has_cookie: true,
|
||
page: importPage,
|
||
page_size: IMPORT_PAGE_SIZE,
|
||
});
|
||
const exclude = new Set(accounts.map((item) => item.id));
|
||
setImportPool(result.items.filter((item) => !exclude.has(item.id)));
|
||
setImportTotal(result.total);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setImportLoading(false);
|
||
}
|
||
}, [accounts, importPage, importSearch, importTag]);
|
||
|
||
useEffect(() => { void load(); }, [load]);
|
||
useEffect(() => { if (importOpen) void loadImportPool(); }, [importOpen, loadImportPool]);
|
||
useEffect(() => {
|
||
if (!importOpen) return;
|
||
void huyaApi.listTags().then(setImportTags).catch(() => {});
|
||
}, [importOpen]);
|
||
// 切换搜索/标签/翻页时清空已选,避免计数与可见行不一致
|
||
useEffect(() => {
|
||
setImportPage(1);
|
||
setImportSelectedIds([]);
|
||
}, [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]));
|
||
setAccounts(
|
||
saved.account_ids
|
||
.map((id) => byId.get(Number(id)))
|
||
.filter((item): item is HuyaAccountItem => Boolean(item)),
|
||
);
|
||
return saved.account_ids;
|
||
};
|
||
|
||
const removeAccount = async (record: HuyaAccountItem) => {
|
||
try {
|
||
await persistAccounts(accounts.filter((item) => item.id !== record.id).map((item) => item.id));
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
}
|
||
};
|
||
|
||
const importAccounts = async (ids: number[]) => {
|
||
const toImport = importPool.filter((item) => ids.includes(item.id));
|
||
if (!toImport.length) return;
|
||
try {
|
||
await persistAccounts([...new Set([...accounts.map((item) => item.id), ...toImport.map((item) => item.id)])]);
|
||
setImportSelectedIds([]);
|
||
setImportOpen(false);
|
||
message.success(`已导入 ${toImport.length} 个账号`);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
}
|
||
};
|
||
|
||
const importAllAccounts = async () => {
|
||
setImportAllLoading(true);
|
||
try {
|
||
const result = await huyaApi.listAccountIds({
|
||
search: importSearch.trim() || undefined,
|
||
tag: importTag || undefined,
|
||
has_cookie: true,
|
||
});
|
||
const existing = new Set(accounts.map((item) => item.id));
|
||
const imported = result.account_ids.filter((id) => !existing.has(id));
|
||
if (!imported.length) {
|
||
message.info('当前筛选条件下没有可新增的账号');
|
||
return;
|
||
}
|
||
await persistAccounts([...accounts.map((item) => item.id), ...imported]);
|
||
setImportSelectedIds([]);
|
||
setImportOpen(false);
|
||
message.success(`已一键导入 ${imported.length} 个账号`);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setImportAllLoading(false);
|
||
}
|
||
};
|
||
|
||
// 兑换请求/注入的代号:换账号、换目标、关弹窗都会自增,用于丢弃过期结果
|
||
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 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) {
|
||
if (epoch === exchangeEpochRef.current) message.error(getErrorMessage(error));
|
||
} finally {
|
||
// 只清自己的 loading,避免后点的按钮被先前请求清掉
|
||
setExchangePending((prev) => (
|
||
prev && prev.accountId === record.id && prev.target === target ? null : prev
|
||
));
|
||
}
|
||
};
|
||
|
||
const copyExchangeText = async (text: string, tip: string) => {
|
||
try {
|
||
await copyTextToClipboard(text);
|
||
message.success(tip);
|
||
} catch {
|
||
message.error('复制失败,请重试');
|
||
}
|
||
};
|
||
|
||
const queryExchangeRecords = async (record: HuyaAccountItem) => {
|
||
// 同一账号已有在途查询时不重复发起
|
||
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 = {
|
||
phase: update.phase ?? current?.phase ?? 'loading',
|
||
records: update.records ?? current?.records ?? [],
|
||
errorMsg: update.errorMsg ?? current?.errorMsg,
|
||
};
|
||
return { ...prev, [record.id]: merged };
|
||
});
|
||
setRecordsState((prev) => (prev && prev.account.id === record.id ? { ...prev, ...update } : prev));
|
||
};
|
||
setRecordsMap((prev) => ({ ...prev, [record.id]: { phase: 'loading', records: [] } }));
|
||
setRecordsState({ account: record, phase: 'loading', records: [], errorMsg: '' });
|
||
try {
|
||
const batch = await huyaApi.createTasks({
|
||
account_ids: [record.id],
|
||
task_type: 'query_exchange_records',
|
||
handbook_scope: 'elite',
|
||
});
|
||
const startedAt = Date.now();
|
||
for (;;) {
|
||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||
if (!alive()) return;
|
||
if (Date.now() - startedAt > 60_000) {
|
||
patch({ phase: 'error', errorMsg: '查询超时,请稍后重试' });
|
||
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') {
|
||
const result = (task.result || {}) as { records?: HuyaExchangeRecord[] };
|
||
patch({ phase: 'done', records: result.records || [] });
|
||
return;
|
||
}
|
||
if (['failed', 'error', 'stopped', 'timeout'].includes(task.status)) {
|
||
patch({ phase: 'error', errorMsg: task.message || '查询失败' });
|
||
return;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
patch({ phase: 'error', errorMsg: getErrorMessage(error) });
|
||
} finally {
|
||
recordsInFlightRef.current.delete(record.id);
|
||
if (!alive()) restoreAfterAbort();
|
||
}
|
||
};
|
||
|
||
const closeRecordsModal = () => {
|
||
// 只中断当前账号的轮询,不影响其它账号
|
||
if (recordsState) bumpRecordsSeq(recordsState.account.id);
|
||
setRecordsState(null);
|
||
};
|
||
|
||
/** 点击表格单元格时用缓存结果打开详情弹窗(不重新查询,也不中断其它在途查询) */
|
||
const openCachedRecords = (record: HuyaAccountItem, cell: RecordsCell) => {
|
||
if (cell.phase !== 'done') return;
|
||
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'] = [
|
||
{
|
||
title: '账号',
|
||
render: (_value, record) => (
|
||
<Space size={6}>
|
||
<Text strong>{record.nickname || record.username || record.uid}</Text>
|
||
{record.username && record.username !== (record.nickname || record.username) && (
|
||
<Text type="secondary">{record.username}</Text>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
{ title: '标签', dataIndex: 'tag', render: (tag: string) => (tag ? <Tag>{tag}</Tag> : '-') },
|
||
{
|
||
title: '登录态',
|
||
render: (_value, record) => (record.has_cookie
|
||
? <Tag color="success">有 Cookie</Tag>
|
||
: <Tag color="warning">无 Cookie</Tag>),
|
||
},
|
||
{
|
||
title: '兑换记录',
|
||
render: (_value, record) => {
|
||
const cell = recordsMap[record.id];
|
||
if (!cell) return <Text type="secondary">未查询</Text>;
|
||
if (cell.phase === 'loading') return <Spin size="small" />;
|
||
if (cell.phase === 'error') {
|
||
return (
|
||
<Tooltip title={cell.errorMsg}>
|
||
<Text type="danger">查询失败</Text>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
if (!cell.records.length) return <Text type="secondary">暂无记录</Text>;
|
||
const names = cell.records.map((item) => item.prize_name || '未命名奖品');
|
||
const summary = names.length > 1 ? `${names[0]} 等 ${names.length} 条` : names[0];
|
||
return (
|
||
<Tooltip title={names.join('、')}>
|
||
<Text
|
||
style={{ cursor: 'pointer' }}
|
||
onClick={() => openCachedRecords(record, cell)}
|
||
>
|
||
{summary}
|
||
</Text>
|
||
</Tooltip>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 360,
|
||
render: (_value, record) => (
|
||
<Space size={6}>
|
||
{EXCHANGE_TARGET_ACTIONS.map((action) => (
|
||
<Tooltip
|
||
key={action.key}
|
||
title={record.has_cookie
|
||
? `写入该账号登录态并打开「${action.label}」(在你自己的 Chrome/Edge 里完成兑换)`
|
||
: '该账号没有 Cookie,请联系管理员在账号管理中完成登录'}
|
||
>
|
||
<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={recordsMap[record.id]?.phase === 'loading'}
|
||
onClick={() => void queryExchangeRecords(record)}
|
||
/>
|
||
</Tooltip>
|
||
<Popconfirm title="确认从兑换列表移除该账号?" onConfirm={() => void removeAccount(record)}>
|
||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<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>
|
||
</div>
|
||
<Space>
|
||
<Button icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>
|
||
<Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}>刷新</Button>
|
||
</Space>
|
||
</div>
|
||
|
||
{!exchangeHelperReady && (
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
style={{ marginBottom: 12 }}
|
||
message="未检测到「虎牙兑换助手」扩展"
|
||
description="安装后点操作列的任意目标按钮即可自动写入登录态并打开对应页面(见 docs/HUYA_BROWSER_EXCHANGE.md,开发者模式加载 web/extension/huya-exchange-helper 并刷新本页);未安装时会直接打开目标页面,走弹窗里的手动脚本流程。"
|
||
/>
|
||
)}
|
||
|
||
<Card size="small" styles={{ body: { padding: '4px 6px' } }}>
|
||
<Table<HuyaAccountItem>
|
||
rowKey="id"
|
||
size="small"
|
||
columns={columns}
|
||
dataSource={accounts}
|
||
loading={loading}
|
||
pagination={accounts.length > 20 ? { pageSize: 20 } : false}
|
||
locale={{ emptyText: '还没有账号,点击右上角「导入账号」开始' }}
|
||
/>
|
||
</Card>
|
||
|
||
<Modal
|
||
title="导入兑换账号"
|
||
open={importOpen}
|
||
onCancel={() => setImportOpen(false)}
|
||
footer={null}
|
||
width={700}
|
||
>
|
||
<div style={{ marginBottom: 12, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||
<Space wrap>
|
||
<Input
|
||
placeholder="搜索账号/昵称"
|
||
prefix={<SearchOutlined />}
|
||
allowClear
|
||
value={importSearch}
|
||
onChange={(event) => setImportSearch(event.target.value)}
|
||
style={{ width: 220 }}
|
||
/>
|
||
<Select
|
||
placeholder="按标签筛选"
|
||
allowClear
|
||
showSearch
|
||
value={importTag || undefined}
|
||
onChange={(value) => setImportTag(value || '')}
|
||
style={{ width: 160 }}
|
||
options={importTags.map((tag) => ({ value: tag, label: tag }))}
|
||
/>
|
||
</Space>
|
||
<Space wrap style={{ width: '100%' }}>
|
||
<Button onClick={() => void importAccounts(importPool.map((item) => item.id))}>
|
||
导入本页 ({importPool.length})
|
||
</Button>
|
||
<Button loading={importAllLoading} onClick={() => void importAllAccounts()}>
|
||
一键导入全部
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
disabled={!importSelectedIds.length}
|
||
onClick={() => void importAccounts(importSelectedIds)}
|
||
>
|
||
导入选中 ({importSelectedIds.length})
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
<Table
|
||
rowKey="id"
|
||
size="small"
|
||
loading={importLoading}
|
||
rowSelection={{ selectedRowKeys: importSelectedIds, onChange: (keys) => setImportSelectedIds(keys.map(Number)) }}
|
||
dataSource={importPool}
|
||
columns={[
|
||
{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid },
|
||
{ title: '用户名', dataIndex: 'username', render: (value: string) => value || '-' },
|
||
{ title: '标签', dataIndex: 'tag', width: 140, render: (value: string) => (value ? <Tag color="blue">{value}</Tag> : '-') },
|
||
]}
|
||
pagination={{
|
||
current: importPage,
|
||
pageSize: IMPORT_PAGE_SIZE,
|
||
total: importTotal,
|
||
size: 'small',
|
||
showLessItems: true,
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
onChange: setImportPage,
|
||
}}
|
||
locale={{ emptyText: '没有可导入的账号' }}
|
||
/>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={`兑换记录 · ${recordsState?.account.nickname || recordsState?.account.username || `账号#${recordsState?.account.id}` || ''}`}
|
||
open={recordsState !== null}
|
||
onCancel={closeRecordsModal}
|
||
width={760}
|
||
footer={[
|
||
<Button
|
||
key="retry"
|
||
loading={recordsState?.phase === 'loading'}
|
||
onClick={() => recordsState && void queryExchangeRecords(recordsState.account)}
|
||
>
|
||
重新查询
|
||
</Button>,
|
||
<Button key="close" type="primary" onClick={closeRecordsModal}>关闭</Button>,
|
||
]}
|
||
>
|
||
{recordsState?.phase === 'loading' && (
|
||
<div style={{ padding: '48px 0', textAlign: 'center' }}>
|
||
<Spin size="large" />
|
||
<Paragraph type="secondary" style={{ marginTop: 12 }}>正在通过精英宝典任务查询兑换记录…</Paragraph>
|
||
</div>
|
||
)}
|
||
{recordsState?.phase === 'error' && (
|
||
<Alert type="error" showIcon message="兑换记录查询失败" description={recordsState.errorMsg} />
|
||
)}
|
||
{recordsState?.phase === 'done' && (
|
||
recordsState.records.length ? (
|
||
<Table
|
||
rowKey="record_id"
|
||
size="small"
|
||
dataSource={recordsState.records}
|
||
pagination={false}
|
||
scroll={{ y: 420 }}
|
||
columns={[
|
||
{ title: '#', dataIndex: 'index', width: 48 },
|
||
{ title: '奖品', dataIndex: 'prize_name', render: (value: string) => value || '-' },
|
||
{ title: '积分', dataIndex: 'score', width: 90, render: (value: number) => (value != null ? `${value}积分` : '-') },
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status_label',
|
||
width: 90,
|
||
render: (value: string) => (
|
||
<Tag color={value === '已发放' ? 'success' : 'warning'}>{value}</Tag>
|
||
),
|
||
},
|
||
{ title: '兑换时间', dataIndex: 'exchange_time_text', width: 170, render: (value: string) => value || '-' },
|
||
{ title: '订单号', dataIndex: 'order_id', render: (value: string) => value || '-' },
|
||
]}
|
||
/>
|
||
) : (
|
||
<Empty description="暂无兑换记录" />
|
||
)
|
||
)}
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={`浏览器兑换 · ${exchangeTitle}`}
|
||
open={exchangeKit !== null}
|
||
width={720}
|
||
centered
|
||
onCancel={closeExchangeModal}
|
||
footer={exchangeKit ? [
|
||
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 && (
|
||
<Space orientation="vertical" style={{ width: '100%' }} size={12}>
|
||
{exchangeHelperReady ? (
|
||
<Alert
|
||
type="success"
|
||
showIcon
|
||
message="已检测到「虎牙兑换助手」扩展"
|
||
description="点操作列的任意目标按钮即会写入该账号登录态并打开对应页面,结果会在这里提示;失败可直接点「注入登录态并打开」重试。"
|
||
/>
|
||
) : (
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
message="未检测到「虎牙兑换助手」扩展"
|
||
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> 再回车。脚本会写入该账号的登录态并自动跳转到所选目标。
|
||
</Paragraph>
|
||
<Space wrap>
|
||
<Button icon={<CopyOutlined />} onClick={() => void copyExchangeText(exchangeKit.cookie_script, '登录脚本已复制')}>复制登录脚本</Button>
|
||
<Button icon={<CopyOutlined />} onClick={() => void copyExchangeText(exchangeKit.cookie_string, 'Cookie 字符串已复制')}>复制 Cookie 字符串</Button>
|
||
</Space>
|
||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||
备选:安装 Cookie-editor 类扩展后,在 .huya.com 域下导入 Cookie 字符串。脚本和 Cookie 等同账号凭证,请勿泄露给他人。
|
||
</Paragraph>
|
||
<Paragraph style={{ marginBottom: 0 }}>
|
||
<Text strong>手动步骤 3:</Text>
|
||
确认页面右上角登录态为目标账号,并在「{currentExchangeTarget?.label || '兑换'}」页完成兑换,回到此处点击「完成」。
|
||
</Paragraph>
|
||
</Space>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|