feat(huya): 工作台补手机号/精英宝典列与一键查询
- 手机号列:外部 CK 的 `----手机号` 本来就落在 huya_accounts.game_phone,
列表一直没展示;现在单独成列,并纳入搜索
- 精英宝典列:显示「已开通/未开通/未查询」+ 开通时间 + 有效期
· 开通状态 = 积分中心 getRedeemNeedGoods 的 tag5(真机对照网页:1=已开通,
0=未开通,此时 tag7 会列出开通套餐),拿不准时为 NULL(显示「未查询」)
· 开通时间 = revenueWebUI.queryUserOrderList 里已支付的宝典订单 pay_time
(按 SPU 白名单 + 订单名含「精英宝典」识别,未支付订单不计,取最近一次)
· 有效期 = PlayMallUI.queryAllocation 页面配置的本赛季节点 tag10/tag11
(2026-10-15 抓包:精英宝典福利季丨Q4,2026-10-09 ~ 2026-12-31 23:59:59),
不写死索引,递归找带 tag10/tag11 的节点
- 新任务 query_all「一键查询」:勾选账号一次跑完角色绑定 + 宝典开通 + 积分,
按部分失败如实汇总(某个接口挂了不影响其它结论),并把宝典状态写回账号
- 迁移 20261015_0053:huya_accounts 新增 elite_opened / elite_open_time /
elite_expire_time(时间存 Unix 秒,与斗鱼 elite_expire_time 同约定)
- 开通商品按页面「赠送指定礼物得积分」那一排对齐成 6 款(去掉页面已不用的
旧版精英宝典 hy-5879340),列表接口只返回账号级快照并按 spu 去重
- 建 WSS 会话失败(积分中心端点静默不回包)时回退 HTTP cdnws,
不再让整批任务判失败
- 列表列宽改按比例分配,避免宽屏下被拉出奇怪的宽度;最近操作不再重复状态
- 新增 tests/test_huya_elite_query.py(10 条);同步迁移头版本断言
真机验证:#65 已开通(10-13 20:41:45,有效期 12-31)、#70 新开通
(10-15 16:37:37)判定与订单/网页一致
This commit is contained in:
@@ -695,6 +695,12 @@ export interface HuyaAccountItem {
|
||||
game_name: string;
|
||||
game_channel: string;
|
||||
game_phone: string;
|
||||
/** 精英宝典是否已开通(null = 还没查过) */
|
||||
elite_opened?: boolean | null;
|
||||
/** 最近一次宝典订单支付时间(Unix 毫秒) */
|
||||
elite_open_time?: number | null;
|
||||
/** 宝典有效期(Unix 毫秒;接口没给就是空) */
|
||||
elite_expire_time?: number | null;
|
||||
sms_url?: string;
|
||||
assigned_to: number | null;
|
||||
assigned_username: string | null;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { formatTime, formatUnixSeconds } from '../utils/time';
|
||||
|
||||
const { Text } = Typography;
|
||||
const SCOPE = 'elite' as const;
|
||||
@@ -27,7 +27,7 @@ const TASK_LABELS: Record<string, string> = {
|
||||
query_act_tasks: '读取宝典任务', get_bind_qr: '获取绑定二维码', query_game_name: '查询游戏角色',
|
||||
confirm_bind: '确认绑定角色', query_points: '查询积分', refresh_goods: '刷新商品',
|
||||
exchange_goods: '兑换商品', query_exchange_records: '查询兑换记录', create_recharge_order: '生成开通支付码',
|
||||
refresh_recharge_goods: '刷新开通商品',
|
||||
refresh_recharge_goods: '刷新开通商品', query_all: '一键查询(宝典+角色+积分)',
|
||||
};
|
||||
const TASK_COLORS: Record<string, string> = {
|
||||
planned: 'default', pending: 'default', running: 'processing', success: 'success',
|
||||
@@ -39,6 +39,7 @@ const TASK_STATUS_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const HUYA_QUICK_ACTIONS = [
|
||||
{ key: 'query_all', icon: <SearchOutlined /> },
|
||||
{ key: 'get_bind_qr', icon: <QrcodeOutlined /> },
|
||||
{ key: 'query_game_name', icon: <SearchOutlined /> },
|
||||
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
|
||||
@@ -237,7 +238,7 @@ export default function HuyaElitePage() {
|
||||
|
||||
const filteredAccounts = useMemo(() => {
|
||||
const value = search.trim().toLowerCase();
|
||||
return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name].some((v) => String(v || '').toLowerCase().includes(value)));
|
||||
return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name, item.game_phone].some((v) => String(v || '').toLowerCase().includes(value)));
|
||||
}, [accounts, search]);
|
||||
const filteredGoods = useMemo(() => goods.map((item) => ({ ...item, label: `${item.name} / ${item.price ?? '-'}分 / ${item.remain_text || '动态库存'}` })), [goods]);
|
||||
const filteredRechargeGoods = useMemo(() => rechargeGoods.map((item) => ({ ...item, label: `${item.name || item.spu_id} / ${item.price ? `${(item.price / 100).toFixed(2)}元` : '价格动态'} / ${item.spu_id}` })).sort((a, b) => Number(a.raw?.raw_order ?? 0) - Number(b.raw?.raw_order ?? 0) || a.id - b.id), [rechargeGoods]);
|
||||
@@ -250,20 +251,23 @@ export default function HuyaElitePage() {
|
||||
}, [filteredRechargeGoods, selectedRechargeSpu]);
|
||||
const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length;
|
||||
|
||||
// 列宽按比例分配(percent),表格宽度随容器缩放,不会被拉出「奇奇怪怪」的宽度
|
||||
const accountColumns: TableProps<HuyaAccountItem>['columns'] = [
|
||||
{ title: '#', width: 45, render: (_value, _row, index) => <Text type="secondary">{index + 1}</Text> },
|
||||
{ title: '账号', width: 180, render: (_value, row) => <Space direction="vertical" size={0} style={{ width: '100%' }}><Text strong ellipsis title={row.nickname || row.username || row.uid}>{row.nickname || row.username || row.uid}</Text><Text type="secondary" style={{ fontSize: 12 }} ellipsis title={row.username}>UID {row.uid || '-'}</Text></Space> },
|
||||
{ title: '游戏名', width: 160, render: (_value, row) => <Space direction="vertical" size={0} style={{ width: '100%' }}><Text ellipsis title={row.game_name || row.game_channel}>{row.game_name || <Text type="secondary">未查询</Text>}</Text>{row.game_channel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.game_channel}</Text>}</Space> },
|
||||
{ title: '积分', dataIndex: 'points', width: 75, align: 'right', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
|
||||
{ title: '最近操作', width: 250, render: (_value, row) => { const task = latestTask(tasks, row.id); return task ? <Space direction="vertical" size={1} style={{ width: '100%' }}><Space size={4}><Tag color={TASK_COLORS[task.status] || 'default'}>{TASK_STATUS_LABELS[task.status] || task.status}</Tag><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text></Space><Text type="secondary" ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
|
||||
{ title: '二维码', width: 80, align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); if (task) void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }} title="点击查看二维码">{image ? <img src={`data:image/png;base64,${image}`} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} bordered={false} />}</div>; } },
|
||||
{ title: '状态', width: 90, render: (_value, row) => { const task = latestTask(tasks, row.id); const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
|
||||
{ title: '#', width: '4%', align: 'center', render: (_value, _row, index) => <Text type="secondary">{index + 1}</Text> },
|
||||
{ title: '账号', width: '15%', ellipsis: true, render: (_value, row) => <Space direction="vertical" size={0} style={{ width: '100%' }}><Text strong ellipsis title={row.nickname || row.username || row.uid}>{row.nickname || row.username || row.uid}</Text><Text type="secondary" style={{ fontSize: 12 }} ellipsis title={row.username}>UID {row.uid || '-'}</Text></Space> },
|
||||
{ title: '手机号', width: '10%', ellipsis: true, render: (_value, row) => row.game_phone || <Text type="secondary">-</Text> },
|
||||
{ title: '游戏名', width: '13%', ellipsis: true, render: (_value, row) => <Space direction="vertical" size={0} style={{ width: '100%' }}><Text ellipsis title={row.game_name || row.game_channel}>{row.game_name || <Text type="secondary">未查询</Text>}</Text>{row.game_channel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.game_channel}</Text>}</Space> },
|
||||
{ title: '精英宝典', width: '13%', ellipsis: true, render: (_value, row) => <Space direction="vertical" size={0} style={{ width: '100%' }}><Tag color={row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space> },
|
||||
{ title: '积分', dataIndex: 'points', width: '7%', align: 'right', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
|
||||
{ title: '最近操作', width: '25%', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); return task ? <Space direction="vertical" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type="secondary" ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
|
||||
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); if (task) void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }} title="点击查看二维码">{image ? <img src={`data:image/png;base64,${image}`} alt="二维码" style={{ width: 44, height: 44, objectFit: 'contain' }} /> : <QRCode value={url} size={44} bordered={false} />}</div>; } },
|
||||
{ title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
|
||||
];
|
||||
|
||||
const operationBody = (
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<div className="huya-operation-section"><div className="huya-operation-title"><QrcodeOutlined /><span>绑定与查询</span></div><div className="huya-action-grid">
|
||||
<Button size="small" icon={<QrcodeOutlined />} onClick={() => void startTask('get_bind_qr')} disabled={starting || !selectedIds.length}>绑定二维码</Button><Button size="small" icon={<SearchOutlined />} onClick={() => void startTask('query_game_name')} disabled={starting || !selectedIds.length}>查询角色</Button><Button size="small" icon={<CheckCircleOutlined />} onClick={() => void startTask('confirm_bind')} disabled={starting || !selectedIds.length}>确认绑定</Button><Button size="small" icon={<SearchOutlined />} onClick={() => void startTask('query_points')} disabled={starting || !selectedIds.length}>刷新积分</Button><Button size="small" onClick={() => void startTask('query_act_tasks')} disabled={starting || !selectedIds.length}>宝典任务</Button><Button size="small" onClick={() => void startTask('query_exchange_records')} disabled={starting || !selectedIds.length}>兑换记录</Button>
|
||||
<Button size="small" type="primary" icon={<SearchOutlined />} onClick={() => void startTask('query_all')} disabled={starting || !selectedIds.length}>一键查询</Button><Button size="small" icon={<QrcodeOutlined />} onClick={() => void startTask('get_bind_qr')} disabled={starting || !selectedIds.length}>绑定二维码</Button><Button size="small" icon={<SearchOutlined />} onClick={() => void startTask('query_game_name')} disabled={starting || !selectedIds.length}>查询角色</Button><Button size="small" icon={<CheckCircleOutlined />} onClick={() => void startTask('confirm_bind')} disabled={starting || !selectedIds.length}>确认绑定</Button><Button size="small" icon={<SearchOutlined />} onClick={() => void startTask('query_points')} disabled={starting || !selectedIds.length}>刷新积分</Button><Button size="small" onClick={() => void startTask('query_act_tasks')} disabled={starting || !selectedIds.length}>宝典任务</Button><Button size="small" onClick={() => void startTask('query_exchange_records')} disabled={starting || !selectedIds.length}>兑换记录</Button>
|
||||
</div></div>
|
||||
<div className="huya-operation-section"><div className="huya-operation-title"><ShoppingOutlined /><span>兑换</span></div><Select size="small" showSearch optionFilterProp="label" value={selectedGoodsId || undefined} onChange={setSelectedGoodsId} options={filteredGoods.map((item) => ({ value: item.product_id, label: item.label }))} placeholder="选择兑换商品" style={{ width: '100%' }} /><div className="huya-action-grid"><Button size="small" icon={<ReloadOutlined />} onClick={() => void startTask('refresh_goods')} disabled={starting || !selectedIds.length}>刷新商品</Button><Button size="small" type="primary" icon={<ShoppingOutlined />} onClick={() => void startTask('exchange_goods')} disabled={starting || !selectedIds.length || !selectedGoodsId}>兑换商品</Button></div></div>
|
||||
<div className="huya-operation-section"><div className="huya-operation-title"><GiftOutlined /><span>开通宝典</span></div><Button block size="small" icon={<QrcodeOutlined />} onClick={() => void startTask('create_recharge_order')} disabled={starting || !selectedIds.length}>生成开通支付码</Button></div>
|
||||
@@ -272,7 +276,7 @@ export default function HuyaElitePage() {
|
||||
</Space>
|
||||
);
|
||||
|
||||
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1040, y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
|
||||
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
|
||||
|
||||
const configField = (key: Exclude<keyof HuyaConfig, 'updated_at'>, label: string) => configDraft && <Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}><Text>{label}</Text><Input value={configDraft[key] || ''} onChange={(event) => setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} /></Space>;
|
||||
const contextMenuStyle = contextMenu ? {
|
||||
|
||||
@@ -31,6 +31,7 @@ const FALLBACK_TASK_TYPES: Record<string, string> = {
|
||||
confirm_bind: '确认绑定',
|
||||
query_points: '一键查询积分',
|
||||
query_game_name: '一键查询游戏名',
|
||||
query_all: '一键查询(角色+宝典+积分)',
|
||||
query_exchange_records: '一键查询兑换记录',
|
||||
refresh_goods: '刷新商品列表',
|
||||
refresh_recharge_goods: '刷新充值商品列表',
|
||||
@@ -39,6 +40,7 @@ const FALLBACK_TASK_TYPES: Record<string, string> = {
|
||||
};
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ key: 'query_all', icon: <SearchOutlined /> },
|
||||
{ key: 'get_bind_qr', icon: <LinkOutlined /> },
|
||||
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
|
||||
{ key: 'query_points', icon: <SearchOutlined /> },
|
||||
@@ -1136,6 +1138,19 @@ export default function HuyaTasksPage() {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
|
||||
// 一键查询:积分 + 宝典开通 + 角色,一处看全(明细在任务 message 里)
|
||||
if (record.task_type === 'query_all') {
|
||||
const tags: Array<{ text: string; color: string }> = [];
|
||||
if (typeof value?.points === 'number') tags.push({ text: `积分 ${value.points}`, color: 'blue' });
|
||||
if (typeof value?.elite_opened === 'boolean') {
|
||||
tags.push({ text: value.elite_opened ? '已开通宝典' : '未开通宝典', color: value.elite_opened ? 'green' : 'default' });
|
||||
}
|
||||
if (typeof value?.role_name === 'string' && value.role_name) tags.push({ text: `角色 ${value.role_name}`, color: 'cyan' });
|
||||
if (tags.length) {
|
||||
return <Space size={6} wrap>{tags.map((tag) => <Tag key={tag.text} color={tag.color}>{tag.text}</Tag>)}</Space>;
|
||||
}
|
||||
}
|
||||
|
||||
// 积分结果:新版走积分中心(points),旧任务记录里是活动接口的 available_score
|
||||
const points = typeof value?.points === 'number' ? value.points : value?.available_score;
|
||||
if (typeof points === 'number') {
|
||||
|
||||
Reference in New Issue
Block a user