diff --git a/web/backend/main.py b/web/backend/main.py index 53bb64e..c566d4d 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -12,7 +12,7 @@ from fastapi.responses import FileResponse from starlette.middleware.base import BaseHTTPMiddleware from .database import init_db -from .routers import auth, users, accounts, account_check, login, proxy, cookies, huya, douyu +from .routers import auth, users, accounts, account_check, dashboard, login, proxy, cookies, huya, douyu from .schemas import AppInfo from .version import get_app_version from utils import setup_logger @@ -91,6 +91,7 @@ app.include_router(auth.router) app.include_router(users.router) app.include_router(accounts.router) app.include_router(account_check.router) +app.include_router(dashboard.router) app.include_router(login.router) app.include_router(proxy.router) app.include_router(cookies.router) diff --git a/web/backend/routers/dashboard.py b/web/backend/routers/dashboard.py new file mode 100644 index 0000000..dd92681 --- /dev/null +++ b/web/backend/routers/dashboard.py @@ -0,0 +1,123 @@ +"""首页聚合统计路由。""" + +from fastapi import APIRouter, Depends +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user +from ..models import ( + Account, + HuyaAccount, + HuyaGoodsSnapshot, + HuyaRechargeGoodsSnapshot, + HuyaTask, + LoginTask, + User, +) +from ..permissions import user_has_permission + + +router = APIRouter(prefix="/api/dashboard", tags=["首页概览"]) + + +def _task_summary(query, task_model) -> dict: + """按状态汇总任务,避免首页读取任务列表。""" + rows = ( + query.order_by(None) + .with_entities(task_model.status, func.count(task_model.id)) + .group_by(task_model.status) + .all() + ) + status_counts = {status or "": count for status, count in rows} + return { + "total": sum(status_counts.values()), + "success": status_counts.get("success", 0), + "failed": sum(status_counts.get(status, 0) for status in ("failed", "error")), + "status_counts": status_counts, + } + + +def _empty_task_summary() -> dict: + return {"total": 0, "success": 0, "failed": 0, "status_counts": {}} + + +def _can_view_huya_all(user: User) -> bool: + """兼容旧 huya:account 全量权限。""" + return user_has_permission(user, "huya:view_all") or user_has_permission(user, "huya:account") + + +@router.get("/summary") +def dashboard_summary( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """返回首页所需的全部轻量统计。""" + douyu_accounts = 0 + if user_has_permission(current, "account:view_all"): + douyu_accounts = db.query(Account).count() + elif user_has_permission(current, "account:view_assigned"): + douyu_accounts = db.query(Account).filter(Account.assigned_to == current.id).count() + + login_tasks = _empty_task_summary() + if any(user_has_permission(current, permission) for permission in ( + "login:batch", + "login:view_all", + "login:view_assigned", + )): + login_query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id) + if not user_has_permission(current, "login:view_all"): + login_query = login_query.filter(Account.assigned_to == current.id) + login_tasks = _task_summary(login_query, LoginTask) + + cookies = 0 + if user_has_permission(current, "cookie:view"): + cookie_query = ( + db.query(LoginTask) + .join(Account, LoginTask.account_id == Account.id) + .filter(LoginTask.status == "success") + ) + if not user_has_permission(current, "login:view_all"): + cookie_query = cookie_query.filter(Account.assigned_to == current.id) + cookies = cookie_query.count() + + huya_accounts = 0 + can_view_huya_accounts = any(user_has_permission(current, permission) for permission in ( + "huya:account", + "huya:view_all", + "huya:view_assigned", + )) + if can_view_huya_accounts: + huya_account_query = db.query(HuyaAccount) + if not _can_view_huya_all(current): + huya_account_query = huya_account_query.filter(HuyaAccount.assigned_to == current.id) + huya_accounts = huya_account_query.count() + + huya_tasks = _empty_task_summary() + huya_goods = 0 + huya_recharge_goods = 0 + if user_has_permission(current, "huya:task"): + huya_task_query = db.query(HuyaTask).join(HuyaAccount, HuyaTask.account_id == HuyaAccount.id) + if not _can_view_huya_all(current): + huya_task_query = huya_task_query.filter(HuyaAccount.assigned_to == current.id) + huya_tasks = _task_summary(huya_task_query, HuyaTask) + huya_goods = db.query(HuyaGoodsSnapshot).count() + huya_recharge_goods = db.query(HuyaRechargeGoodsSnapshot).count() + + return { + "douyu": { + "accounts": douyu_accounts, + "tasks": login_tasks["total"], + "success": login_tasks["success"], + "failed": login_tasks["failed"], + "cookies": cookies, + }, + "huya": { + "accounts": huya_accounts, + "tasks": huya_tasks["total"], + "success": huya_tasks["success"], + "failed": huya_tasks["failed"], + "goods": huya_goods, + "rechargeGoods": huya_recharge_goods, + }, + } diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 639b099..8030b06 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -236,10 +236,12 @@ def task_types(current: User = Depends(require_permission("douyu:task"))): return SUPPORTED_DOUYU_TASK_TYPES -@router.get("/accounts", response_model=list[DouyuTaskAccountOut]) +@router.get("/accounts") def list_task_accounts( search: str = Query(""), ids: str = Query(""), + page: int | None = Query(None, ge=1), + page_size: int = Query(50, ge=1, le=200), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): @@ -262,8 +264,18 @@ def list_task_accounts( Account.game_name.ilike(pattern), Account.esports_game_name.ilike(pattern), )) - rows = query.order_by(Account.id.desc()).limit(500).all() - return [_account_out(account) for account in rows] + total = None + if page is not None: + total = query.order_by(None).count() + query = query.order_by(Account.id.desc()) + if page is not None: + query = query.offset((page - 1) * page_size).limit(page_size) + else: + query = query.limit(500) + result = [_account_out(account) for account in query.all()] + if page is not None: + return {"items": result, "total": total or 0, "page": page, "page_size": page_size} + return result @router.get("/config", response_model=DouyuConfigOut) @@ -366,25 +378,46 @@ async def create_task_batch( return {"batch_id": batch_id, "count": count, "success": True} -@router.get("/tasks", response_model=list[DouyuTaskOut]) +@router.get("/tasks") def list_tasks( batch_id: str | None = None, include_detail: bool = Query(False, description="是否返回完整任务结果(默认否,轮询请保持 false)"), + page: int | None = Query(None, ge=1), + page_size: int = Query(100, ge=1, le=500), db: Session = Depends(get_db), current: User = Depends(require_permission("douyu:task")), ): - """查看斗鱼任务记录。""" - cleanup_orphan_douyu_tasks( + """查看斗鱼任务记录,默认只返回最近 100 条。""" + query = _visible_tasks_query(db, current) + if batch_id: + query = query.filter(DouyuTask.batch_id == batch_id) + total = None + if page is not None: + total = query.enable_eagerloads(False).order_by(None).count() + query = query.order_by(DouyuTask.id.desc()) + if page is not None: + query = query.offset((page - 1) * page_size).limit(page_size) + else: + query = query.limit(page_size) + result = [_task_out(task, include_detail=include_detail) for task in query.all()] + if page is not None: + return {"items": result, "total": total or 0, "page": page, "page_size": page_size} + return result + + +@router.post("/tasks/cleanup-orphans") +def cleanup_orphan_tasks( + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:task")), +): + """手动清理没有内存执行器接管的斗鱼任务。""" + cleaned = cleanup_orphan_douyu_tasks( db, active_batch_ids=douyu_batch_registry.active_ids(), statuses=("pending", "running"), message="任务已中断(无执行器接管)", ) - query = _visible_tasks_query(db, current) - if batch_id: - query = query.filter(DouyuTask.batch_id == batch_id) - rows = query.order_by(DouyuTask.id.desc()).limit(300).all() - return [_task_out(task, include_detail=include_detail) for task in rows] + return {"message": f"已清理 {cleaned} 个残留任务", "cleaned": cleaned, "success": True} @router.get("/tasks/{task_id}", response_model=DouyuTaskOut) diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 9a86481..648d00d 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -1384,12 +1384,6 @@ def list_tasks( current: User = Depends(require_permission("huya:task")), ): """查看虎牙任务记录。""" - cleanup_orphan_huya_tasks( - db, - active_batch_ids=huya_batch_registry.active_ids(), - statuses=("pending", "running"), - message="任务已中断(无执行器接管)", - ) query = _visible_huya_tasks_query(db, current) if batch_id: query = query.filter(HuyaTask.batch_id == batch_id) @@ -1397,6 +1391,21 @@ def list_tasks( return [_task_out(task, include_images=include_images) for task in tasks] +@router.post("/tasks/cleanup-orphans") +def cleanup_orphan_tasks( + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """手动清理没有内存执行器接管的虎牙任务。""" + cleaned = cleanup_orphan_huya_tasks( + db, + active_batch_ids=huya_batch_registry.active_ids(), + statuses=("pending", "running"), + message="任务已中断(无执行器接管)", + ) + return {"message": f"已清理 {cleaned} 个残留任务", "cleaned": cleaned, "success": True} + + @router.get("/tasks/summary") def tasks_summary( db: Session = Depends(get_db), diff --git a/web/frontend/src/api/dashboard.ts b/web/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..0325725 --- /dev/null +++ b/web/frontend/src/api/dashboard.ts @@ -0,0 +1,6 @@ +import api from './client'; +import type { DashboardSummary } from './types'; + +export const dashboardApi = { + summary: () => api.get('/dashboard/summary'), +}; diff --git a/web/frontend/src/api/douyu.ts b/web/frontend/src/api/douyu.ts index 11167aa..ff5ef47 100644 --- a/web/frontend/src/api/douyu.ts +++ b/web/frontend/src/api/douyu.ts @@ -7,12 +7,16 @@ import type { DouyuTaskBatchResult, DouyuTaskItem, MessageResponse, + PageParams, + PaginatedResponse, } from './types'; export const douyuApi = { taskTypes: () => api.get, Record>('/douyu/task-types'), listAccounts: (params?: { search?: string; ids?: string }) => api.get('/douyu/accounts', { params }), + listAccountsPaged: (params: PageParams & { ids?: string }) => + api.get, PaginatedResponse>('/douyu/accounts', { params }), getConfig: () => api.get('/douyu/config'), updateConfig: (data: Partial) => api.put('/douyu/config', data), listGoods: () => api.get('/douyu/goods'), @@ -21,6 +25,10 @@ export const douyuApi = { api.post('/douyu/tasks/batch', data), listTasks: (batchId?: string) => api.get('/douyu/tasks', { params: batchId ? { batch_id: batchId } : {} }), + listTasksPaged: (params: PageParams & { batch_id?: string; include_detail?: boolean }) => + api.get, PaginatedResponse>('/douyu/tasks', { params }), getTask: (taskId: number) => api.get(`/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(`/douyu/stop/${batchId}`), }; diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 6debee3..537b6f0 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -112,4 +112,6 @@ export const huyaApi = { tasksSummary: () => api.get('/huya/tasks/summary'), getTask: (taskId: number) => api.get(`/huya/tasks/${taskId}`), + cleanupOrphans: () => + api.post<{ message: string; cleaned: number; success: boolean }, { message: string; cleaned: number; success: boolean }>('/huya/tasks/cleanup-orphans'), }; diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index 393bd35..88277d5 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -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'; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index bb55320..7798110 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -57,6 +57,24 @@ export interface TaskSummary { status_counts: Record; } +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 { diff --git a/web/frontend/src/pages/DashboardPage.tsx b/web/frontend/src/pages/DashboardPage.tsx index ed34092..970994d 100644 --- a/web/frontend/src/pages/DashboardPage.tsx +++ b/web/frontend/src/pages/DashboardPage.tsx @@ -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(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, diff --git a/web/frontend/src/pages/LoginTasksPage.tsx b/web/frontend/src/pages/LoginTasksPage.tsx index 16ab057..91b7694 100644 --- a/web/frontend/src/pages/LoginTasksPage.tsx +++ b/web/frontend/src/pages/LoginTasksPage.tsx @@ -41,6 +41,7 @@ const STATUS_LABELS: Record = { }; 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([]); + const [accountOptions, setAccountOptions] = useState([]); + const [accountCache, setAccountCache] = useState>({}); + const [accountPage, setAccountPage] = useState(1); + const [accountTotal, setAccountTotal] = useState(0); + const [accountSearch, setAccountSearch] = useState(''); + const [accountsLoading, setAccountsLoading] = useState(false); + const [allTags, setAllTags] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [tasks, setTasks] = useState([]); 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>({}); // 值变化时自动持久化 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 = {}; - 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(); + 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 = {}; + 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 = {}; - 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) => ( <> -
- - + + 已加载 {accountOptions.length}/{accountTotal} + + {accountOptions.length < accountTotal && ( + + )}
{menu} @@ -380,7 +493,7 @@ export default function LoginTasksPage() { /> {selectedTags.length > 0 && ( - 标签选中 {accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length} 个 + 标签选中 {selectedTagCount} 个 )}