@@ -922,58 +858,6 @@ export default function HuyaAccountsPage() {
-
setExchangeKit(null)}
- footer={exchangeKit ? [
- } onClick={openExchangePage}>打开兑换页面,
- ,
- ] : null}
- >
- {exchangeKit && (
-
- {exchangeHelperReady ? (
-
- ) : (
-
- )}
-
- 手动步骤 1:
- 点击下方「打开兑换页面」,在新标签页打开兑换活动页。
-
-
- 手动步骤 2:
- 点击「复制登录脚本」,在兑换页面按 F12 打开控制台,粘贴脚本并回车执行。
- Chrome/Edge 首次粘贴需先手动输入 allow pasting 再回车。脚本会写入该账号的登录态并自动跳转。
-
-
- } onClick={() => void copyExchangeText(exchangeKit.cookie_script, '登录脚本已复制')}>复制登录脚本
- } onClick={() => void copyExchangeText(exchangeKit.cookie_string, 'Cookie 字符串已复制')}>复制 Cookie 字符串
-
-
- 备选:安装 Cookie-editor 类扩展后,在 .huya.com 域下导入 Cookie 字符串。脚本和 Cookie 等同账号凭证,请勿泄露给他人。
-
-
- 手动步骤 3:
- 确认页面右上角登录态为目标账号后完成兑换,回到此处点击「完成」。
-
-
- )}
-
-
([]);
+ const [accounts, setAccounts] = useState([]);
+ const [loading, setLoading] = useState(false);
+ // 导入弹窗:候选池走服务端分页筛选(搜索/标签/仅有 CK),与斗鱼操作台导入一致
+ const [importOpen, setImportOpen] = useState(false);
+ const [importPool, setImportPool] = useState([]);
+ 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([]);
+ const [importSelectedIds, setImportSelectedIds] = useState([]);
+ const [importAllLoading, setImportAllLoading] = useState(false);
+ const [exchangeKit, setExchangeKit] = useState(null);
+ const [exchangeTitle, setExchangeTitle] = useState('');
+ const [exchangeId, setExchangeId] = useState(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 => {
+ 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['columns'] = [
+ {
+ title: '账号',
+ render: (_value, record) => (
+
+ {record.nickname || record.username || record.uid}
+ {record.username && record.username !== (record.nickname || record.username) && (
+ {record.username}
+ )}
+
+ ),
+ },
+ { title: '标签', dataIndex: 'tag', render: (tag: string) => (tag ? {tag} : '-') },
+ {
+ title: '登录态',
+ render: (_value, record) => (record.has_cookie
+ ? 有 Cookie
+ : 无 Cookie),
+ },
+ {
+ title: '操作',
+ width: 200,
+ render: (_value, record) => (
+
+
+ }
+ disabled={!record.has_cookie}
+ loading={exchangeId === record.id}
+ onClick={() => void handleExchange(record)}
+ >
+ 浏览器兑换
+
+
+ void removeAccount(record)}>
+ } />
+
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
+
浏览器兑换
+ 把账号登录态写入你自己的 Chrome/Edge 后打开兑换页,兑换手动完成;客服仅可见已分配给自己的账号。
+
+
+ } onClick={() => setImportOpen(true)}>导入账号
+ } onClick={() => void load()} loading={loading}>刷新
+
+
+
+ {!exchangeHelperReady && (
+
+ )}
+
+
+
+ rowKey="id"
+ size="small"
+ columns={columns}
+ dataSource={accounts}
+ loading={loading}
+ pagination={accounts.length > 20 ? { pageSize: 20 } : false}
+ locale={{ emptyText: '还没有账号,点击右上角「导入账号」开始' }}
+ />
+
+
+ setImportOpen(false)}
+ footer={null}
+ width={700}
+ >
+
+
+ }
+ allowClear
+ value={importSearch}
+ onChange={(event) => setImportSearch(event.target.value)}
+ style={{ width: 220 }}
+ />
+
+
+
+
+
+
+
+ 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 ? {value} : '-') },
+ ]}
+ pagination={{
+ current: importPage,
+ pageSize: IMPORT_PAGE_SIZE,
+ total: importTotal,
+ size: 'small',
+ showLessItems: true,
+ showTotal: (total) => `共 ${total} 条`,
+ onChange: setImportPage,
+ }}
+ locale={{ emptyText: '没有可导入的账号' }}
+ />
+
+
+ setExchangeKit(null)}
+ footer={exchangeKit ? [
+ } onClick={() => openExchangeTarget(exchangeKit.target_url)}>打开兑换页面,
+ ,
+ ] : null}
+ >
+ {exchangeKit && (
+
+ {exchangeHelperReady ? (
+
+ ) : (
+
+ )}
+
+ 手动步骤 1:
+ 点击下方「打开兑换页面」,在新标签页打开兑换活动页。
+
+
+ 手动步骤 2:
+ 点击「复制登录脚本」,在兑换页面按 F12 打开控制台,粘贴脚本并回车执行。
+ Chrome/Edge 首次粘贴需先手动输入 allow pasting 再回车。脚本会写入该账号的登录态并自动跳转。
+
+
+ } onClick={() => void copyExchangeText(exchangeKit.cookie_script, '登录脚本已复制')}>复制登录脚本
+ } onClick={() => void copyExchangeText(exchangeKit.cookie_string, 'Cookie 字符串已复制')}>复制 Cookie 字符串
+
+
+ 备选:安装 Cookie-editor 类扩展后,在 .huya.com 域下导入 Cookie 字符串。脚本和 Cookie 等同账号凭证,请勿泄露给他人。
+
+
+ 手动步骤 3:
+ 确认页面右上角登录态为目标账号后完成兑换,回到此处点击「完成」。
+
+
+ )}
+
+
+ );
+}
diff --git a/web/frontend/src/utils/huyaExchange.ts b/web/frontend/src/utils/huyaExchange.ts
new file mode 100644
index 0000000..c20668b
--- /dev/null
+++ b/web/frontend/src/utils/huyaExchange.ts
@@ -0,0 +1,32 @@
+/** 虎牙浏览器兑换:与内置扩展(web/extension/huya-exchange-helper)的通信桥。 */
+
+import type { HuyaBrowserExchangeKit } from '../api/modules';
+
+/** 解析后端 Cookie 字符串为 [name, value] 数组,供兑换扩展注入 */
+export function parseCookieString(cookieString: string): [string, string][] {
+ return cookieString
+ .split(';')
+ .map((part) => part.trim())
+ .filter((part) => part.includes('='))
+ .map((part) => {
+ const idx = part.indexOf('=');
+ return [part.slice(0, idx), part.slice(idx + 1)];
+ });
+}
+
+/** 内置扩展的内容脚本会在所有页面打上此标记 */
+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) });
+ (window as unknown as { __huyaExchangeKit?: string }).__huyaExchangeKit = payload;
+ window.dispatchEvent(new CustomEvent('huya-exchange-kit', { detail: payload }));
+}
+
+/** 在新标签页打开兑换页面(noopener 避免反向标签劫持) */
+export function openExchangeTarget(targetUrl: string): void {
+ window.open(targetUrl, '_blank', 'noopener,noreferrer');
+}