功能: 按用户同步斗鱼工作台

This commit is contained in:
yml2213
2026-08-13 15:47:07 +08:00
parent 090698e6b2
commit cf3fe68de3
14 changed files with 503 additions and 44 deletions
+12 -2
View File
@@ -27,8 +27,18 @@ export const douyuApi = {
listPeaceGoods: () => api.get<DouyuXpdGoodsItem[], DouyuXpdGoodsItem[]>('/douyu/xpd-goods'),
createTasks: (data: DouyuTaskBatchRequest) =>
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
listTasks: (batchId?: string) =>
api.get<DouyuTaskItem[], DouyuTaskItem[]>('/douyu/tasks', { params: batchId ? { batch_id: batchId } : {} }),
listTasks: (handbookScope: 'elite' | 'esports' | 'peace', batchId?: string) =>
api.get<DouyuTaskItem[], DouyuTaskItem[]>('/douyu/tasks', {
params: { handbook_scope: handbookScope, ...(batchId ? { batch_id: batchId } : {}) },
}),
listWorkbenchAccounts: (handbookScope: 'elite' | 'esports' | 'peace') =>
api.get<{ account_ids: number[]; configured: boolean }, { account_ids: number[]; configured: boolean }>('/douyu/workbench-accounts', {
params: { handbook_scope: handbookScope },
}),
updateWorkbenchAccounts: (handbookScope: 'elite' | 'esports' | 'peace', accountIds: number[]) =>
api.put<{ account_ids: number[]; success: boolean }, { account_ids: number[]; success: boolean }>(
'/douyu/workbench-accounts', { handbook_scope: handbookScope, account_ids: accountIds },
),
listTasksPaged: (params: PageParams & { batch_id?: string; include_detail?: boolean }) =>
api.get<PaginatedResponse<DouyuTaskItem>, PaginatedResponse<DouyuTaskItem>>('/douyu/tasks', { params }),
getTask: (taskId: number) => api.get<DouyuTaskItem, DouyuTaskItem>(`/douyu/tasks/${taskId}`),
+2
View File
@@ -347,6 +347,7 @@ export interface DouyuConfig {
export interface DouyuTaskBatchRequest {
account_ids: number[];
task_type: string;
handbook_scope: 'elite' | 'esports' | 'peace';
concurrency?: number;
payload?: Record<string, unknown>;
}
@@ -365,6 +366,7 @@ export interface DouyuTaskItem {
account_uid: string;
account_nickname: string;
task_type: string;
handbook_scope: 'elite' | 'esports' | 'peace' | 'legacy';
status: string;
message: string;
result: Record<string, unknown> | null;
+80 -35
View File
@@ -358,12 +358,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const { can } = usePermissions();
const { token } = theme.useToken();
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
// 工作台已导入账号 IDlocalStorage 持久化,默认空白,用户手动导入/移除)
const [workbenchIds, setWorkbenchIds] = useState<number[]>(() => {
const raw = localStorage.getItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook));
if (!raw) return [];
return raw.split(',').map(Number).filter(Number.isInteger).filter((id) => id >= 1);
});
// 工作台账号归属由服务端按用户保存;localStorage 仅用于首次升级迁移旧浏览器数据。
const [workbenchIds, setWorkbenchIds] = useState<number[]>([]);
const [workbenchReady, setWorkbenchReady] = useState(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [goods, setGoods] = useState<DouyuGoodsItem[]>([]);
const [tasks, setTasks] = useState<DouyuTaskItem[]>([]);
@@ -421,6 +418,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const [confirmFailCountdown, setConfirmFailCountdown] = useState(0);
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState<number | null>(null);
const locallyStartedBatchIds = useRef<Set<string>>(new Set());
const autoOpenQrReady = useRef(false);
const autoOpenEsportsBindReady = useRef(false);
const autoOpenPayReady = useRef(false);
@@ -594,7 +592,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
isPeaceHandbook
? douyuApi.listPeaceGoods()
: (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()),
douyuApi.listTasks(),
douyuApi.listTasks(handbook),
canConfig ? douyuApi.getConfig() : Promise.resolve(null),
douyuApi.taskTypes(),
]);
@@ -608,14 +606,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
if (taskResult.status === 'fulfilled') {
setTasks(taskResult.value);
// 首次加载时屏蔽历史二维码自动弹窗;后续刷新不能把新任务一并标记掉
// 首次加载只压制历史的复制/失败提示。二维码只会因当前页面创建的批次自动打开。
if (!autoOpenQrReady.current) {
for (const task of taskResult.value) {
if (hasBindQrcode(task)) autoOpenedQrTaskIds.current.add(task.id);
if (task.task_type === 'prepare_esports_bind' && task.result?.esports_bind_dialog === true) {
autoOpenedEsportsBindTaskIds.current.add(task.id);
}
if (hasPaymentQrcode(task)) autoOpenedPayTaskIds.current.add(task.id);
// 历史兑换成功任务不触发自动复制,避免页面加载时打扰
if (exchangeTaskSucceeded(task)) autoCopiedExchangeTaskIds.current.add(task.id);
// 历史确认绑定失败任务不重复弹提示(只提示一次)
@@ -637,13 +630,13 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
} finally {
setLoading(false);
}
}, [accountPage, accountPageSize, accountSearch, canConfig, isEsportsHandbook, isPeaceHandbook, workbenchIds]);
}, [accountPage, accountPageSize, accountSearch, canConfig, handbook, isEsportsHandbook, isPeaceHandbook, workbenchIds]);
const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
tasksLoadingRef.current = true;
try {
const data = await douyuApi.listTasks();
const data = await douyuApi.listTasks(handbook);
setTasks(data);
if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) {
// 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住
@@ -654,22 +647,43 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
} finally {
tasksLoadingRef.current = false;
}
}, []);
}, [handbook]);
useEffect(() => { loadData(); }, [loadData]);
useEffect(() => {
let cancelled = false;
void douyuApi.listWorkbenchAccounts(handbook)
.then(async ({ account_ids, configured }) => {
if (cancelled) return;
if (configured) {
setWorkbenchIds(account_ids);
localStorage.removeItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook));
return;
}
// 仅在服务端尚未保存时迁移当前浏览器的旧工作台列表,避免覆盖其他设备的配置。
const legacy = (localStorage.getItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)) || '')
.split(',').map(Number).filter(Number.isInteger).filter((id) => id >= 1);
if (legacy.length === 0) return;
const saved = await douyuApi.updateWorkbenchAccounts(handbook, legacy);
if (!cancelled) {
setWorkbenchIds(saved.account_ids);
localStorage.removeItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook));
}
})
.catch((error) => {
if (!cancelled) message.error(`加载工作台账号失败: ${getErrorMessage(error)}`);
})
.finally(() => {
if (!cancelled) setWorkbenchReady(true);
});
return () => { cancelled = true; };
}, [handbook]);
useEffect(() => {
localStorage.setItem(DOUYU_LAYOUT_MODE_STORAGE_KEY, layoutMode);
}, [layoutMode]);
// 工作台账号集合持久化:移除账号后刷新不再出现
useEffect(() => {
localStorage.setItem(
DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook),
workbenchIds.length ? workbenchIds.join(',') : '',
);
}, [handbook, workbenchIds]);
// 监听账号表格区域高度变化,动态计算 scroll.y 实现表体内部滚动
useEffect(() => {
const node = accountTableAreaRef.current;
@@ -770,7 +784,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
useEffect(() => {
if (!autoOpenQrReady.current) return;
const candidates = visibleTasks
.filter((task) => hasBindQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id))
.filter((task) => (
locallyStartedBatchIds.current.has(task.batch_id)
&& hasBindQrcode(task)
&& !autoOpenedQrTaskIds.current.has(task.id)
))
.sort((a, b) => b.id - a.id);
if (candidates.length === 0) return;
// 全部加入标签栏但不抢焦点;仅在当前无 active 标签时激活最新(id 最大)的
@@ -783,7 +801,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
if (!autoOpenEsportsBindReady.current || esportsBindTask) return;
const nextTask = visibleTasks
.filter((task) => (
task.task_type === 'prepare_esports_bind'
locallyStartedBatchIds.current.has(task.batch_id)
&& task.task_type === 'prepare_esports_bind'
&& task.result?.esports_bind_dialog === true
&& !autoOpenedEsportsBindTaskIds.current.has(task.id)
))
@@ -795,7 +814,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
useEffect(() => {
if (!autoOpenPayReady.current || payTask) return;
const nextPayTask = visibleTasks
.filter((task) => hasPaymentQrcode(task) && !paymentArrived(task) && !autoOpenedPayTaskIds.current.has(task.id))
.filter((task) => (
locallyStartedBatchIds.current.has(task.batch_id)
&& hasPaymentQrcode(task)
&& !paymentArrived(task)
&& !autoOpenedPayTaskIds.current.has(task.id)
))
.sort((a, b) => b.id - a.id)[0];
if (nextPayTask) openPayTask(nextPayTask);
}, [openPayTask, payTask, visibleTasks]);
@@ -956,9 +980,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const result = await douyuApi.createTasks({
account_ids: ids,
task_type: taskType,
handbook_scope: handbook,
concurrency,
payload,
});
// 只有本页面创建的批次才允许主动弹二维码,防止其他工作台/浏览器的任务打扰当前操作。
locallyStartedBatchIds.current.add(result.batch_id);
setRunningBatchIds((prev) => new Set(prev).add(result.batch_id));
logs.connectBatch(result.batch_id, `/api/douyu/ws/${result.batch_id}`, {
clear: true,
@@ -972,6 +999,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
},
onTask: (rawTask) => {
const task = rawTask as unknown as DouyuTaskItem;
if (task.handbook_scope !== handbook) return;
// 已有任务原位更新保持排序;新任务插入头部(与后端 id desc 一致)
setTasks((prev) => {
if (prev.some((t) => t.id === task.id)) {
@@ -1039,9 +1067,21 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}, [importSearch, importTag]);
useEffect(() => { setImportSelectedIds([]); }, [importPage]);
const importAccounts = (ids: number[]) => {
const saveWorkbenchIds = async (nextIds: number[]) => {
const saved = await douyuApi.updateWorkbenchAccounts(handbook, nextIds);
setWorkbenchIds(saved.account_ids);
return saved.account_ids;
};
const importAccounts = async (ids: number[]) => {
const toImport = importPool.filter((a) => ids.includes(a.id));
setWorkbenchIds((prev) => [...new Set([...prev, ...toImport.map((a) => a.id)])]);
const nextIds = [...new Set([...workbenchIds, ...toImport.map((a) => a.id)])];
try {
await saveWorkbenchIds(nextIds);
} catch (error) {
message.error(getErrorMessage(error));
return;
}
setAccounts((prev) => {
const existing = new Set(prev.map((a) => a.id));
return [...prev, ...toImport.filter((a) => !existing.has(a.id))];
@@ -1063,7 +1103,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
message.info('当前筛选条件下没有可新增的账号');
return;
}
setWorkbenchIds((prev) => [...new Set([...prev, ...imported])]);
await saveWorkbenchIds([...new Set([...workbenchIds, ...imported])]);
setImportSelectedIds([]);
setImportOpen(false);
message.success(`已一键导入 ${imported.length} 个账号`);
@@ -1074,9 +1114,14 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}
};
const removeSelected = () => {
const removeSelected = async () => {
const sel = new Set(selectedIds);
setWorkbenchIds((prev) => prev.filter((id) => !sel.has(id)));
try {
await saveWorkbenchIds(workbenchIds.filter((id) => !sel.has(id)));
} catch (error) {
message.error(getErrorMessage(error));
return;
}
setAccounts((prev) => prev.filter((a) => !sel.has(a.id)));
setSelectedIds([]);
message.success(`已移出 ${sel.size} 个账号`);
@@ -1873,7 +1918,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
rowKey="id"
size="small"
className="douyu-task-record-table"
loading={loading}
loading={loading || !workbenchReady}
locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }}
rowSelection={{
selectedRowKeys: selectedIds,
@@ -2099,13 +2144,13 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
/>
</Space>
<Space wrap style={{ width: '100%', justifyContent: 'flex-start' }}>
<Button onClick={() => importAccounts(importPool.map((a) => a.id))}>
<Button onClick={() => void importAccounts(importPool.map((a) => a.id))}>
({importPool.length})
</Button>
<Button loading={importAllLoading} onClick={() => void importAllAccounts()}>
</Button>
<Button type="primary" onClick={() => importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}>
<Button type="primary" onClick={() => void importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}>
({importSelectedIds.length})
</Button>
</Space>