优化首页统计和任务请求

This commit is contained in:
yml2213
2026-08-05 10:41:40 +08:00
parent 2fda47c631
commit 335f5cf57b
11 changed files with 421 additions and 160 deletions
+6
View File
@@ -0,0 +1,6 @@
import api from './client';
import type { DashboardSummary } from './types';
export const dashboardApi = {
summary: () => api.get<DashboardSummary, DashboardSummary>('/dashboard/summary'),
};
+8
View File
@@ -7,12 +7,16 @@ import type {
DouyuTaskBatchResult,
DouyuTaskItem,
MessageResponse,
PageParams,
PaginatedResponse,
} from './types';
export const douyuApi = {
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/douyu/task-types'),
listAccounts: (params?: { search?: string; ids?: string }) =>
api.get<DouyuTaskAccountItem[], DouyuTaskAccountItem[]>('/douyu/accounts', { params }),
listAccountsPaged: (params: PageParams & { ids?: string }) =>
api.get<PaginatedResponse<DouyuTaskAccountItem>, PaginatedResponse<DouyuTaskAccountItem>>('/douyu/accounts', { params }),
getConfig: () => api.get<DouyuConfig, DouyuConfig>('/douyu/config'),
updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data),
listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'),
@@ -21,6 +25,10 @@ export const douyuApi = {
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
listTasks: (batchId?: string) =>
api.get<DouyuTaskItem[], DouyuTaskItem[]>('/douyu/tasks', { params: batchId ? { batch_id: batchId } : {} }),
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}`),
cleanupOrphans: () =>
api.post<{ message: string; cleaned: number; success: boolean }, { message: string; cleaned: number; success: boolean }>('/douyu/tasks/cleanup-orphans'),
stopBatch: (batchId: string) => api.post<MessageResponse, MessageResponse>(`/douyu/stop/${batchId}`),
};
+2
View File
@@ -112,4 +112,6 @@ export const huyaApi = {
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/huya/tasks/summary'),
getTask: (taskId: number) =>
api.get<HuyaTaskItem, HuyaTaskItem>(`/huya/tasks/${taskId}`),
cleanupOrphans: () =>
api.post<{ message: string; cleaned: number; success: boolean }, { message: string; cleaned: number; success: boolean }>('/huya/tasks/cleanup-orphans'),
};
+1
View File
@@ -4,6 +4,7 @@ export { accountApi } from './accounts';
export { appApi } from './app';
export { authApi } from './auth';
export { cookieApi } from './cookies';
export { dashboardApi } from './dashboard';
export { douyuApi } from './douyu';
export { huyaApi } from './huya';
export { loginApi } from './login';
+18
View File
@@ -57,6 +57,24 @@ export interface TaskSummary {
status_counts: Record<string, number>;
}
export interface DashboardSummary {
douyu: {
accounts: number;
tasks: number;
success: number;
failed: number;
cookies: number;
};
huya: {
accounts: number;
tasks: number;
success: number;
failed: number;
goods: number;
rechargeGoods: number;
};
}
// ==================== Auth ====================
export interface LoginResult {
+8 -61
View File
@@ -1,12 +1,6 @@
import { Card, Col, Row, Space, Statistic, Tag, Typography, theme } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import {
accountApi,
cookieApi,
huyaApi,
loginApi,
} from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { dashboardApi } from '../api/modules';
const { Title, Text } = Typography;
@@ -67,74 +61,27 @@ function StatCard({
export default function DashboardPage() {
const { token } = theme.useToken();
const { can, canAny } = usePermissions();
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<DashboardStats>(EMPTY_STATS);
const canViewDouyuAccounts = canAny(['account:view_all', 'account:view_assigned']);
const canViewDouyuTasks = canAny(['login:batch', 'login:view_all', 'login:view_assigned']);
const canViewCookies = can('cookie:view');
const canViewHuyaAccounts = canAny(['huya:account', 'huya:view_all', 'huya:view_assigned']);
const canViewHuyaTasks = can('huya:task');
useEffect(() => {
let ignore = false;
async function loadDashboard() {
setLoading(true);
const [
douyuAccountsResult,
douyuTasksResult,
cookiesResult,
huyaAccountsResult,
huyaTasksResult,
huyaGoodsResult,
huyaRechargeGoodsResult,
] = await Promise.allSettled([
canViewDouyuAccounts ? accountApi.summary() : Promise.resolve(null),
canViewDouyuTasks ? loginApi.tasksSummary() : Promise.resolve(null),
canViewCookies ? cookieApi.summary() : Promise.resolve(null),
canViewHuyaAccounts ? huyaApi.accountsSummary() : Promise.resolve(null),
canViewHuyaTasks ? huyaApi.tasksSummary() : Promise.resolve(null),
canViewHuyaTasks ? huyaApi.listGoods() : Promise.resolve([]),
canViewHuyaTasks ? huyaApi.listRechargeGoods() : Promise.resolve([]),
]);
if (ignore) return;
const douyuAccounts = douyuAccountsResult.status === 'fulfilled' ? douyuAccountsResult.value : null;
const douyuTasks = douyuTasksResult.status === 'fulfilled' ? douyuTasksResult.value : null;
const cookies = cookiesResult.status === 'fulfilled' ? cookiesResult.value : null;
const huyaAccounts = huyaAccountsResult.status === 'fulfilled' ? huyaAccountsResult.value : null;
const huyaTasks = huyaTasksResult.status === 'fulfilled' ? huyaTasksResult.value : null;
const huyaGoods = huyaGoodsResult.status === 'fulfilled' ? huyaGoodsResult.value : [];
const huyaRechargeGoods = huyaRechargeGoodsResult.status === 'fulfilled' ? huyaRechargeGoodsResult.value : [];
setStats({
douyu: {
accounts: douyuAccounts?.total || 0,
tasks: douyuTasks?.total || 0,
success: douyuTasks?.success || 0,
failed: douyuTasks?.failed || 0,
cookies: cookies?.total || 0,
},
huya: {
accounts: huyaAccounts?.total || 0,
tasks: huyaTasks?.total || 0,
success: huyaTasks?.success || 0,
failed: huyaTasks?.failed || 0,
goods: huyaGoods.length,
rechargeGoods: huyaRechargeGoods.length,
},
});
setLoading(false);
try {
const data = await dashboardApi.summary();
if (!ignore) setStats(data);
} finally {
if (!ignore) setLoading(false);
}
}
void loadDashboard();
return () => {
ignore = true;
};
}, [canViewCookies, canViewDouyuAccounts, canViewDouyuTasks, canViewHuyaAccounts, canViewHuyaTasks]);
}, []);
const totalStats = useMemo(() => ({
accounts: stats.douyu.accounts + stats.huya.accounts,
+194 -81
View File
@@ -41,6 +41,7 @@ const STATUS_LABELS: Record<string, string> = {
};
type BatchMode = 'login' | 'check';
const ACCOUNT_PAGE_SIZE = 50;
interface SelectGroupOption {
label: string;
@@ -48,7 +49,13 @@ interface SelectGroupOption {
}
export default function LoginTasksPage() {
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [accountOptions, setAccountOptions] = useState<AccountItem[]>([]);
const [accountCache, setAccountCache] = useState<Record<number, AccountItem>>({});
const [accountPage, setAccountPage] = useState(1);
const [accountTotal, setAccountTotal] = useState(0);
const [accountSearch, setAccountSearch] = useState('');
const [accountsLoading, setAccountsLoading] = useState(false);
const [allTags, setAllTags] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [tasks, setTasks] = useState<LoginTaskItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -72,6 +79,9 @@ export default function LoginTasksPage() {
});
const [settingsOpen, setSettingsOpen] = useState(false);
const tasksLoadingRef = useRef(false);
const accountPageRequestRef = useRef(0);
const tagSelectionRequestRef = useRef(0);
const [tagAccountIds, setTagAccountIds] = useState<Record<string, number[]>>({});
// 值变化时自动持久化
useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]);
@@ -86,64 +96,104 @@ export default function LoginTasksPage() {
const canBatch = can('login:batch');
// 从账号中提取所有标签
const allTags = useMemo(() => {
const tags = [...new Set(accounts.map((a) => (a.tag || '').trim()).filter(Boolean))];
return tags.sort();
}, [accounts]);
// 标签→账号ID映射
const tagAccountMap = useMemo(() => {
const map: Record<string, number[]> = {};
accounts.forEach((a) => {
const tag = (a.tag || '').trim();
if (tag) {
if (!map[tag]) map[tag] = [];
map[tag].push(a.id);
}
const rememberAccounts = useCallback((items: AccountItem[]) => {
if (items.length === 0) return;
setAccountCache((prev) => {
const next = { ...prev };
for (const item of items) next[item.id] = item;
return next;
});
return map;
}, [accounts]);
}, []);
const loadAccounts = useCallback(async () => {
const loadAccountsPage = useCallback(async (page: number, search: string, append = false) => {
const requestId = accountPageRequestRef.current + 1;
accountPageRequestRef.current = requestId;
setAccountsLoading(true);
try {
const data = await accountApi.list({ include_sensitive: false });
setAccounts(data);
const data = await accountApi.listPaged({
page,
page_size: ACCOUNT_PAGE_SIZE,
search,
include_sensitive: false,
});
if (accountPageRequestRef.current !== requestId) return;
rememberAccounts(data.items);
setAccountOptions((prev) => {
const map = new Map((append ? prev : []).map((item) => [item.id, item]));
for (const item of data.items) map.set(item.id, item);
return Array.from(map.values());
});
setAccountPage(data.page);
setAccountTotal(data.total);
} catch (e: unknown) {
if (accountPageRequestRef.current === requestId) {
message.error(getErrorMessage(e));
}
} finally {
if (accountPageRequestRef.current === requestId) {
setAccountsLoading(false);
}
}
}, [rememberAccounts]);
const loadTags = useCallback(async () => {
try {
const tags = await accountApi.listTags();
setAllTags(tags.sort());
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
}, []);
// 标签选择变化时,同步更新选中的账号
const handleTagChange = useCallback((tags: string[]) => {
setSelectedTags(tags);
setSelectedIds((prev) => {
const prevTagSet = new Set(selectedTags);
const newTagSet = new Set(tags);
// 新增的标签
const addedTags = tags.filter((t) => !prevTagSet.has(t));
// 移除的标签
const removedTags = selectedTags.filter((t) => !newTagSet.has(t));
// 收集被移除标签下的所有账号ID
const removedIds = new Set(removedTags.flatMap((t) => tagAccountMap[t] || []));
// 收集新增标签下的所有账号ID
const addedIds = addedTags.flatMap((t) => tagAccountMap[t] || []);
// 保留手动选择的账号(不属于任何已选标签的),移除被取消标签的账号,添加新选标签的账号
const manualIds = prev.filter((id) => {
const acc = accounts.find((a) => a.id === id);
if (!acc) return false;
const accTag = (acc.tag || '').trim();
// 保留不属于当前任何已选标签、也不属于被移除标签的
return !prevTagSet.has(accTag) && !removedIds.has(id);
const loadAllAccountsByTag = useCallback(async (tag: string) => {
const items: AccountItem[] = [];
for (let page = 1; ; page += 1) {
const data = await accountApi.listPaged({
page,
page_size: 200,
tag,
include_sensitive: false,
});
items.push(...data.items);
if (items.length >= data.total) break;
}
rememberAccounts(items);
return items;
}, [rememberAccounts]);
return [...new Set([...manualIds, ...addedIds])];
});
}, [selectedTags, tagAccountMap, accounts]);
// 标签选择变化时,同步更新选中的账号
const handleTagChange = useCallback(async (tags: string[]) => {
const requestId = tagSelectionRequestRef.current + 1;
tagSelectionRequestRef.current = requestId;
const previousTags = selectedTags;
setSelectedTags(tags);
const addedTags = tags.filter((tag) => !previousTags.includes(tag));
const removedTags = previousTags.filter((tag) => !tags.includes(tag));
try {
const addedResults = await Promise.all(
addedTags.map(async (tag) => [tag, (await loadAllAccountsByTag(tag)).map((account) => account.id)] as const),
);
if (tagSelectionRequestRef.current !== requestId) return;
setTagAccountIds((prev) => {
const next = { ...prev };
const removedIds = new Set(removedTags.flatMap((tag) => prev[tag] || []));
for (const tag of removedTags) delete next[tag];
for (const [tag, ids] of addedResults) next[tag] = ids;
const selectedByTags = new Set(Object.values(next).flat());
setSelectedIds((current) => [
...new Set([
...current.filter((id) => !removedIds.has(id) || selectedByTags.has(id)),
...selectedByTags,
]),
]);
return next;
});
} catch (e: unknown) {
if (tagSelectionRequestRef.current === requestId) {
message.error(getErrorMessage(e));
}
}
}, [loadAllAccountsByTag, selectedTags]);
const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
@@ -164,8 +214,16 @@ export default function LoginTasksPage() {
);
useEffect(() => {
Promise.all([loadAccounts(), loadTasks()]);
}, [loadAccounts, loadTasks]);
void loadTags();
void loadTasks();
}, [loadTags, loadTasks]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadAccountsPage(1, accountSearch);
}, 250);
return () => window.clearTimeout(timer);
}, [accountSearch, loadAccountsPage]);
useEffect(() => {
const intervalMs = hasActiveTasks || wsConnected || starting || batchId ? 3000 : 15000;
@@ -275,6 +333,42 @@ export default function LoginTasksPage() {
'account_auth_unknown',
].includes(t.status)).length;
const selectableAccounts = useMemo(() => {
const map = new Map<number, AccountItem>();
accountOptions.forEach((account) => map.set(account.id, account));
selectedIds.forEach((id) => {
const account = accountCache[id];
if (account) map.set(account.id, account);
});
return Array.from(map.values());
}, [accountCache, accountOptions, selectedIds]);
const accountSelectOptions = useMemo(() => {
const grouped: Record<string, { value: number; label: string }[]> = {};
const noTag: { value: number; label: string }[] = [];
selectableAccounts.forEach((account) => {
const option = { value: account.id, label: account.username };
const tag = (account.tag || '').trim();
if (tag) {
if (!grouped[tag]) grouped[tag] = [];
grouped[tag].push(option);
} else {
noTag.push(option);
}
});
const result: SelectGroupOption[] = [];
Object.keys(grouped).sort().forEach((tag) => {
result.push({ label: tag, options: grouped[tag] });
});
if (noTag.length > 0) result.push({ label: '未分组', options: noTag });
return result;
}, [selectableAccounts]);
const selectedTagCount = useMemo(
() => new Set(Object.values(tagAccountIds).flat()).size,
[tagAccountIds],
);
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '账号', dataIndex: 'account_username' },
@@ -335,44 +429,63 @@ export default function LoginTasksPage() {
placeholder="选择账号"
value={selectedIds}
onChange={setSelectedIds}
options={(() => {
const grouped: Record<string, { value: number; label: string }[]> = {};
const noTag: { value: number; label: string }[] = [];
accounts.forEach((a) => {
const tag = (a.tag || '').trim();
if (tag) {
if (!grouped[tag]) grouped[tag] = [];
grouped[tag].push({ value: a.id, label: a.username });
} else {
noTag.push({ value: a.id, label: a.username });
}
});
const result: SelectGroupOption[] = [];
Object.keys(grouped).sort().forEach((tag) => {
result.push({ label: tag, options: grouped[tag] });
});
if (noTag.length > 0) {
result.push({ label: '未分组', options: noTag });
}
return result;
})()}
options={accountSelectOptions}
maxTagCount="responsive"
showSearch
size="small"
filterOption={(input, option) => {
if (!option) return false;
const label = (option as { label?: string }).label || '';
return label.toLowerCase().includes(input.toLowerCase());
loading={accountsLoading}
filterOption={false}
onSearch={setAccountSearch}
onPopupScroll={(event) => {
const target = event.currentTarget;
const reachedBottom = target.scrollTop + target.clientHeight >= target.scrollHeight - 24;
if (reachedBottom && accountOptions.length < accountTotal && !accountsLoading) {
void loadAccountsPage(accountPage + 1, accountSearch, true);
}
}}
dropdownRender={(menu) => (
<>
<div style={{ padding: '4px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8 }}>
<Button size="small" type="link" onClick={() => { setSelectedIds(accounts.map((a) => a.id)); setSelectedTags([]); }}>
({accounts.length})
<div style={{ padding: '4px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8, alignItems: 'center' }}>
<Button
size="small"
type="link"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setSelectedIds((prev) => [...new Set([...prev, ...accountOptions.map((account) => account.id)])]);
setSelectedTags([]);
setTagAccountIds({});
tagSelectionRequestRef.current += 1;
}}
>
({accountOptions.length})
</Button>
<Button size="small" type="link" onClick={() => { setSelectedIds([]); setSelectedTags([]); }}>
<Button
size="small"
type="link"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setSelectedIds([]);
setSelectedTags([]);
setTagAccountIds({});
tagSelectionRequestRef.current += 1;
}}
>
</Button>
<span style={{ color: token.colorTextSecondary, fontSize: 12 }}>
{accountOptions.length}/{accountTotal}
</span>
{accountOptions.length < accountTotal && (
<Button
size="small"
type="link"
loading={accountsLoading}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void loadAccountsPage(accountPage + 1, accountSearch, true)}
>
</Button>
)}
</div>
{menu}
</>
@@ -380,7 +493,7 @@ export default function LoginTasksPage() {
/>
{selectedTags.length > 0 && (
<span style={{ color: token.colorTextSecondary, fontSize: 12, whiteSpace: 'nowrap' }}>
{accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length}
{selectedTagCount}
</span>
)}
<Tooltip title={`登录设置(接口 ${apiStrategy},并发 ${concurrency},超时 ${maxTotalTime || '∞'}s,重试 ${maxLoginRetries || '∞'}`}>