feat(web): 新增『虎牙设备绑定』管理页 — 一号一环境可视化

后端 (/huya/device-bindings):
- GET: 三方合并列表 (设备画像 + hydevice 指纹状态 + 账号表登录渠道)
  标识脱敏展示 (fingerprint/guid32 只留前12位)
- DELETE: 解绑 = 删除画像 + hydevice 状态 → 下次登录自动生成全新环境重新注册
前端:
- HuyaDeviceBindingsPage: 汇总卡片(绑定数/指纹状态/入库账号) + 明细表
  (机型/屏幕/CDID40/GUID32/Hebe/hydevice状态/登录渠道/最后登录) + 解绑重绑
- 路由 /huya/device-bindings + 菜单『设备绑定』(账号管理之下)
This commit is contained in:
yml2213
2026-08-29 16:39:30 +08:00
parent bf6dee1850
commit fda3d5257b
6 changed files with 243 additions and 0 deletions
+2
View File
@@ -23,6 +23,7 @@ const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
const HuyaDeviceBindingsPage = lazy(() => import('./pages/HuyaDeviceBindingsPage'));
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
@@ -88,6 +89,7 @@ function AppContent() {
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage key="esports" handbook="esports" />)} />
<Route path="douyu/peace" element={lazyRoute(<DouyuTasksPage key="peace" handbook="peace" />)} />
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/device-bindings" element={lazyRoute(<HuyaDeviceBindingsPage />)} />
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
+7
View File
@@ -5,6 +5,7 @@ import type {
AccountBulkTagRequest,
HuyaAccountItem,
HuyaAccountSummary,
HuyaDeviceBindingListResult,
HuyaAutoRegisterBatch,
HuyaAutoRegisterRequest,
HuyaAutoRegisterRetryRequest,
@@ -42,6 +43,12 @@ export const huyaApi = {
listAccountsPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_cookie?: boolean }) =>
api.get<PaginatedResponse<HuyaAccountItem>, PaginatedResponse<HuyaAccountItem>>('/huya/accounts', { params }),
accountsSummary: () => api.get<HuyaAccountSummary, HuyaAccountSummary>('/huya/accounts/summary'),
/** 设备绑定: 账号 ↔ 环境列表 (画像+指纹状态+账号表合并) */
listDeviceBindings: () =>
api.get<HuyaDeviceBindingListResult, HuyaDeviceBindingListResult>('/huya/device-bindings'),
/** 解绑: 删除设备画像与 hydevice 指纹状态, 下次登录自动生成全新环境 */
deleteDeviceBinding: (account: string) =>
api.delete<{ ok: boolean; message: string }, { ok: boolean; message: string }>(`/huya/device-bindings/${encodeURIComponent(account)}`),
importCookies: (text: string, tag: string = '') =>
api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }),
importPasswordAccounts: (text: string, tag: string = '') =>
+23
View File
@@ -463,6 +463,29 @@ export interface HuyaAccountItem {
updated_at: string | null;
}
/** 账号 ↔ 设备环境绑定 (一号一环境, 后端 data/huya_device_profiles.json + huya_fp_states) */
export interface HuyaDeviceBindingItem {
account: string;
vendor: string;
model: string;
screen: string;
fingerprint_masked: string;
guid32_masked: string;
hebe_count: number;
hdid32: string;
has_hydevice_state: boolean;
login_channel: string;
account_status: string;
bound_at: number | null;
last_login_at: number | null;
last_login_ok: boolean | null;
}
export interface HuyaDeviceBindingListResult {
items: HuyaDeviceBindingItem[];
total: number;
}
export interface HuyaAccountSummary extends BasicSummary {
password_ready_count: number;
point_count: number;
+1
View File
@@ -83,6 +83,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
// 虎牙
if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: <GiftOutlined /> });
huyaItems.push({ key: '/huya/device-bindings', label: '设备绑定', icon: <ApiOutlined /> });
}
if (canAny(['huya:account', 'huya:import'])) {
huyaItems.push({ key: '/huya/register', label: '自动注册', icon: <MobileOutlined /> });
@@ -0,0 +1,139 @@
/**
* 虎牙设备绑定管理页 — 账号 ↔ 设备环境 (一号一环境) 可视化。
*
* 数据来源 (后端 /huya/device-bindings 合并三处):
* 1. data/huya_device_profiles.json 账号画像 (机型/屏幕/指纹40/guid32/Hebe)
* 2. data/huya_fp_states/<账号>/ hydevice 高信任指纹状态 (sdid/40hex hdid 的根)
* 3. huya_accounts 表 登录渠道 / 账号状态
*
* 解绑 = 删除 1+2 → 下次 App 协议登录自动生成全新环境并重新注册签发 t2/t5。
*/
import { useCallback, useEffect, useState } from 'react';
import { Button, Card, Col, Popconfirm, Row, Statistic, Table, Tag, Tooltip, Typography } from 'antd';
import type { TableProps } from 'antd';
import { LinkOutlined, ReloadOutlined, RestOutlined } from '@ant-design/icons';
import { message } from '../utils/antdMessage';
import { huyaApi, type HuyaDeviceBindingItem } from '../api/modules';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
/** 登录渠道展示 (与账号管理页一致) */
const CHANNEL_LABELS: Record<string, { label: string; color: string }> = {
app: { label: 'App 协议', color: 'processing' },
web: { label: 'Web 旧版', color: 'warning' },
sms: { label: '短信', color: 'cyan' },
};
export default function HuyaDeviceBindingsPage() {
const [items, setItems] = useState<HuyaDeviceBindingItem[]>([]);
const [loading, setLoading] = useState(false);
const [unbinding, setUnbinding] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const result = await huyaApi.listDeviceBindings();
setItems(result.items || []);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
/** 解绑: 清除画像+指纹状态, 下次登录生成全新设备 */
const handleUnbind = async (account: string) => {
setUnbinding(account);
try {
await huyaApi.deleteDeviceBinding(account);
message.success(`已解绑 ${account}, 下次 App 协议登录将生成全新设备环境`);
load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setUnbinding('');
}
};
const columns: TableProps<HuyaDeviceBindingItem>['columns'] = [
{ title: '账号', dataIndex: 'account', width: 150, ellipsis: true,
render: (v: string) => <Text copyable={{ text: v }}>{v}</Text> },
{ title: '机型', width: 150,
render: (_: unknown, r: HuyaDeviceBindingItem) => (
<span>{r.model || '-'} <Text type="secondary" style={{ fontSize: 12 }}>({r.vendor})</Text></span>
) },
{ title: '屏幕', dataIndex: 'screen', width: 150, ellipsis: true },
{ title: '设备指纹 (CDID40)', dataIndex: 'fingerprint_masked', width: 170 },
{ title: 'GUID32', dataIndex: 'guid32_masked', width: 160 },
{ title: 'Hebe', dataIndex: 'hebe_count', width: 70, align: 'center',
render: (n: number) => <Tag color="geekblue">{n} </Tag> },
{ title: 'hydevice 状态', dataIndex: 'has_hydevice_state', width: 110, align: 'center',
render: (ok: boolean) => ok
? <Tag color="success"></Tag>
: <Tooltip title="尚无高信任指纹状态, 下次 App 登录时生成"><Tag></Tag></Tooltip> },
{ title: '登录渠道', dataIndex: 'login_channel', width: 100, align: 'center',
render: (c: string) => {
const info = CHANNEL_LABELS[c];
return info ? <Tag color={info.color}>{info.label}</Tag> : <Tag></Tag>;
} },
{ title: '账号状态', dataIndex: 'account_status', width: 100, align: 'center',
render: (s: string) => s === '未入库' ? <Tag></Tag> : <span>{s}</span> },
{ title: '最后登录', dataIndex: 'last_login_at', width: 160,
render: (_: unknown, r: HuyaDeviceBindingItem) => {
if (!r.last_login_at) return <Text type="secondary">-</Text>;
const time = new Date(r.last_login_at * 1000).toLocaleString();
return <span>{time} {r.last_login_ok === false && <Tag color="error"></Tag>}</span>;
} },
{ title: '操作', width: 110, align: 'center',
render: (_: unknown, r: HuyaDeviceBindingItem) => (
<Popconfirm
title={`解绑 ${r.account}?`}
description="删除设备画像与指纹状态, 下次登录将生成全新设备环境并重新注册"
onConfirm={() => handleUnbind(r.account)}
>
<Button danger size="small" icon={<RestOutlined />} loading={unbinding === r.account}>
</Button>
</Popconfirm>
) },
];
const withState = items.filter((i) => i.has_hydevice_state).length;
const linked = items.filter((i) => i.account_status !== '未入库').length;
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}></h2>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</div>
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}><Card size="small"><Statistic title="绑定环境总数" value={items.length} prefix={<LinkOutlined />} /></Card></Col>
<Col xs={24} sm={8}><Card size="small"><Statistic title="已生成指纹状态" value={withState} /></Card></Col>
<Col xs={24} sm={8}><Card size="small"><Statistic title="已入库账号" value={linked} /></Card></Col>
</Row>
<Card size="small" style={{ marginBottom: 16 }}>
<Text type="secondary">
一号一环境: 每个账号绑定一套独立设备画像 (//40/GUID32/Hebe) hydevice
; App safedeviceid/device_id
t1.t0 app (R15),
</Text>
</Card>
<Table<HuyaDeviceBindingItem>
rowKey="account"
columns={columns}
dataSource={items}
loading={loading}
pagination={{ pageSize: 20, showSizeChanger: false, showTotal: (t) => `${t}` }}
size="middle"
/>
</div>
);
}