浏览器兑换独立成页面并按斗鱼模式优化导入

新增 /huya/browser-exchange 页面(精英宝典下方),支持导入账号与
执行兑换,客服仅见已分配账号;账号管理页移除该功能。导入弹窗对齐
斗鱼操作台:服务端分页筛选、可搜索标签、无 CK 不展示、导入本页/
一键导入全部/导入选中;新增 /accounts/ids 接口与 workbench
browser_exchange scope。
This commit is contained in:
yml2213
2026-09-05 18:30:29 +08:00
parent 230d6c1918
commit 178eba1eea
11 changed files with 493 additions and 130 deletions
+11 -2
View File
@@ -2,6 +2,14 @@
人工兑换在操作者自己的 Chrome/Edge 中完成:后端下发目标页地址和登录材料,登录态自动(扩展)或手动(控制台脚本)写入本机浏览器,兑换本身始终由人工点击,服务端不托管任何浏览器会话。
## 入口与账号范围
独立页面:虎牙 → 浏览器兑换(位于精英宝典下方;账号管理页不再提供该功能)。
- 页面维护一份「兑换账号列表」,通过「导入账号」从自己可见的账号池勾选加入;客服只能看到分配给自己的账号。
- 列表按用户隔离保存(工作台 scope `browser_exchange`),与精英宝典的账号集合互不影响。
- 账号需先在账号管理完成 App 协议登录(有 Cookie)才能执行兑换。
## 一键自动注入(推荐):安装内置扩展
项目自带 Chrome/Edge 扩展 `web/extension/huya-exchange-helper/`,安装一次后即可自动注入:
@@ -12,7 +20,7 @@
之后的流程:
1. 点击账号行的浏览器图标 → 后端返回兑换材料 → 扩展自动把该账号 Cookie 写入 `.huya.com` 域并在新标签页打开已登录的兑换页。
1. 在兑换列表点击账号的「浏览器兑换」→ 后端返回兑换材料 → 扩展自动把该账号 Cookie 写入 `.huya.com` 域并在新标签页打开已登录的兑换页。
2. 确认页面右上角账号无误后,手动完成兑换。
3. 回到后台点击「完成」。
@@ -20,7 +28,7 @@
## 手动方式(未装扩展时的兜底)
1. 点击账号行的浏览器图标,弹出「浏览器兑换」窗口。
1. 在兑换列表点击「浏览器兑换」,弹出「浏览器兑换」窗口。
2. 点击「打开兑换页面」在新标签页打开兑换活动页(地址来自配置的 `room_pid`,未配置时回落到活动页 `https://zt.huya.com/b02faae1/pc/index.html`)。
3. 点击「复制登录脚本」,在兑换页面按 `F12` 打开控制台,粘贴脚本并回车执行。Chrome/Edge 首次粘贴需先手动输入 `allow pasting` 再回车。脚本把该账号 Cookie 写入 `.huya.com` 域并自动跳转。
4. 确认登录态正确后完成兑换,回到系统点击「完成」。
@@ -43,6 +51,7 @@
- 脚本由 `core.huya.cookie_utils.browser_login_script` 生成:Cookie 以 JSON 数组内联(自动转义),逐条 `document.cookie` 写入 `domain=.huya.com; path=/`,有效期 180 天,最后跳转目标页。
- 扩展注入走同一条接口:前端解析 `cookie_string` 后通过 DOM 事件交给扩展,由 `chrome.cookies` API 写入。
- 兑换账号列表复用 `/api/huya/workbench-accounts`(`handbook_scope=browser_exchange`)。
- 服务端不保存任何会话状态。
## 安全说明
+32
View File
@@ -6,6 +6,7 @@ from web.backend.database import Base
from web.backend.models import HuyaAccount, HuyaWorkbench, HuyaWorkbenchAccount, User
from web.backend.routers.huya import (
delete_account,
list_huya_account_ids,
list_huya_workbench_accounts,
list_tasks,
update_huya_workbench_accounts,
@@ -70,6 +71,37 @@ class TestHuyaWorkbenchScope:
handbook_scope="invalid",
)
def test_browser_exchange_scope_and_account_ids(self):
no_cookie = HuyaAccount(uid="200", yyuid="200", cookie="")
self.session.add(no_cookie)
self.session.commit()
update_huya_workbench_accounts(
HuyaWorkbenchAccountsUpdate(handbook_scope="browser_exchange", account_ids=[self.account.id]),
db=self.session,
current=self.user,
)
assert list_huya_workbench_accounts("browser_exchange", db=self.session, current=self.user) == {
"account_ids": [self.account.id],
"configured": True,
}
# browser_exchange 与 elite 两个 scope 的账号集合互不影响
assert list_huya_workbench_accounts("elite", db=self.session, current=self.user) == {
"account_ids": [],
"configured": False,
}
# ids 接口:has_cookie=True 只返回有 Cookie 的账号
result = list_huya_account_ids(has_cookie=True, db=self.session, current=self.user)
assert result == {"account_ids": [self.account.id], "total": 1}
all_ids = list_huya_account_ids(db=self.session, current=self.user)
assert sorted(all_ids["account_ids"]) == sorted([self.account.id, no_cookie.id])
assert all_ids["total"] == 2
# ids 接口:search 匹配 uid
searched = list_huya_account_ids(search="200", db=self.session, current=self.user)
assert searched["account_ids"] == [no_cookie.id]
def test_account_delete_cleans_workbench_membership(self):
self.session.add(
HuyaWorkbenchAccount(
+26 -6
View File
@@ -315,10 +315,10 @@ def _require_huya_task_account_access(
raise HTTPException(status_code=403, detail="包含无权操作的虎牙账号")
def _elite_workbench_account_ids(db: Session, current: User) -> list[int]:
def _workbench_account_ids(db: Session, current: User, scope: str) -> list[int]:
query = db.query(HuyaWorkbenchAccount.account_id).filter(
HuyaWorkbenchAccount.user_id == current.id,
HuyaWorkbenchAccount.handbook_scope == "elite",
HuyaWorkbenchAccount.handbook_scope == scope,
)
if not _can_view_huya_all(current):
query = query.join(HuyaAccount, HuyaAccount.id == HuyaWorkbenchAccount.account_id).filter(
@@ -629,6 +629,26 @@ def accounts_summary(
}
@router.get("/accounts/ids")
def list_huya_account_ids(
search: str = "",
tag: str = "",
has_cookie: bool = False,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:task")),
):
"""返回当前用户可见的全部账号 ID(不受分页限制),供工作台一键导入。"""
query = _filter_huya_accounts_query(
_visible_huya_accounts_query(db, current),
current,
tag=tag or None,
has_cookie=has_cookie,
search=search,
)
rows = query.with_entities(HuyaAccount.id).all()
return {"account_ids": [row[0] for row in rows], "total": len(rows)}
@router.post("/accounts/import-cookies")
def import_cookies(
req: HuyaCookieImport,
@@ -1821,12 +1841,12 @@ def update_config(
@router.get("/workbench-accounts")
def list_huya_workbench_accounts(
handbook_scope: str = Query(..., pattern="^elite$"),
handbook_scope: str = Query(..., pattern="^elite$|^browser_exchange$"),
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:task")),
):
"""读取当前用户的虎牙精英宝典账号集合。"""
ids = _elite_workbench_account_ids(db, current)
"""读取当前用户指定工作台(精英宝典/浏览器兑换)的账号集合。"""
ids = _workbench_account_ids(db, current, handbook_scope)
configured = db.query(HuyaWorkbench.id).filter(
HuyaWorkbench.user_id == current.id,
HuyaWorkbench.handbook_scope == handbook_scope,
@@ -1840,7 +1860,7 @@ def update_huya_workbench_accounts(
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:task")),
):
"""覆盖虎牙精英宝典账号集合,支持跨浏览器同步。"""
"""覆盖当前用户指定工作台(精英宝典/浏览器兑换)的账号集合,支持跨浏览器同步。"""
ids = sorted(set(req.account_ids))
if any(account_id < 1 for account_id in ids):
raise HTTPException(status_code=400, detail="无效的账号 ID")
+1 -1
View File
@@ -511,7 +511,7 @@ class HuyaTaskBatchRequest(BaseModel):
class HuyaWorkbenchAccountsUpdate(BaseModel):
handbook_scope: str = Field(..., pattern="^elite$")
handbook_scope: str = Field(..., pattern="^elite$|^browser_exchange$")
account_ids: list[int] = Field(default_factory=list)
+2
View File
@@ -26,6 +26,7 @@ const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
const HuyaDeviceBindingsPage = lazy(() => import('./pages/HuyaDeviceBindingsPage'));
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage'));
const HuyaBrowserExchangePage = lazy(() => import('./pages/HuyaBrowserExchangePage'));
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
@@ -96,6 +97,7 @@ function AppContent() {
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
<Route path="huya/elite" element={lazyRoute(<HuyaElitePage />)} />
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
<Route path="users" element={lazyRoute(<UsersPage />)} />
+6 -3
View File
@@ -30,6 +30,7 @@ import type {
HuyaTaskBatchResult,
HuyaTaskItem,
HuyaBrowserExchangeKit,
HuyaWorkbenchScope,
MessageCountResponse,
MessageDeletedResponse,
MessageResponse,
@@ -42,8 +43,10 @@ export const huyaApi = {
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/huya/task-types'),
listAccounts: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_cookie?: boolean }) =>
api.get<HuyaAccountItem[], HuyaAccountItem[]>('/huya/accounts', { params }),
listAccountsPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_cookie?: boolean }) =>
listAccountsPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; search?: string; include_cookie?: boolean }) =>
api.get<PaginatedResponse<HuyaAccountItem>, PaginatedResponse<HuyaAccountItem>>('/huya/accounts', { params }),
listAccountIds: (params?: { search?: string; tag?: string; has_cookie?: boolean }) =>
api.get<{ account_ids: number[]; total: number }, { account_ids: number[]; total: number }>('/huya/accounts/ids', { params }),
accountsSummary: () => api.get<HuyaAccountSummary, HuyaAccountSummary>('/huya/accounts/summary'),
/** 设备绑定: 账号 ↔ 环境列表 (画像+指纹状态+账号表合并) */
listDeviceBindings: () =>
@@ -119,11 +122,11 @@ export const huyaApi = {
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
listRechargeGoods: () => api.get<HuyaRechargeGoodsItem[], HuyaRechargeGoodsItem[]>('/huya/recharge-goods'),
listWorkbenchAccounts: (scope: 'elite' = 'elite') =>
listWorkbenchAccounts: (scope: HuyaWorkbenchScope = 'elite') =>
api.get<{ account_ids: number[]; configured: boolean }, { account_ids: number[]; configured: boolean }>(
'/huya/workbench-accounts', { params: { handbook_scope: scope } },
),
updateWorkbenchAccounts: (accountIds: number[], scope: 'elite' = 'elite') =>
updateWorkbenchAccounts: (accountIds: number[], scope: HuyaWorkbenchScope = 'elite') =>
api.put<{ account_ids: number[]; success: boolean }, { account_ids: number[]; success: boolean }>(
'/huya/workbench-accounts', { handbook_scope: scope, account_ids: accountIds },
),
+3
View File
@@ -464,6 +464,9 @@ export interface HuyaAccountItem {
updated_at: string | null;
}
/** 工作台账号集合范围: elite=精英宝典 / browser_exchange=浏览器兑换 */
export type HuyaWorkbenchScope = 'elite' | 'browser_exchange';
/** 本机浏览器人工兑换材料 (登录脚本在用户自己的 Chrome/Edge 控制台执行) */
export interface HuyaBrowserExchangeKit {
account_id: number;
+2 -1
View File
@@ -6,7 +6,7 @@ import {
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined,
BookOutlined, TrophyOutlined, ShopOutlined,
BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined,
SafetyCertificateOutlined, FileSearchOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
@@ -96,6 +96,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
}
if (can('huya:task')) {
huyaItems.push({ key: '/huya/elite', label: '精英宝典', icon: <BookOutlined /> });
huyaItems.push({ key: '/huya/browser-exchange', label: '浏览器兑换', icon: <GlobalOutlined /> });
huyaItems.push({ key: '/huya/tasks', label: '旧版任务台', icon: <ShoppingCartOutlined /> });
}
+1 -117
View File
@@ -4,19 +4,17 @@ import {
} from 'antd';
import { message } from '../utils/antdMessage';
import type { TableProps } from 'antd';
import { CopyOutlined, DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, MobileOutlined, ReloadOutlined, SearchOutlined, TagOutlined, GlobalOutlined } from '@ant-design/icons';
import { DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, MobileOutlined, ReloadOutlined, SearchOutlined, TagOutlined } from '@ant-design/icons';
import {
huyaApi,
type HuyaAccountItem,
type HuyaAccountSummary,
type HuyaBrowserExchangeKit,
type HuyaPasswordLoginBatchItem,
type SupportUserItem,
} from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
import { copyTextToClipboard } from '../utils/clipboard';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
@@ -50,30 +48,6 @@ const STATUS_COLORS: Record<string, string> = {
invalid: 'error',
};
/** 解析后端 Cookie 字符串为 [name, value] 数组,供兑换扩展注入 */
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)];
});
}
/** 内置扩展的内容脚本会在所有页面打上此标记 */
function huyaExchangeHelperReady(): boolean {
return document.documentElement.dataset.huyaExchangeHelper === '1';
}
/** 请求内置扩展自动注入登录态;同时留存到 window 供扩展图标点击兜底读取 */
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 }));
}
export default function HuyaAccountsPage() {
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [users, setUsers] = useState<SupportUserItem[]>([]);
@@ -99,9 +73,6 @@ export default function HuyaAccountsPage() {
const [smsState, setSmsState] = useState('');
const [smsSending, setSmsSending] = useState(false);
const [smsLogging, setSmsLogging] = useState(false);
const [exchangeKit, setExchangeKit] = useState<HuyaBrowserExchangeKit | null>(null);
const [exchangeTitle, setExchangeTitle] = useState('');
const [exchangeLoading, setExchangeLoading] = useState(false);
const [searchText, setSearchText] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
@@ -380,34 +351,6 @@ export default function HuyaAccountsPage() {
}
};
const handleBrowserExchange = async (record: HuyaAccountItem) => {
setExchangeLoading(true);
try {
const kit = await huyaApi.createBrowserExchange(record.id);
requestExtensionInject(kit);
setExchangeKit(kit);
setExchangeTitle(record.nickname || record.username || record.yyuid || `账号#${record.id}`);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setExchangeLoading(false);
}
};
const openExchangePage = () => {
if (!exchangeKit) return;
window.open(exchangeKit.target_url, '_blank', 'noopener,noreferrer');
};
const copyExchangeText = async (text: string, tip: string) => {
try {
await copyTextToClipboard(text);
message.success(tip);
} catch {
message.error('复制失败,请重试');
}
};
const handleWebLoginSelected = async () => {
if (selectedCount === 0) {
message.warning('请先选择虎牙账号');
@@ -683,11 +626,6 @@ export default function HuyaAccountsPage() {
</Tooltip>
</>
)}
{canImport && record.has_cookie && (
<Tooltip title="在浏览器中打开兑换页面(用你自己的 Chrome/Edge 完成)">
<Button size="small" icon={<GlobalOutlined />} loading={exchangeLoading} onClick={() => void handleBrowserExchange(record)} />
</Tooltip>
)}
{canDelete && (
<Popconfirm title="确认删除这条虎牙账号?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
@@ -698,8 +636,6 @@ export default function HuyaAccountsPage() {
},
];
const exchangeHelperReady = huyaExchangeHelperReady();
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
@@ -922,58 +858,6 @@ export default function HuyaAccountsPage() {
</Paragraph>
</Space>
</Modal>
<Modal
title={`浏览器兑换 · ${exchangeTitle}`}
open={exchangeKit !== null}
width={720}
centered
onCancel={() => setExchangeKit(null)}
footer={exchangeKit ? [
<Button key="open" type="primary" icon={<GlobalOutlined />} onClick={openExchangePage}>打开兑换页面</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,开发者模式加载 web/extension/huya-exchange-helper 并刷新本页)。当前请按下方手动步骤操作。"
/>
)}
<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>
<Modal
title="批量设置标签"
open={batchTagVisible}
@@ -0,0 +1,377 @@
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>
);
}
+32
View File
@@ -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');
}