Files
live-hub-py/web/frontend/src/pages/HuyaBrowserExchangePage.tsx
T
yml2213 178eba1eea 浏览器兑换独立成页面并按斗鱼模式优化导入
新增 /huya/browser-exchange 页面(精英宝典下方),支持导入账号与
执行兑换,客服仅见已分配账号;账号管理页移除该功能。导入弹窗对齐
斗鱼操作台:服务端分页筛选、可搜索标签、无 CK 不展示、导入本页/
一键导入全部/导入选中;新增 /accounts/ids 接口与 workbench
browser_exchange scope。
2026-09-05 18:30:29 +08:00

378 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}