新增 /huya/browser-exchange 页面(精英宝典下方),支持导入账号与 执行兑换,客服仅见已分配账号;账号管理页移除该功能。导入弹窗对齐 斗鱼操作台:服务端分页筛选、可搜索标签、无 CK 不展示、导入本页/ 一键导入全部/导入选中;新增 /accounts/ids 接口与 workbench browser_exchange scope。
378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import {
|
||
Alert, Button, Card, Input, Modal, Popconfirm, Select, Space, Table, Tag, Tooltip, Typography,
|
||
} from 'antd';
|
||
import type { TableProps } from 'antd';
|
||
import {
|
||
CopyOutlined, DeleteOutlined, GlobalOutlined, ImportOutlined, ReloadOutlined, SearchOutlined,
|
||
} from '@ant-design/icons';
|
||
import { huyaApi, type HuyaAccountItem, type HuyaBrowserExchangeKit } 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';
|
||
|
||
const { Text, Paragraph } = Typography;
|
||
const SCOPE = 'browser_exchange' as const;
|
||
const IMPORT_PAGE_SIZE = 20;
|
||
|
||
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('');
|
||
const [exchangeId, setExchangeId] = useState<number | null>(null);
|
||
|
||
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]);
|
||
|
||
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 handleExchange = async (record: HuyaAccountItem) => {
|
||
setExchangeId(record.id);
|
||
try {
|
||
const kit = await huyaApi.createBrowserExchange(record.id);
|
||
requestExtensionInject(kit);
|
||
setExchangeKit(kit);
|
||
setExchangeTitle(record.nickname || record.username || record.yyuid || `账号#${record.id}`);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setExchangeId(null);
|
||
}
|
||
};
|
||
|
||
const copyExchangeText = async (text: string, tip: string) => {
|
||
try {
|
||
await copyTextToClipboard(text);
|
||
message.success(tip);
|
||
} catch {
|
||
message.error('复制失败,请重试');
|
||
}
|
||
};
|
||
|
||
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: '操作',
|
||
width: 200,
|
||
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)}
|
||
>
|
||
浏览器兑换
|
||
</Button>
|
||
</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={`浏览器兑换 · ${exchangeTitle}`}
|
||
open={exchangeKit !== null}
|
||
width={720}
|
||
centered
|
||
onCancel={() => setExchangeKit(null)}
|
||
footer={exchangeKit ? [
|
||
<Button key="open" type="primary" icon={<GlobalOutlined />} onClick={() => openExchangeTarget(exchangeKit.target_url)}>打开兑换页面</Button>,
|
||
<Button key="done" onClick={() => { setExchangeKit(null); 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)。当前请按下方手动步骤操作。"
|
||
/>
|
||
)}
|
||
<Paragraph style={{ marginBottom: 0 }}>
|
||
<Text strong>手动步骤 1:</Text>
|
||
点击下方「打开兑换页面」,在新标签页打开兑换活动页。
|
||
</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>
|
||
确认页面右上角登录态为目标账号后完成兑换,回到此处点击「完成」。
|
||
</Paragraph>
|
||
</Space>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|