优化首页统计和任务请求
This commit is contained in:
+2
-1
@@ -12,7 +12,7 @@ from fastapi.responses import FileResponse
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from .database import init_db
|
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 .schemas import AppInfo
|
||||||
from .version import get_app_version
|
from .version import get_app_version
|
||||||
from utils import setup_logger
|
from utils import setup_logger
|
||||||
@@ -91,6 +91,7 @@ app.include_router(auth.router)
|
|||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(accounts.router)
|
app.include_router(accounts.router)
|
||||||
app.include_router(account_check.router)
|
app.include_router(account_check.router)
|
||||||
|
app.include_router(dashboard.router)
|
||||||
app.include_router(login.router)
|
app.include_router(login.router)
|
||||||
app.include_router(proxy.router)
|
app.include_router(proxy.router)
|
||||||
app.include_router(cookies.router)
|
app.include_router(cookies.router)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -236,10 +236,12 @@ def task_types(current: User = Depends(require_permission("douyu:task"))):
|
|||||||
return SUPPORTED_DOUYU_TASK_TYPES
|
return SUPPORTED_DOUYU_TASK_TYPES
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts", response_model=list[DouyuTaskAccountOut])
|
@router.get("/accounts")
|
||||||
def list_task_accounts(
|
def list_task_accounts(
|
||||||
search: str = Query(""),
|
search: str = Query(""),
|
||||||
ids: 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),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(get_current_user),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
@@ -262,8 +264,18 @@ def list_task_accounts(
|
|||||||
Account.game_name.ilike(pattern),
|
Account.game_name.ilike(pattern),
|
||||||
Account.esports_game_name.ilike(pattern),
|
Account.esports_game_name.ilike(pattern),
|
||||||
))
|
))
|
||||||
rows = query.order_by(Account.id.desc()).limit(500).all()
|
total = None
|
||||||
return [_account_out(account) for account in rows]
|
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)
|
@router.get("/config", response_model=DouyuConfigOut)
|
||||||
@@ -366,25 +378,46 @@ async def create_task_batch(
|
|||||||
return {"batch_id": batch_id, "count": count, "success": True}
|
return {"batch_id": batch_id, "count": count, "success": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks", response_model=list[DouyuTaskOut])
|
@router.get("/tasks")
|
||||||
def list_tasks(
|
def list_tasks(
|
||||||
batch_id: str | None = None,
|
batch_id: str | None = None,
|
||||||
include_detail: bool = Query(False, description="是否返回完整任务结果(默认否,轮询请保持 false)"),
|
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),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(require_permission("douyu:task")),
|
current: User = Depends(require_permission("douyu:task")),
|
||||||
):
|
):
|
||||||
"""查看斗鱼任务记录。"""
|
"""查看斗鱼任务记录,默认只返回最近 100 条。"""
|
||||||
cleanup_orphan_douyu_tasks(
|
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,
|
db,
|
||||||
active_batch_ids=douyu_batch_registry.active_ids(),
|
active_batch_ids=douyu_batch_registry.active_ids(),
|
||||||
statuses=("pending", "running"),
|
statuses=("pending", "running"),
|
||||||
message="任务已中断(无执行器接管)",
|
message="任务已中断(无执行器接管)",
|
||||||
)
|
)
|
||||||
query = _visible_tasks_query(db, current)
|
return {"message": f"已清理 {cleaned} 个残留任务", "cleaned": cleaned, "success": True}
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}", response_model=DouyuTaskOut)
|
@router.get("/tasks/{task_id}", response_model=DouyuTaskOut)
|
||||||
|
|||||||
@@ -1384,12 +1384,6 @@ def list_tasks(
|
|||||||
current: User = Depends(require_permission("huya:task")),
|
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)
|
query = _visible_huya_tasks_query(db, current)
|
||||||
if batch_id:
|
if batch_id:
|
||||||
query = query.filter(HuyaTask.batch_id == 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]
|
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")
|
@router.get("/tasks/summary")
|
||||||
def tasks_summary(
|
def tasks_summary(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import api from './client';
|
||||||
|
import type { DashboardSummary } from './types';
|
||||||
|
|
||||||
|
export const dashboardApi = {
|
||||||
|
summary: () => api.get<DashboardSummary, DashboardSummary>('/dashboard/summary'),
|
||||||
|
};
|
||||||
@@ -7,12 +7,16 @@ import type {
|
|||||||
DouyuTaskBatchResult,
|
DouyuTaskBatchResult,
|
||||||
DouyuTaskItem,
|
DouyuTaskItem,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
|
PageParams,
|
||||||
|
PaginatedResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const douyuApi = {
|
export const douyuApi = {
|
||||||
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/douyu/task-types'),
|
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/douyu/task-types'),
|
||||||
listAccounts: (params?: { search?: string; ids?: string }) =>
|
listAccounts: (params?: { search?: string; ids?: string }) =>
|
||||||
api.get<DouyuTaskAccountItem[], DouyuTaskAccountItem[]>('/douyu/accounts', { params }),
|
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'),
|
getConfig: () => api.get<DouyuConfig, DouyuConfig>('/douyu/config'),
|
||||||
updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data),
|
updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data),
|
||||||
listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'),
|
listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'),
|
||||||
@@ -21,6 +25,10 @@ export const douyuApi = {
|
|||||||
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
|
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
|
||||||
listTasks: (batchId?: string) =>
|
listTasks: (batchId?: string) =>
|
||||||
api.get<DouyuTaskItem[], DouyuTaskItem[]>('/douyu/tasks', { params: batchId ? { batch_id: batchId } : {} }),
|
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}`),
|
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}`),
|
stopBatch: (batchId: string) => api.post<MessageResponse, MessageResponse>(`/douyu/stop/${batchId}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,4 +112,6 @@ export const huyaApi = {
|
|||||||
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/huya/tasks/summary'),
|
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/huya/tasks/summary'),
|
||||||
getTask: (taskId: number) =>
|
getTask: (taskId: number) =>
|
||||||
api.get<HuyaTaskItem, HuyaTaskItem>(`/huya/tasks/${taskId}`),
|
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'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { accountApi } from './accounts';
|
|||||||
export { appApi } from './app';
|
export { appApi } from './app';
|
||||||
export { authApi } from './auth';
|
export { authApi } from './auth';
|
||||||
export { cookieApi } from './cookies';
|
export { cookieApi } from './cookies';
|
||||||
|
export { dashboardApi } from './dashboard';
|
||||||
export { douyuApi } from './douyu';
|
export { douyuApi } from './douyu';
|
||||||
export { huyaApi } from './huya';
|
export { huyaApi } from './huya';
|
||||||
export { loginApi } from './login';
|
export { loginApi } from './login';
|
||||||
|
|||||||
@@ -57,6 +57,24 @@ export interface TaskSummary {
|
|||||||
status_counts: Record<string, number>;
|
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 ====================
|
// ==================== Auth ====================
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { Card, Col, Row, Space, Statistic, Tag, Typography, theme } from 'antd';
|
import { Card, Col, Row, Space, Statistic, Tag, Typography, theme } from 'antd';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { dashboardApi } from '../api/modules';
|
||||||
accountApi,
|
|
||||||
cookieApi,
|
|
||||||
huyaApi,
|
|
||||||
loginApi,
|
|
||||||
} from '../api/modules';
|
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -67,74 +61,27 @@ function StatCard({
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { can, canAny } = usePermissions();
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [stats, setStats] = useState<DashboardStats>(EMPTY_STATS);
|
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(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [
|
try {
|
||||||
douyuAccountsResult,
|
const data = await dashboardApi.summary();
|
||||||
douyuTasksResult,
|
if (!ignore) setStats(data);
|
||||||
cookiesResult,
|
} finally {
|
||||||
huyaAccountsResult,
|
if (!ignore) setLoading(false);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadDashboard();
|
void loadDashboard();
|
||||||
return () => {
|
return () => {
|
||||||
ignore = true;
|
ignore = true;
|
||||||
};
|
};
|
||||||
}, [canViewCookies, canViewDouyuAccounts, canViewDouyuTasks, canViewHuyaAccounts, canViewHuyaTasks]);
|
}, []);
|
||||||
|
|
||||||
const totalStats = useMemo(() => ({
|
const totalStats = useMemo(() => ({
|
||||||
accounts: stats.douyu.accounts + stats.huya.accounts,
|
accounts: stats.douyu.accounts + stats.huya.accounts,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type BatchMode = 'login' | 'check';
|
type BatchMode = 'login' | 'check';
|
||||||
|
const ACCOUNT_PAGE_SIZE = 50;
|
||||||
|
|
||||||
interface SelectGroupOption {
|
interface SelectGroupOption {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -48,7 +49,13 @@ interface SelectGroupOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginTasksPage() {
|
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 [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [tasks, setTasks] = useState<LoginTaskItem[]>([]);
|
const [tasks, setTasks] = useState<LoginTaskItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -72,6 +79,9 @@ export default function LoginTasksPage() {
|
|||||||
});
|
});
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const tasksLoadingRef = useRef(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]);
|
useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]);
|
||||||
@@ -86,64 +96,104 @@ export default function LoginTasksPage() {
|
|||||||
|
|
||||||
const canBatch = can('login:batch');
|
const canBatch = can('login:batch');
|
||||||
|
|
||||||
// 从账号中提取所有标签
|
const rememberAccounts = useCallback((items: AccountItem[]) => {
|
||||||
const allTags = useMemo(() => {
|
if (items.length === 0) return;
|
||||||
const tags = [...new Set(accounts.map((a) => (a.tag || '').trim()).filter(Boolean))];
|
setAccountCache((prev) => {
|
||||||
return tags.sort();
|
const next = { ...prev };
|
||||||
}, [accounts]);
|
for (const item of items) next[item.id] = item;
|
||||||
|
return next;
|
||||||
// 标签→账号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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
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 {
|
try {
|
||||||
const data = await accountApi.list({ include_sensitive: false });
|
const data = await accountApi.listPaged({
|
||||||
setAccounts(data);
|
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) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e));
|
message.error(getErrorMessage(e));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 标签选择变化时,同步更新选中的账号
|
const loadAllAccountsByTag = useCallback(async (tag: string) => {
|
||||||
const handleTagChange = useCallback((tags: string[]) => {
|
const items: AccountItem[] = [];
|
||||||
setSelectedTags(tags);
|
for (let page = 1; ; page += 1) {
|
||||||
|
const data = await accountApi.listPaged({
|
||||||
setSelectedIds((prev) => {
|
page,
|
||||||
const prevTagSet = new Set(selectedTags);
|
page_size: 200,
|
||||||
const newTagSet = new Set(tags);
|
tag,
|
||||||
|
include_sensitive: false,
|
||||||
// 新增的标签
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
items.push(...data.items);
|
||||||
|
if (items.length >= data.total) break;
|
||||||
|
}
|
||||||
|
rememberAccounts(items);
|
||||||
|
return items;
|
||||||
|
}, [rememberAccounts]);
|
||||||
|
|
||||||
return [...new Set([...manualIds, ...addedIds])];
|
// 标签选择变化时,同步更新选中的账号
|
||||||
});
|
const handleTagChange = useCallback(async (tags: string[]) => {
|
||||||
}, [selectedTags, tagAccountMap, accounts]);
|
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 () => {
|
const loadTasks = useCallback(async () => {
|
||||||
if (tasksLoadingRef.current) return;
|
if (tasksLoadingRef.current) return;
|
||||||
@@ -164,8 +214,16 @@ export default function LoginTasksPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([loadAccounts(), loadTasks()]);
|
void loadTags();
|
||||||
}, [loadAccounts, loadTasks]);
|
void loadTasks();
|
||||||
|
}, [loadTags, loadTasks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void loadAccountsPage(1, accountSearch);
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [accountSearch, loadAccountsPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const intervalMs = hasActiveTasks || wsConnected || starting || batchId ? 3000 : 15000;
|
const intervalMs = hasActiveTasks || wsConnected || starting || batchId ? 3000 : 15000;
|
||||||
@@ -275,6 +333,42 @@ export default function LoginTasksPage() {
|
|||||||
'account_auth_unknown',
|
'account_auth_unknown',
|
||||||
].includes(t.status)).length;
|
].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 = [
|
const columns = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||||
{ title: '账号', dataIndex: 'account_username' },
|
{ title: '账号', dataIndex: 'account_username' },
|
||||||
@@ -335,44 +429,63 @@ export default function LoginTasksPage() {
|
|||||||
placeholder="选择账号"
|
placeholder="选择账号"
|
||||||
value={selectedIds}
|
value={selectedIds}
|
||||||
onChange={setSelectedIds}
|
onChange={setSelectedIds}
|
||||||
options={(() => {
|
options={accountSelectOptions}
|
||||||
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;
|
|
||||||
})()}
|
|
||||||
maxTagCount="responsive"
|
maxTagCount="responsive"
|
||||||
showSearch
|
showSearch
|
||||||
size="small"
|
size="small"
|
||||||
filterOption={(input, option) => {
|
loading={accountsLoading}
|
||||||
if (!option) return false;
|
filterOption={false}
|
||||||
const label = (option as { label?: string }).label || '';
|
onSearch={setAccountSearch}
|
||||||
return label.toLowerCase().includes(input.toLowerCase());
|
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) => (
|
dropdownRender={(menu) => (
|
||||||
<>
|
<>
|
||||||
<div style={{ padding: '4px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8 }}>
|
<div style={{ padding: '4px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
<Button size="small" type="link" onClick={() => { setSelectedIds(accounts.map((a) => a.id)); setSelectedTags([]); }}>
|
<Button
|
||||||
全选 ({accounts.length})
|
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>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
{menu}
|
{menu}
|
||||||
</>
|
</>
|
||||||
@@ -380,7 +493,7 @@ export default function LoginTasksPage() {
|
|||||||
/>
|
/>
|
||||||
{selectedTags.length > 0 && (
|
{selectedTags.length > 0 && (
|
||||||
<span style={{ color: token.colorTextSecondary, fontSize: 12, whiteSpace: 'nowrap' }}>
|
<span style={{ color: token.colorTextSecondary, fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||||
标签选中 {accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length} 个
|
标签选中 {selectedTagCount} 个
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tooltip title={`登录设置(接口 ${apiStrategy},并发 ${concurrency},超时 ${maxTotalTime || '∞'}s,重试 ${maxLoginRetries || '∞'})`}>
|
<Tooltip title={`登录设置(接口 ${apiStrategy},并发 ${concurrency},超时 ${maxTotalTime || '∞'}s,重试 ${maxLoginRetries || '∞'})`}>
|
||||||
|
|||||||
Reference in New Issue
Block a user