优化任务列表性能和本地登录代理
This commit is contained in:
@@ -79,6 +79,20 @@ detect_compose() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detect_backend_proxy_host() {
|
||||||
|
if [ "$BACKEND_HOST" != "0.0.0.0" ] && [ "$BACKEND_HOST" != "::" ]; then
|
||||||
|
echo "$BACKEND_HOST"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# 绑定 0.0.0.0 时,Vite 代理不要固定连 127.0.0.1;某些本地工具会抢占 loopback 端口。
|
||||||
|
local ip
|
||||||
|
ip="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)"
|
||||||
|
if [ -z "$ip" ] && command -v hostname >/dev/null 2>&1; then
|
||||||
|
ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||||
|
fi
|
||||||
|
echo "${ip:-127.0.0.1}"
|
||||||
|
}
|
||||||
|
|
||||||
if [ -n "${DATABASE_URL:-}" ] && [[ "$DATABASE_URL" != mysql+* ]]; then
|
if [ -n "${DATABASE_URL:-}" ] && [[ "$DATABASE_URL" != mysql+* ]]; then
|
||||||
echo "dev.sh 只支持 MySQL,请移除 SQLite DATABASE_URL: $DATABASE_URL"
|
echo "dev.sh 只支持 MySQL,请移除 SQLite DATABASE_URL: $DATABASE_URL"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -99,6 +113,8 @@ if [ -z "$COMPOSE" ]; then
|
|||||||
echo "缺少 docker compose"
|
echo "缺少 docker compose"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
BACKEND_PROXY_HOST="${BACKEND_PROXY_HOST:-$(detect_backend_proxy_host)}"
|
||||||
|
BACKEND_PROXY_TARGET="${VITE_BACKEND_TARGET:-http://${BACKEND_PROXY_HOST}:${BACKEND_PORT}}"
|
||||||
|
|
||||||
mkdir -p data logs
|
mkdir -p data logs
|
||||||
|
|
||||||
@@ -128,7 +144,7 @@ echo " 本地调试模式"
|
|||||||
echo "=============================="
|
echo "=============================="
|
||||||
echo " 后端: http://${BACKEND_HOST}:${BACKEND_PORT}"
|
echo " 后端: http://${BACKEND_HOST}:${BACKEND_PORT}"
|
||||||
echo " 前端: http://localhost:${FRONTEND_PORT}"
|
echo " 前端: http://localhost:${FRONTEND_PORT}"
|
||||||
echo " API 代理: http://127.0.0.1:${BACKEND_PORT}"
|
echo " API 代理: ${BACKEND_PROXY_TARGET}"
|
||||||
echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||||
echo " 退出: Ctrl+C"
|
echo " 退出: Ctrl+C"
|
||||||
echo "=============================="
|
echo "=============================="
|
||||||
@@ -157,7 +173,7 @@ BACKEND_PID=$!
|
|||||||
|
|
||||||
(
|
(
|
||||||
cd web/frontend
|
cd web/frontend
|
||||||
VITE_BACKEND_TARGET="http://127.0.0.1:${BACKEND_PORT}" \
|
VITE_BACKEND_TARGET="$BACKEND_PROXY_TARGET" \
|
||||||
npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT"
|
npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT"
|
||||||
) &
|
) &
|
||||||
FRONTEND_PID=$!
|
FRONTEND_PID=$!
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import uvicorn
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
@@ -66,6 +67,9 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 压缩 JSON 和静态资源响应,避免任务列表轮询反复传输大体积文本。
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||||
|
|
||||||
|
|
||||||
# 安全响应头中间件
|
# 安全响应头中间件
|
||||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
@@ -74,6 +78,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
if request.url.path.startswith("/assets/"):
|
||||||
|
# Vite 产物文件名带 hash,可以长期缓存。
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""补充任务状态清理索引
|
||||||
|
|
||||||
|
Revision ID: 20260805_0015
|
||||||
|
Revises: 20260728_0014
|
||||||
|
Create Date: 2026-08-05
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260805_0015"
|
||||||
|
down_revision: Union[str, None] = "20260728_0014"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
INDEXES = [
|
||||||
|
("ix_douyu_tasks_status_id", "douyu_tasks", ["status", "id"]),
|
||||||
|
("ix_huya_tasks_status_id", "huya_tasks", ["status", "id"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(bind, table_name: str) -> bool:
|
||||||
|
return sa.inspect(bind).has_table(table_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _indexes(bind, table_name: str) -> set[str]:
|
||||||
|
if not _has_table(bind, table_name):
|
||||||
|
return set()
|
||||||
|
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
for name, table_name, columns in INDEXES:
|
||||||
|
if name not in _indexes(bind, table_name):
|
||||||
|
op.create_index(name, table_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
for name, table_name, _ in reversed(INDEXES):
|
||||||
|
if name in _indexes(bind, table_name):
|
||||||
|
op.drop_index(name, table_name=table_name)
|
||||||
@@ -118,7 +118,73 @@ def _account_out(account: Account) -> DouyuTaskAccountOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _task_out(task: DouyuTask) -> DouyuTaskOut:
|
def _slim_goods(goods: object) -> object:
|
||||||
|
"""保留兑换图片和列表展示需要的商品小字段。"""
|
||||||
|
if not isinstance(goods, dict):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in goods.items()
|
||||||
|
if key in {"commodityId", "commodity_id", "commodityName", "name", "webPic", "pic", "score", "status"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _slim_limited_goods(goods: object) -> list[dict[str, object]]:
|
||||||
|
"""限兑列表只返回前几个商品名,避免任务列表携带完整原始数组。"""
|
||||||
|
if not isinstance(goods, list):
|
||||||
|
return []
|
||||||
|
result: list[dict[str, object]] = []
|
||||||
|
for item in goods[:5]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
key: value
|
||||||
|
for key, value in item.items()
|
||||||
|
if key in {"commodityId", "commodity_id", "commodityName", "name"}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_task_result(result: dict | None, task_type: str, *, include_detail: bool = False) -> dict | None:
|
||||||
|
"""列表接口剥离原始快照/大数组;详情接口保留完整 result。"""
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return result
|
||||||
|
if include_detail:
|
||||||
|
return result
|
||||||
|
|
||||||
|
data = dict(result)
|
||||||
|
for key in (
|
||||||
|
"raw",
|
||||||
|
"bind_info",
|
||||||
|
"before_bind_info",
|
||||||
|
"bind_candidates",
|
||||||
|
"cooldown_bind_info",
|
||||||
|
"after_bind_info",
|
||||||
|
"activity_bind_snapshot",
|
||||||
|
"points_query",
|
||||||
|
"exchange_balance_query",
|
||||||
|
"query_act_aliases",
|
||||||
|
"records",
|
||||||
|
):
|
||||||
|
data.pop(key, None)
|
||||||
|
|
||||||
|
goods = data.get("goods")
|
||||||
|
if isinstance(goods, list):
|
||||||
|
data.pop("goods", None)
|
||||||
|
elif isinstance(goods, dict):
|
||||||
|
data["goods"] = _slim_goods(goods)
|
||||||
|
|
||||||
|
if "limited_goods" in data:
|
||||||
|
data["limited_goods"] = _slim_limited_goods(data.get("limited_goods"))
|
||||||
|
|
||||||
|
if task_type not in {"get_bind_qr", "prepare_esports_bind", "get_esports_bind_qr"}:
|
||||||
|
data.pop("url", None)
|
||||||
|
if task_type not in {"create_elite_qr", "create_esports_qr", "create_gold_qr"}:
|
||||||
|
data.pop("pay_url", None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _task_out(task: DouyuTask, *, include_detail: bool = False) -> DouyuTaskOut:
|
||||||
account = task.account
|
account = task.account
|
||||||
return DouyuTaskOut(
|
return DouyuTaskOut(
|
||||||
id=task.id,
|
id=task.id,
|
||||||
@@ -130,7 +196,11 @@ def _task_out(task: DouyuTask) -> DouyuTaskOut:
|
|||||||
task_type=task.task_type,
|
task_type=task.task_type,
|
||||||
status=task.status or "",
|
status=task.status or "",
|
||||||
message=task.message or "",
|
message=task.message or "",
|
||||||
result=task.result if isinstance(task.result, dict) else None,
|
result=_sanitize_task_result(
|
||||||
|
task.result if isinstance(task.result, dict) else None,
|
||||||
|
task.task_type or "",
|
||||||
|
include_detail=include_detail,
|
||||||
|
),
|
||||||
created_by=task.created_by,
|
created_by=task.created_by,
|
||||||
created_at=task.created_at,
|
created_at=task.created_at,
|
||||||
finished_at=task.finished_at,
|
finished_at=task.finished_at,
|
||||||
@@ -299,6 +369,7 @@ async def create_task_batch(
|
|||||||
@router.get("/tasks", response_model=list[DouyuTaskOut])
|
@router.get("/tasks", response_model=list[DouyuTaskOut])
|
||||||
def list_tasks(
|
def list_tasks(
|
||||||
batch_id: str | None = None,
|
batch_id: str | None = None,
|
||||||
|
include_detail: bool = Query(False, description="是否返回完整任务结果(默认否,轮询请保持 false)"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: User = Depends(require_permission("douyu:task")),
|
current: User = Depends(require_permission("douyu:task")),
|
||||||
):
|
):
|
||||||
@@ -313,7 +384,7 @@ def list_tasks(
|
|||||||
if batch_id:
|
if batch_id:
|
||||||
query = query.filter(DouyuTask.batch_id == batch_id)
|
query = query.filter(DouyuTask.batch_id == batch_id)
|
||||||
rows = query.order_by(DouyuTask.id.desc()).limit(300).all()
|
rows = query.order_by(DouyuTask.id.desc()).limit(300).all()
|
||||||
return [_task_out(task) for task in rows]
|
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)
|
||||||
@@ -326,7 +397,7 @@ def get_task(
|
|||||||
task = _visible_tasks_query(db, current).filter(DouyuTask.id == task_id).first()
|
task = _visible_tasks_query(db, current).filter(DouyuTask.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
return _task_out(task)
|
return _task_out(task, include_detail=True)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop/{batch_id}")
|
@router.post("/stop/{batch_id}")
|
||||||
|
|||||||
@@ -208,6 +208,24 @@ def _visible_huya_tasks_query(db: Session, current: User):
|
|||||||
raise HTTPException(status_code=403, detail="无权查看虎牙任务")
|
raise HTTPException(status_code=403, detail="无权查看虎牙任务")
|
||||||
|
|
||||||
|
|
||||||
|
def _huya_task_summary(query):
|
||||||
|
"""按状态汇总虎牙任务,避免概览页拉完整任务列表。"""
|
||||||
|
rows = (
|
||||||
|
query.enable_eagerloads(False)
|
||||||
|
.order_by(None)
|
||||||
|
.with_entities(HuyaTask.status, func.count(HuyaTask.id))
|
||||||
|
.group_by(HuyaTask.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 _require_huya_task_account_access(db: Session, current: User, account_ids: list[int]) -> None:
|
def _require_huya_task_account_access(db: Session, current: User, account_ids: list[int]) -> None:
|
||||||
"""确保任务只会提交到当前用户可操作的虎牙账号。"""
|
"""确保任务只会提交到当前用户可操作的虎牙账号。"""
|
||||||
requested_ids = set(account_ids)
|
requested_ids = set(account_ids)
|
||||||
@@ -1379,6 +1397,15 @@ 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.get("/tasks/summary")
|
||||||
|
def tasks_summary(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("huya:task")),
|
||||||
|
):
|
||||||
|
"""虎牙任务统计。"""
|
||||||
|
return _huya_task_summary(_visible_huya_tasks_query(db, current))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}", response_model=HuyaTaskOut)
|
@router.get("/tasks/{task_id}", response_model=HuyaTaskOut)
|
||||||
def get_task(
|
def get_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import asyncio
|
|||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session, defer
|
||||||
|
|
||||||
from ..database import get_db, SessionLocal
|
from ..database import get_db, SessionLocal
|
||||||
from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel
|
from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel
|
||||||
@@ -16,6 +17,23 @@ from ..services.login_service import LoginBatchRunner, batch_registry
|
|||||||
router = APIRouter(prefix="/api/login", tags=["登录任务"])
|
router = APIRouter(prefix="/api/login", tags=["登录任务"])
|
||||||
|
|
||||||
|
|
||||||
|
def _login_task_summary(query):
|
||||||
|
"""按状态汇总登录任务,避免概览页拉完整任务列表。"""
|
||||||
|
rows = (
|
||||||
|
query.order_by(None)
|
||||||
|
.with_entities(LoginTask.status, func.count(LoginTask.id))
|
||||||
|
.group_by(LoginTask.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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch")
|
@router.post("/batch")
|
||||||
async def create_batch(
|
async def create_batch(
|
||||||
req: LoginBatchRequest,
|
req: LoginBatchRequest,
|
||||||
@@ -84,7 +102,7 @@ def list_tasks(
|
|||||||
current: User = Depends(get_current_user),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""查看登录任务列表。"""
|
"""查看登录任务列表。"""
|
||||||
query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id)
|
query = db.query(LoginTask).options(defer(LoginTask.cookie)).join(Account, LoginTask.account_id == Account.id)
|
||||||
|
|
||||||
# 客服只能看自己账号的任务
|
# 客服只能看自己账号的任务
|
||||||
if not user_has_permission(current, "login:view_all"):
|
if not user_has_permission(current, "login:view_all"):
|
||||||
@@ -106,12 +124,24 @@ def list_tasks(
|
|||||||
result.append(LoginTaskOut(
|
result.append(LoginTaskOut(
|
||||||
id=t.id, batch_id=t.batch_id, account_id=t.account_id,
|
id=t.id, batch_id=t.batch_id, account_id=t.account_id,
|
||||||
account_username=accounts_map.get(t.account_id, ""),
|
account_username=accounts_map.get(t.account_id, ""),
|
||||||
status=t.status, cookie=t.cookie or "", message=t.message or "",
|
status=t.status, cookie="", message=t.message or "",
|
||||||
created_by=t.created_by, created_at=t.created_at, finished_at=t.finished_at,
|
created_by=t.created_by, created_at=t.created_at, finished_at=t.finished_at,
|
||||||
))
|
))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/summary")
|
||||||
|
def tasks_summary(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""登录任务统计。"""
|
||||||
|
query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id)
|
||||||
|
if not user_has_permission(current, "login:view_all"):
|
||||||
|
query = query.filter(Account.assigned_to == current.id)
|
||||||
|
return _login_task_summary(query)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/tasks/{task_id}")
|
@router.delete("/tasks/{task_id}")
|
||||||
def delete_task(
|
def delete_task(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
MessageResponse,
|
MessageResponse,
|
||||||
PageParams,
|
PageParams,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
|
TaskSummary,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const huyaApi = {
|
export const huyaApi = {
|
||||||
@@ -108,6 +109,7 @@ export const huyaApi = {
|
|||||||
// 列表轮询默认不带 base64 小程序码,避免每次 1MB+ 流量。
|
// 列表轮询默认不带 base64 小程序码,避免每次 1MB+ 流量。
|
||||||
params: batchId ? { batch_id: batchId, include_images: false } : { include_images: false },
|
params: batchId ? { batch_id: batchId, include_images: false } : { include_images: false },
|
||||||
}),
|
}),
|
||||||
|
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}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import api from './client';
|
import api from './client';
|
||||||
import type { BatchLoginResult, LoginTaskItem, MessageDeletedResponse, MessageResponse } from './types';
|
import type { BatchLoginResult, LoginTaskItem, MessageDeletedResponse, MessageResponse, TaskSummary } from './types';
|
||||||
|
|
||||||
interface CreateBatchParams {
|
interface CreateBatchParams {
|
||||||
account_ids: number[];
|
account_ids: number[];
|
||||||
@@ -15,6 +15,7 @@ export const loginApi = {
|
|||||||
api.post<BatchLoginResult, BatchLoginResult>('/login/batch', params),
|
api.post<BatchLoginResult, BatchLoginResult>('/login/batch', params),
|
||||||
listTasks: (batch_id?: string) =>
|
listTasks: (batch_id?: string) =>
|
||||||
api.get<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
|
api.get<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
|
||||||
|
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/login/tasks/summary'),
|
||||||
stop: (batch_id: string) => api.post<MessageResponse, MessageResponse>(`/login/stop/${batch_id}`),
|
stop: (batch_id: string) => api.post<MessageResponse, MessageResponse>(`/login/stop/${batch_id}`),
|
||||||
deleteTask: (id: number) => api.delete<MessageResponse, MessageResponse>(`/login/tasks/${id}`),
|
deleteTask: (id: number) => api.delete<MessageResponse, MessageResponse>(`/login/tasks/${id}`),
|
||||||
deleteTasks: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>(`/login/tasks`, { params: { task_ids: ids.join(',') } }),
|
deleteTasks: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>(`/login/tasks`, { params: { task_ids: ids.join(',') } }),
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ export interface BasicSummary {
|
|||||||
tag_count?: number;
|
tag_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskSummary {
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
status_counts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Auth ====================
|
// ==================== Auth ====================
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
cookieApi,
|
cookieApi,
|
||||||
huyaApi,
|
huyaApi,
|
||||||
loginApi,
|
loginApi,
|
||||||
type HuyaTaskItem,
|
|
||||||
type LoginTaskItem,
|
|
||||||
} from '../api/modules';
|
} from '../api/modules';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
|
||||||
@@ -47,10 +45,6 @@ const EMPTY_STATS: DashboardStats = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function countFailed<T extends { status: string }>(items: T[]) {
|
|
||||||
return items.filter((item) => ['failed', 'error'].includes(item.status)).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
@@ -98,10 +92,10 @@ export default function DashboardPage() {
|
|||||||
huyaRechargeGoodsResult,
|
huyaRechargeGoodsResult,
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
canViewDouyuAccounts ? accountApi.summary() : Promise.resolve(null),
|
canViewDouyuAccounts ? accountApi.summary() : Promise.resolve(null),
|
||||||
canViewDouyuTasks ? loginApi.listTasks() : Promise.resolve([]),
|
canViewDouyuTasks ? loginApi.tasksSummary() : Promise.resolve(null),
|
||||||
canViewCookies ? cookieApi.summary() : Promise.resolve(null),
|
canViewCookies ? cookieApi.summary() : Promise.resolve(null),
|
||||||
canViewHuyaAccounts ? huyaApi.accountsSummary() : Promise.resolve(null),
|
canViewHuyaAccounts ? huyaApi.accountsSummary() : Promise.resolve(null),
|
||||||
canViewHuyaTasks ? huyaApi.listTasks() : Promise.resolve([]),
|
canViewHuyaTasks ? huyaApi.tasksSummary() : Promise.resolve(null),
|
||||||
canViewHuyaTasks ? huyaApi.listGoods() : Promise.resolve([]),
|
canViewHuyaTasks ? huyaApi.listGoods() : Promise.resolve([]),
|
||||||
canViewHuyaTasks ? huyaApi.listRechargeGoods() : Promise.resolve([]),
|
canViewHuyaTasks ? huyaApi.listRechargeGoods() : Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
@@ -109,26 +103,26 @@ export default function DashboardPage() {
|
|||||||
if (ignore) return;
|
if (ignore) return;
|
||||||
|
|
||||||
const douyuAccounts = douyuAccountsResult.status === 'fulfilled' ? douyuAccountsResult.value : null;
|
const douyuAccounts = douyuAccountsResult.status === 'fulfilled' ? douyuAccountsResult.value : null;
|
||||||
const douyuTasks = douyuTasksResult.status === 'fulfilled' ? douyuTasksResult.value : [];
|
const douyuTasks = douyuTasksResult.status === 'fulfilled' ? douyuTasksResult.value : null;
|
||||||
const cookies = cookiesResult.status === 'fulfilled' ? cookiesResult.value : null;
|
const cookies = cookiesResult.status === 'fulfilled' ? cookiesResult.value : null;
|
||||||
const huyaAccounts = huyaAccountsResult.status === 'fulfilled' ? huyaAccountsResult.value : null;
|
const huyaAccounts = huyaAccountsResult.status === 'fulfilled' ? huyaAccountsResult.value : null;
|
||||||
const huyaTasks = huyaTasksResult.status === 'fulfilled' ? huyaTasksResult.value : [];
|
const huyaTasks = huyaTasksResult.status === 'fulfilled' ? huyaTasksResult.value : null;
|
||||||
const huyaGoods = huyaGoodsResult.status === 'fulfilled' ? huyaGoodsResult.value : [];
|
const huyaGoods = huyaGoodsResult.status === 'fulfilled' ? huyaGoodsResult.value : [];
|
||||||
const huyaRechargeGoods = huyaRechargeGoodsResult.status === 'fulfilled' ? huyaRechargeGoodsResult.value : [];
|
const huyaRechargeGoods = huyaRechargeGoodsResult.status === 'fulfilled' ? huyaRechargeGoodsResult.value : [];
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
douyu: {
|
douyu: {
|
||||||
accounts: douyuAccounts?.total || 0,
|
accounts: douyuAccounts?.total || 0,
|
||||||
tasks: douyuTasks.length,
|
tasks: douyuTasks?.total || 0,
|
||||||
success: douyuTasks.filter((task: LoginTaskItem) => task.status === 'success').length,
|
success: douyuTasks?.success || 0,
|
||||||
failed: countFailed(douyuTasks),
|
failed: douyuTasks?.failed || 0,
|
||||||
cookies: cookies?.total || 0,
|
cookies: cookies?.total || 0,
|
||||||
},
|
},
|
||||||
huya: {
|
huya: {
|
||||||
accounts: huyaAccounts?.total || 0,
|
accounts: huyaAccounts?.total || 0,
|
||||||
tasks: huyaTasks.length,
|
tasks: huyaTasks?.total || 0,
|
||||||
success: huyaTasks.filter((task: HuyaTaskItem) => task.status === 'success').length,
|
success: huyaTasks?.success || 0,
|
||||||
failed: countFailed(huyaTasks),
|
failed: huyaTasks?.failed || 0,
|
||||||
goods: huyaGoods.length,
|
goods: huyaGoods.length,
|
||||||
rechargeGoods: huyaRechargeGoods.length,
|
rechargeGoods: huyaRechargeGoods.length,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
const v = Number(localStorage.getItem('douyu_task_account_page_size'));
|
const v = Number(localStorage.getItem('douyu_task_account_page_size'));
|
||||||
return [10, 20, 50, 100].includes(v) ? v : 20;
|
return [10, 20, 50, 100].includes(v) ? v : 20;
|
||||||
});
|
});
|
||||||
|
const tasksLoadingRef = useRef(false);
|
||||||
|
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [importPool, setImportPool] = useState<DouyuTaskAccountItem[]>([]);
|
const [importPool, setImportPool] = useState<DouyuTaskAccountItem[]>([]);
|
||||||
@@ -507,6 +508,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
}, [canConfig, isEsportsHandbook, workbenchIds]);
|
}, [canConfig, isEsportsHandbook, workbenchIds]);
|
||||||
|
|
||||||
const loadTasks = useCallback(async () => {
|
const loadTasks = useCallback(async () => {
|
||||||
|
if (tasksLoadingRef.current) return;
|
||||||
|
tasksLoadingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const data = await douyuApi.listTasks();
|
const data = await douyuApi.listTasks();
|
||||||
setTasks(data);
|
setTasks(data);
|
||||||
@@ -516,6 +519,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 轮询失败不打扰操作
|
// 轮询失败不打扰操作
|
||||||
|
} finally {
|
||||||
|
tasksLoadingRef.current = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export default function HuyaTasksPage() {
|
|||||||
const notifiedPaidTaskIds = useRef<Set<number>>(new Set());
|
const notifiedPaidTaskIds = useRef<Set<number>>(new Set());
|
||||||
const accountTableAreaRef = useRef<HTMLDivElement | null>(null);
|
const accountTableAreaRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(460);
|
const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(460);
|
||||||
|
const tasksLoadingRef = useRef(false);
|
||||||
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
|
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
|
|
||||||
@@ -455,6 +456,8 @@ export default function HuyaTasksPage() {
|
|||||||
}, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
|
}, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
|
||||||
|
|
||||||
const loadTasks = useCallback(async () => {
|
const loadTasks = useCallback(async () => {
|
||||||
|
if (tasksLoadingRef.current) return;
|
||||||
|
tasksLoadingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const data = await huyaApi.listTasks();
|
const data = await huyaApi.listTasks();
|
||||||
const nextTasks = mergeTaskImageCache(data, qrImageCacheRef.current);
|
const nextTasks = mergeTaskImageCache(data, qrImageCacheRef.current);
|
||||||
@@ -466,6 +469,8 @@ export default function HuyaTasksPage() {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 轮询失败不打扰操作,下一轮继续刷新。
|
// 轮询失败不打扰操作,下一轮继续刷新。
|
||||||
|
} finally {
|
||||||
|
tasksLoadingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
|
}, [rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Table, Button, Select, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, Modal, Form,
|
Table, Button, Select, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, Modal, Form,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -71,6 +71,7 @@ export default function LoginTasksPage() {
|
|||||||
return localStorage.getItem('login_api_strategy') || 'wgapi';
|
return localStorage.getItem('login_api_strategy') || 'wgapi';
|
||||||
});
|
});
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const tasksLoadingRef = useRef(false);
|
||||||
|
|
||||||
// 值变化时自动持久化
|
// 值变化时自动持久化
|
||||||
useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]);
|
useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]);
|
||||||
@@ -145,22 +146,32 @@ export default function LoginTasksPage() {
|
|||||||
}, [selectedTags, tagAccountMap, accounts]);
|
}, [selectedTags, tagAccountMap, accounts]);
|
||||||
|
|
||||||
const loadTasks = useCallback(async () => {
|
const loadTasks = useCallback(async () => {
|
||||||
|
if (tasksLoadingRef.current) return;
|
||||||
|
tasksLoadingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const data = await loginApi.listTasks(batchId || undefined);
|
const data = await loginApi.listTasks(batchId || undefined);
|
||||||
setTasks(data);
|
setTasks(data);
|
||||||
} catch {
|
} catch {
|
||||||
// 忽略轮询失败,下一次定时刷新会继续尝试。
|
// 忽略轮询失败,下一次定时刷新会继续尝试。
|
||||||
|
} finally {
|
||||||
|
tasksLoadingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [batchId]);
|
}, [batchId]);
|
||||||
|
|
||||||
|
const hasActiveTasks = useMemo(
|
||||||
|
() => tasks.some((task) => ['pending', 'running'].includes(task.status)),
|
||||||
|
[tasks],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([loadAccounts(), loadTasks()]);
|
Promise.all([loadAccounts(), loadTasks()]);
|
||||||
}, [loadAccounts, loadTasks]);
|
}, [loadAccounts, loadTasks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(loadTasks, 3000);
|
const intervalMs = hasActiveTasks || wsConnected || starting || batchId ? 3000 : 15000;
|
||||||
|
const timer = setInterval(loadTasks, intervalMs);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [loadTasks]);
|
}, [batchId, hasActiveTasks, loadTasks, starting, wsConnected]);
|
||||||
|
|
||||||
// 共享的批量任务启动逻辑
|
// 共享的批量任务启动逻辑
|
||||||
const startBatch = async (accountIds: number[], mode: BatchMode = 'login') => {
|
const startBatch = async (accountIds: number[], mode: BatchMode = 'login') => {
|
||||||
|
|||||||
Reference in New Issue
Block a user