feat(douyu): 和平小店扭蛋碎片查询 + 深色模式修复 + 任务状态 WS 即时推送
- 碎片: 新增 query_xpd_fragments 任务, 复用 e语言 gamecoin 接口取扭蛋币 jb2, 账号已存角色回退(规避 Livelink 风控), accounts.xpd_fragments 字段与迁移 - 列表: 和平小店移除鱼翅/限制兑换/换绑时间列, 新增扭蛋碎片列; 操作区硬编码白底改用 theme token, 深色模式适配(三个手册共用) - 轮询: 任务状态经 WS(level=task)即时推送(二维码/绑定进度即时展示), pending/running/终态全覆盖, REST/WS 载荷统一 douyu_task_payload, raw 快照递归剥离, HTTP 轮询降频 5s/30s 兜底
This commit is contained in:
@@ -233,6 +233,7 @@ export interface DouyuTaskAccountItem {
|
||||
xpd_plat_id: number | null;
|
||||
xpd_area_id: number | null;
|
||||
xpd_balance: number | null;
|
||||
xpd_fragments: number | null;
|
||||
xpd_bind_status: string;
|
||||
assigned_to: number | null;
|
||||
assigned_username: string | null;
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ConnectOptions {
|
||||
onClose?: () => void;
|
||||
onError?: () => void;
|
||||
onResult?: () => void;
|
||||
onTask?: (task: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
@@ -76,8 +77,13 @@ export function useWebSocketLogs() {
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as RealtimeLog;
|
||||
const msg = JSON.parse(event.data) as RealtimeLog & { task?: Record<string, unknown> };
|
||||
if (msg.level === 'heartbeat') return;
|
||||
if (msg.level === 'task') {
|
||||
// 任务状态即时推送(不进日志),前端直接局部更新
|
||||
if (msg.task) callbacksMapRef.current.get(key)?.onTask?.(msg.task);
|
||||
return;
|
||||
}
|
||||
if (msg.level === 'result') {
|
||||
callbacksMapRef.current.get(key)?.onResult?.();
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography,
|
||||
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography, theme,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
@@ -88,8 +88,9 @@ const PEACE_QUICK_ACTIONS = [
|
||||
{ key: 'query_xpd_bind_info', icon: <SearchOutlined /> },
|
||||
{ key: 'confirm_xpd_bind', icon: <CheckCircleOutlined /> },
|
||||
{ key: 'query_xpd_role', icon: <SearchOutlined /> },
|
||||
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
|
||||
{ key: 'query_xpd_balance', icon: <SearchOutlined /> },
|
||||
{ key: 'query_xpd_fragments', icon: <SearchOutlined /> },
|
||||
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
|
||||
];
|
||||
|
||||
const ELITE_TASK_TYPES = new Set([
|
||||
@@ -131,6 +132,7 @@ const PEACE_TASK_TYPES = new Set([
|
||||
'query_xpd_role',
|
||||
'refresh_xpd_goods',
|
||||
'query_xpd_balance',
|
||||
'query_xpd_fragments',
|
||||
]);
|
||||
const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
|
||||
const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
|
||||
@@ -314,6 +316,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
? PEACE_QUICK_ACTIONS
|
||||
: (isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS);
|
||||
const { can } = usePermissions();
|
||||
const { token } = theme.useToken();
|
||||
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
|
||||
// 工作台已导入账号 ID(localStorage 持久化,默认空白,用户手动导入/移除)
|
||||
const [workbenchIds, setWorkbenchIds] = useState<number[]>(() => {
|
||||
@@ -656,9 +659,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
|
||||
const selectedHasRunning = selectedIds.some((id) => runningAccountIds.has(id));
|
||||
|
||||
// 有活跃任务或 WS 连接时 3 秒刷 tasks;空闲 15 秒轻量刷新
|
||||
// 任务状态由 WS(level=task) 即时推送;轮询仅作兜底(多标签页/断线重连),
|
||||
// 有活跃任务或 WS 连接时 5 秒一次,空闲 30 秒轻量刷新
|
||||
useEffect(() => {
|
||||
const intervalMs = hasActiveTasks || logs.connected || runningBatchIds.size > 0 ? 3000 : 15000;
|
||||
const intervalMs = hasActiveTasks || logs.connected || runningBatchIds.size > 0 ? 5000 : 30000;
|
||||
const timer = setInterval(loadTasks, intervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasActiveTasks, loadTasks, logs.connected, runningBatchIds]);
|
||||
@@ -870,6 +874,16 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
});
|
||||
void loadData();
|
||||
},
|
||||
onTask: (rawTask) => {
|
||||
const task = rawTask as unknown as DouyuTaskItem;
|
||||
// 已有任务原位更新保持排序;新任务插入头部(与后端 id desc 一致)
|
||||
setTasks((prev) => {
|
||||
if (prev.some((t) => t.id === task.id)) {
|
||||
return prev.map((t) => (t.id === task.id ? task : t));
|
||||
}
|
||||
return [task, ...prev].slice(0, 100);
|
||||
});
|
||||
},
|
||||
});
|
||||
message.success(`已创建 ${result.count} 个任务`);
|
||||
setTimeout(() => { void loadTasks(); }, 400);
|
||||
@@ -1272,78 +1286,84 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
width: 80,
|
||||
render: (v) => v ?? <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '鱼翅', dataIndex: 'gold_balance', width: 70,
|
||||
render: (v: number | null) => v ?? <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '限制兑换', dataIndex: 'exchange_balance', width: 150, ellipsis: true,
|
||||
render: (_, record) => {
|
||||
const latestLimitedTask = latestLimitedGoodsTaskByAccount.get(record.id);
|
||||
const text = limitedGoodsTaskText(latestLimitedTask);
|
||||
if (!text) return <Text type="secondary">未查</Text>;
|
||||
if (latestLimitedTask?.status !== 'success') {
|
||||
return <Text type="secondary">{text}</Text>;
|
||||
}
|
||||
const count = resultNumber(latestLimitedTask.result, 'limited_count');
|
||||
if (count === 0 || text === '无限制商品') return <Tag color="green">无限制商品</Tag>;
|
||||
return <Text title={text}>{text}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
|
||||
render: (_, record) => {
|
||||
if (isPeaceHandbook) return <Text type="secondary">-</Text>;
|
||||
const fromTask = latestChangeWaitByAccount.get(record.id);
|
||||
const canChangeTime = fromTask?.canChangeTime ?? (
|
||||
isEsportsHandbook ? record.esports_can_change_time : null
|
||||
);
|
||||
const esportsStatus = isEsportsHandbook ? esportsRebindStatus(canChangeTime) : null;
|
||||
if (esportsStatus) {
|
||||
return esportsStatus.available
|
||||
? <Tag color="success">可换绑</Tag>
|
||||
: (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color="orange">暂不可换绑</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{esportsStatus.text}</Text>
|
||||
</Space>
|
||||
// 和平小店无鱼翅/限制兑换/换绑时间概念(可无限制换绑),展示扭蛋碎片
|
||||
...(isPeaceHandbook
|
||||
? [{
|
||||
title: '扭蛋碎片', dataIndex: 'xpd_fragments', width: 90,
|
||||
render: (v: number | null) => v ?? <Text type="secondary">-</Text>,
|
||||
}] as NonNullable<TableProps<DouyuTaskAccountItem>['columns']>
|
||||
: [
|
||||
{
|
||||
title: '鱼翅', dataIndex: 'gold_balance', width: 70,
|
||||
render: (v: number | null) => v ?? <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '限制兑换', dataIndex: 'exchange_balance', width: 150, ellipsis: true,
|
||||
render: (_, record) => {
|
||||
const latestLimitedTask = latestLimitedGoodsTaskByAccount.get(record.id);
|
||||
const text = limitedGoodsTaskText(latestLimitedTask);
|
||||
if (!text) return <Text type="secondary">未查</Text>;
|
||||
if (latestLimitedTask?.status !== 'success') {
|
||||
return <Text type="secondary">{text}</Text>;
|
||||
}
|
||||
const count = resultNumber(latestLimitedTask.result, 'limited_count');
|
||||
if (count === 0 || text === '无限制商品') return <Tag color="green">无限制商品</Tag>;
|
||||
return <Text title={text}>{text}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
|
||||
render: (_, record) => {
|
||||
const fromTask = latestChangeWaitByAccount.get(record.id);
|
||||
const canChangeTime = fromTask?.canChangeTime ?? (
|
||||
isEsportsHandbook ? record.esports_can_change_time : null
|
||||
);
|
||||
}
|
||||
const wait = fromTask?.wait ?? (
|
||||
isEsportsHandbook ? record.esports_change_role_wait_time : record.change_role_wait_time
|
||||
);
|
||||
const text = fromTask?.text || formatWaitSeconds(wait);
|
||||
if (wait == null && !text) {
|
||||
return <Text type="secondary">未查</Text>;
|
||||
}
|
||||
if (wait != null && wait <= 0) {
|
||||
return <Tag color="success">可换绑</Tag>;
|
||||
}
|
||||
if (wait != null && wait > 0) {
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color="orange">冷却中</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{text || formatWaitSeconds(wait)}</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
// 接口没返回数字但有文案
|
||||
if (text === '可换绑') return <Tag color="success">可换绑</Tag>;
|
||||
return <Text type="secondary">{text || '-'}</Text>;
|
||||
},
|
||||
sorter: (a, b) => {
|
||||
if (isPeaceHandbook) return 0;
|
||||
const aw = isEsportsHandbook
|
||||
? latestChangeWaitByAccount.get(a.id)?.canChangeTime ?? a.esports_can_change_time ?? -1
|
||||
: latestChangeWaitByAccount.get(a.id)?.wait
|
||||
?? (isEsportsHandbook ? a.esports_change_role_wait_time : a.change_role_wait_time) ?? -1;
|
||||
const bw = isEsportsHandbook
|
||||
? latestChangeWaitByAccount.get(b.id)?.canChangeTime ?? b.esports_can_change_time ?? -1
|
||||
: latestChangeWaitByAccount.get(b.id)?.wait
|
||||
?? (isEsportsHandbook ? b.esports_change_role_wait_time : b.change_role_wait_time) ?? -1;
|
||||
return aw - bw;
|
||||
},
|
||||
},
|
||||
const esportsStatus = isEsportsHandbook ? esportsRebindStatus(canChangeTime) : null;
|
||||
if (esportsStatus) {
|
||||
return esportsStatus.available
|
||||
? <Tag color="success">可换绑</Tag>
|
||||
: (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color="orange">暂不可换绑</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{esportsStatus.text}</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
const wait = fromTask?.wait ?? (
|
||||
isEsportsHandbook ? record.esports_change_role_wait_time : record.change_role_wait_time
|
||||
);
|
||||
const text = fromTask?.text || formatWaitSeconds(wait);
|
||||
if (wait == null && !text) {
|
||||
return <Text type="secondary">未查</Text>;
|
||||
}
|
||||
if (wait != null && wait <= 0) {
|
||||
return <Tag color="success">可换绑</Tag>;
|
||||
}
|
||||
if (wait != null && wait > 0) {
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color="orange">冷却中</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{text || formatWaitSeconds(wait)}</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
// 接口没返回数字但有文案
|
||||
if (text === '可换绑') return <Tag color="success">可换绑</Tag>;
|
||||
return <Text type="secondary">{text || '-'}</Text>;
|
||||
},
|
||||
sorter: (a, b) => {
|
||||
const aw = isEsportsHandbook
|
||||
? latestChangeWaitByAccount.get(a.id)?.canChangeTime ?? a.esports_can_change_time ?? -1
|
||||
: latestChangeWaitByAccount.get(a.id)?.wait
|
||||
?? (isEsportsHandbook ? a.esports_change_role_wait_time : a.change_role_wait_time) ?? -1;
|
||||
const bw = isEsportsHandbook
|
||||
? latestChangeWaitByAccount.get(b.id)?.canChangeTime ?? b.esports_can_change_time ?? -1
|
||||
: latestChangeWaitByAccount.get(b.id)?.wait
|
||||
?? (isEsportsHandbook ? b.esports_change_role_wait_time : b.change_role_wait_time) ?? -1;
|
||||
return aw - bw;
|
||||
},
|
||||
},
|
||||
] as NonNullable<TableProps<DouyuTaskAccountItem>['columns']>),
|
||||
{
|
||||
title: '最近操作', width: 220,
|
||||
render: (_, record) => {
|
||||
@@ -1453,10 +1473,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
);
|
||||
};
|
||||
const sectionStyle = {
|
||||
border: '1px solid #f0f0f0',
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
background: '#fff',
|
||||
background: token.colorBgContainer,
|
||||
};
|
||||
const sectionTitleStyle = {
|
||||
display: 'flex',
|
||||
@@ -1506,6 +1526,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
{renderActionButton('confirm_xpd_bind', 'primary')}
|
||||
{renderActionButton('query_xpd_role', 'primary')}
|
||||
{renderActionButton('query_xpd_balance', 'primary')}
|
||||
{renderActionButton('query_xpd_fragments', 'primary')}
|
||||
{renderActionButton('refresh_xpd_goods')}
|
||||
</Space>
|
||||
</div>
|
||||
@@ -1781,7 +1802,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 6px 4px 2px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-top: 1px solid ${token.colorBorderSecondary};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -2247,7 +2268,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
<img
|
||||
src={exchangePreview.url}
|
||||
alt="兑换结果"
|
||||
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
||||
style={{ width: '100%', borderRadius: 8, border: `1px solid ${token.colorBorderSecondary}` }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{exchangePreview.task.account_nickname
|
||||
|
||||
Reference in New Issue
Block a user