- routers/huya.py: POST /api/huya/device-bindings/batch-delete (去重/跳过无记录/删画像+指纹目录) - schemas.py: HuyaDeviceBindingsBatchDeleteRequest - DeviceBindingsPage: 多选(rowSelection preserve) / 全选全部 / 解绑选中(N) + loading, 刷新后清理失效选中 - api/huya.ts + types.ts: deleteDeviceBindings 与响应类型 验证: 批量解绑逻辑单测 OK; 77 后端单测 OK; tsc + vite build OK
201 lines
8.5 KiB
TypeScript
201 lines
8.5 KiB
TypeScript
/**
|
|
* 虎牙设备绑定管理页 — 账号 ↔ 设备环境 (一号一环境) 可视化。
|
|
*
|
|
* 数据来源 (后端 /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, Space, 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 [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
|
const [unbindingSelected, setUnbindingSelected] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await huyaApi.listDeviceBindings();
|
|
setItems(result.items || []);
|
|
// 已解绑/消失的账号从选中里剔除,避免残留脏选择。
|
|
setSelectedRowKeys((keys) => {
|
|
const alive = new Set((result.items || []).map((item) => item.account));
|
|
return keys.filter((key) => alive.has(String(key)));
|
|
});
|
|
} 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 handleUnbindSelected = async () => {
|
|
const accounts = selectedRowKeys.map(String);
|
|
if (accounts.length === 0) return;
|
|
setUnbindingSelected(true);
|
|
try {
|
|
const result = await huyaApi.deleteDeviceBindings(accounts);
|
|
message.success(result.message || `已解绑 ${result.removed.length} 个账号`);
|
|
setSelectedRowKeys([]);
|
|
load();
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e));
|
|
} finally {
|
|
setUnbindingSelected(false);
|
|
}
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
setSelectedRowKeys(items.map((item) => item.account));
|
|
};
|
|
|
|
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>
|
|
<Space wrap>
|
|
{selectedRowKeys.length > 0 && (
|
|
<Text type="secondary">
|
|
已选 <b>{selectedRowKeys.length}</b> 个账号
|
|
</Text>
|
|
)}
|
|
<Button size="small" disabled={items.length === 0} onClick={handleSelectAll}>
|
|
全选全部
|
|
</Button>
|
|
<Popconfirm
|
|
title={`确认批量解绑选中的 ${selectedRowKeys.length} 个账号?`}
|
|
description="删除设备画像与指纹状态, 下次 App 协议登录将生成全新设备环境并重新注册"
|
|
okText="解绑"
|
|
cancelText="取消"
|
|
disabled={selectedRowKeys.length === 0}
|
|
onConfirm={handleUnbindSelected}
|
|
>
|
|
<Button
|
|
danger
|
|
size="small"
|
|
icon={<RestOutlined />}
|
|
disabled={selectedRowKeys.length === 0}
|
|
loading={unbindingSelected}
|
|
>
|
|
解绑选中 ({selectedRowKeys.length})
|
|
</Button>
|
|
</Popconfirm>
|
|
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
|
</Space>
|
|
</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}
|
|
size="middle"
|
|
rowSelection={{
|
|
selectedRowKeys,
|
|
onChange: (keys) => setSelectedRowKeys(keys),
|
|
preserveSelectedRowKeys: true,
|
|
}}
|
|
pagination={{ pageSize: 20, showSizeChanger: false, showTotal: (t) => `共 ${t} 条` }}
|
|
/>
|
|
|
|
</div>
|
|
);
|
|
}
|