feat(login): 任务列表支持分页、跨页全选与一键清空
任务量早就过了单页能装下的规模(库里 1.4 万条,单批就 177 条),但接口
硬编码 limit(200)、前端 pagination={false},于是「全选」其实只能选到前
200 条,失败任务清不干净。
- 接口加 page/page_size/status:不传 page 时仍返回裸数组(Cookie 页等
老调用方依赖这个契约,不能改);传了才返回 {items,total},total 供跨页全选。
- 删除支持 all_matching:按当前筛选条件删全部,不必先翻完所有页去勾。
- 前端:分页控件(20/50/100/200/500,记住选择)、状态筛选、表头
「选择全部 N 条(含其他页)」、「清空失败」一键操作。
两条护栏:
- **pending/running 一律不删** —— 删掉会让批次线程找不到记录;要停批次
走 /stop/{batch_id}。
- 不给任何条件就拒绝删除,绝不默认全删。
顺带:带 join 的 query 不能 bulk delete(SQLAlchemy 会拒绝),改成先查
id 再按主键删。
This commit is contained in:
@@ -52,6 +52,39 @@ live-hub-py/
|
||||
./deploy.sh reset # 重置所有数据(需二次确认)
|
||||
```
|
||||
|
||||
## 时区口径(查库前必读)
|
||||
|
||||
**全库时间列一律存 UTC 裸值**(`DATETIME`,无 `TIMESTAMP` 列、无 `CURRENT_TIMESTAMP` 默认值)。MySQL 容器以
|
||||
`--default-time-zone=+00:00` 启动,因此 **`NOW()` 返回的也是 UTC** —— 这**不是故障**,是刻意对齐存储口径。
|
||||
|
||||
| 层 | 看到的时间 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 数据库裸值 / `NOW()` | UTC | 命令行直连就是这个 |
|
||||
| ORM 读出(`UTCDateTime`) | `+00:00` aware | 读出来永远带时区,比较不会踩 naive/aware 的坑 |
|
||||
| HTTP 接口返回 | `...+00:00` | 前端按本地时区渲染 |
|
||||
| 前端显示 | 北京时间(UTC+8) | `dayjs` 自动转换 |
|
||||
| 应用日志 | 北京时间 | 容器内 `TZ=Asia/Shanghai` |
|
||||
|
||||
> ⚠️ **别拿 `SELECT NOW()` 当"真实时间"去和业务时间比对** —— 它比你手表慢 8 小时是正常的。
|
||||
> 已踩过一次:据此误判"数据库时区错了",实际端到端完全正确。
|
||||
|
||||
命令行查库要按北京时间看,用 `CONVERT_TZ`:
|
||||
|
||||
```sql
|
||||
SELECT id, status,
|
||||
CONVERT_TZ(created_at, '+00:00', '+08:00') AS 创建时间,
|
||||
CONVERT_TZ(finished_at, '+00:00', '+08:00') AS 完成时间
|
||||
FROM login_tasks ORDER BY id DESC LIMIT 10;
|
||||
```
|
||||
|
||||
按"北京时间某段"筛选用同样的换算,别直接写本地时间字面量:
|
||||
|
||||
```sql
|
||||
-- 查北京时间 10-03 09:00 之后的任务
|
||||
SELECT * FROM login_tasks
|
||||
WHERE created_at >= CONVERT_TZ('2026-10-03 09:00:00', '+08:00', '+00:00');
|
||||
```
|
||||
|
||||
## 运行时与密钥
|
||||
|
||||
生产是 **单个应用容器 + 内网 MySQL 8.4**。数据库不映射公网;本机 `./dev.sh` 通过 `127.0.0.1:3307` 连 compose 里的 MySQL。
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""回归:登录任务列表分页、跨页全选删除、以及「不删在途任务」的护栏。
|
||||
|
||||
任务量早就过了单页能装下的规模(库里 1.4 万条、单批就 177 条),前端只能看
|
||||
200 条于是「全选」其实选不到全部。分页 + ``all_matching`` 是为了让「清空失败
|
||||
任务」这类操作真能一键做完。
|
||||
|
||||
三条必须守住:
|
||||
|
||||
1. **不传 page 时返回裸数组** —— Cookie 页等老调用方依赖这个契约,改了就崩;
|
||||
2. **传 page 才返回 {items,total}**,total 用于跨页全选;
|
||||
3. **pending/running 一律不删** —— 删掉会让批次线程找不到记录。要停批次
|
||||
走 ``/stop/{batch_id}``,不是删除。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import web.backend.database as database_module
|
||||
from tests.support.db import get_test_engine, make_test_session
|
||||
from web.backend.models import Account, LoginTask, User
|
||||
from web.backend.routers.login import delete_tasks, list_tasks
|
||||
|
||||
|
||||
class TestTaskPagination:
|
||||
def setup_method(self):
|
||||
self.engine = get_test_engine()
|
||||
database_module.SessionLocal.configure(bind=self.engine)
|
||||
self.session = make_test_session(self.engine)
|
||||
|
||||
self.user = User(username="admin", password_hash="h", role="super_admin")
|
||||
self.session.add(self.user)
|
||||
self.session.flush()
|
||||
|
||||
self.account = Account(
|
||||
username="分页测试账号",
|
||||
password="p",
|
||||
email="paged@example.com",
|
||||
email_password="ep",
|
||||
)
|
||||
self.session.add(self.account)
|
||||
self.session.flush()
|
||||
|
||||
# 建 12 条:7 条 failed、5 条 success,用于验证筛选与分页
|
||||
self.task_ids = []
|
||||
for i in range(12):
|
||||
task = LoginTask(
|
||||
batch_id="paged-batch",
|
||||
account_id=self.account.id,
|
||||
created_by=self.user.id,
|
||||
status="failed" if i < 7 else "success",
|
||||
message=f"任务{i}",
|
||||
)
|
||||
self.session.add(task)
|
||||
self.session.flush()
|
||||
self.task_ids.append(task.id)
|
||||
self.session.commit()
|
||||
|
||||
def teardown_method(self):
|
||||
self.session.close()
|
||||
|
||||
def _fresh(self):
|
||||
return sessionmaker(bind=self.engine)()
|
||||
|
||||
def test_without_page_returns_plain_list_for_backward_compat(self):
|
||||
"""老调用方(Cookie 页)拿到的必须是数组,不能是 {items,...}。"""
|
||||
db = self._fresh()
|
||||
try:
|
||||
rows = list_tasks(db=db, current=self.user)
|
||||
finally:
|
||||
db.close()
|
||||
assert isinstance(rows, list)
|
||||
assert len(rows) == 12
|
||||
|
||||
def test_with_page_returns_items_and_total(self):
|
||||
db = self._fresh()
|
||||
try:
|
||||
data = list_tasks(page=1, page_size=5, db=db, current=self.user)
|
||||
finally:
|
||||
db.close()
|
||||
assert isinstance(data, dict)
|
||||
assert data["total"] == 12
|
||||
assert data["page"] == 1
|
||||
assert len(data["items"]) == 5
|
||||
|
||||
def test_pages_do_not_overlap_and_cover_all(self):
|
||||
db = self._fresh()
|
||||
try:
|
||||
seen = []
|
||||
for page in (1, 2, 3):
|
||||
data = list_tasks(page=page, page_size=5, db=db, current=self.user)
|
||||
seen.extend(item.id for item in data["items"])
|
||||
finally:
|
||||
db.close()
|
||||
assert len(seen) == 12
|
||||
assert len(set(seen)) == 12, "分页出现重复"
|
||||
|
||||
def test_status_filter_narrows_total(self):
|
||||
"""total 必须是**筛选后**的总数,否则跨页全选会多删。"""
|
||||
db = self._fresh()
|
||||
try:
|
||||
data = list_tasks(
|
||||
page=1, page_size=5, status="failed", db=db, current=self.user
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
assert data["total"] == 7
|
||||
assert all(item.status == "failed" for item in data["items"])
|
||||
|
||||
def test_page_beyond_range_is_empty(self):
|
||||
db = self._fresh()
|
||||
try:
|
||||
data = list_tasks(page=99, page_size=5, db=db, current=self.user)
|
||||
finally:
|
||||
db.close()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 12
|
||||
|
||||
|
||||
class TestBulkDelete:
|
||||
def setup_method(self):
|
||||
self.engine = get_test_engine()
|
||||
database_module.SessionLocal.configure(bind=self.engine)
|
||||
self.session = make_test_session(self.engine)
|
||||
|
||||
self.user = User(username="admin2", password_hash="h", role="super_admin")
|
||||
self.session.add(self.user)
|
||||
self.session.flush()
|
||||
self.account = Account(
|
||||
username="删除测试账号",
|
||||
password="p",
|
||||
email="del@example.com",
|
||||
email_password="ep",
|
||||
)
|
||||
self.session.add(self.account)
|
||||
self.session.flush()
|
||||
|
||||
self.failed_ids = []
|
||||
self.pending_ids = []
|
||||
for i in range(5):
|
||||
for status, bucket in (
|
||||
("failed", self.failed_ids),
|
||||
("pending", self.pending_ids),
|
||||
):
|
||||
task = LoginTask(
|
||||
batch_id="del-batch",
|
||||
account_id=self.account.id,
|
||||
created_by=self.user.id,
|
||||
status=status,
|
||||
message=f"{status}{i}",
|
||||
)
|
||||
self.session.add(task)
|
||||
self.session.flush()
|
||||
bucket.append(task.id)
|
||||
self.session.commit()
|
||||
|
||||
def teardown_method(self):
|
||||
self.session.close()
|
||||
|
||||
def _fresh(self):
|
||||
return sessionmaker(bind=self.engine)()
|
||||
|
||||
def _remaining(self):
|
||||
db = self._fresh()
|
||||
try:
|
||||
return {
|
||||
row[0]
|
||||
for row in db.query(LoginTask.id).filter(
|
||||
LoginTask.batch_id == "del-batch"
|
||||
)
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_delete_by_ids(self):
|
||||
db = self._fresh()
|
||||
try:
|
||||
res = delete_tasks(
|
||||
task_ids=",".join(str(i) for i in self.failed_ids[:3]),
|
||||
db=db,
|
||||
current=self.user,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
assert res["deleted"] == 3
|
||||
remaining = self._remaining()
|
||||
assert all(i not in remaining for i in self.failed_ids[:3])
|
||||
|
||||
def test_delete_all_matching_status(self):
|
||||
"""跨页全选:删掉筛选条件下的全部 failed。"""
|
||||
db = self._fresh()
|
||||
try:
|
||||
res = delete_tasks(
|
||||
status="failed", all_matching=True, db=db, current=self.user
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
assert res["deleted"] == 5
|
||||
remaining = self._remaining()
|
||||
assert all(i not in remaining for i in self.failed_ids)
|
||||
assert all(i in remaining for i in self.pending_ids)
|
||||
|
||||
def test_delete_all_matching_never_touches_in_flight(self):
|
||||
"""核心护栏:pending/running 必须留下,否则批次线程会找不到记录。"""
|
||||
db = self._fresh()
|
||||
try:
|
||||
res = delete_tasks(all_matching=True, db=db, current=self.user)
|
||||
finally:
|
||||
db.close()
|
||||
# 只有 5 条 failed 可删,5 条 pending 被护栏挡住
|
||||
assert res["deleted"] == 5
|
||||
remaining = self._remaining()
|
||||
assert all(i in remaining for i in self.pending_ids)
|
||||
|
||||
def test_delete_without_criteria_rejected(self):
|
||||
"""不给条件就拒绝 —— 绝不默认全删。"""
|
||||
db = self._fresh()
|
||||
try:
|
||||
with pytest.raises(Exception) as exc:
|
||||
delete_tasks(db=db, current=self.user)
|
||||
finally:
|
||||
db.close()
|
||||
assert "请指定任务ID" in str(exc.value)
|
||||
|
||||
def test_delete_invalid_ids_rejected(self):
|
||||
db = self._fresh()
|
||||
try:
|
||||
with pytest.raises(Exception) as exc:
|
||||
delete_tasks(task_ids="abc,xyz", db=db, current=self.user)
|
||||
finally:
|
||||
db.close()
|
||||
assert "无效的任务ID" in str(exc.value)
|
||||
+123
-18
@@ -3,8 +3,14 @@
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy import func, or_
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session, defer, load_only
|
||||
|
||||
from ..database import SessionLocal, get_db
|
||||
@@ -135,20 +141,14 @@ async def create_batch(
|
||||
return {"batch_id": batch_id, "count": len(valid_ids), "success": True}
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[LoginTaskOut])
|
||||
def list_tasks(
|
||||
batch_id: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""查看登录任务列表。"""
|
||||
def _visible_tasks_query(db: Session, current: User, batch_id: str | None):
|
||||
"""按权限与批次过滤登录任务。客服只能看自己名下账号的任务。"""
|
||||
query = (
|
||||
db.query(LoginTask)
|
||||
.options(defer(LoginTask.cookie))
|
||||
.join(Account, LoginTask.account_id == Account.id)
|
||||
)
|
||||
|
||||
# 客服只能看自己账号的任务
|
||||
if not user_has_permission(current, "login:view_all"):
|
||||
query = query.filter(Account.assigned_to == current.id)
|
||||
|
||||
@@ -157,9 +157,11 @@ def list_tasks(
|
||||
query = query.filter(
|
||||
or_(LoginTask.batch_id == batch_id, LoginTask.attempt_batch_id == batch_id)
|
||||
)
|
||||
return query
|
||||
|
||||
rows = query.order_by(LoginTask.id.desc()).limit(200).all()
|
||||
# 批量收集 account_id,一次性查出 username
|
||||
|
||||
def _serialize_tasks(db: Session, rows) -> list[LoginTaskOut]:
|
||||
"""把任务行拼上账号名后序列化。"""
|
||||
account_ids = [t.account_id for t in rows]
|
||||
accounts_map = {}
|
||||
if account_ids:
|
||||
@@ -193,6 +195,54 @@ def list_tasks(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
def list_tasks(
|
||||
batch_id: str | None = None,
|
||||
# 默认值保持 None(而不是 Query(None)):这个接口会被测试与内部代码直接
|
||||
# 调用,用 Query() 做默认值时拿到的会是那个 Query 对象而非 None。
|
||||
page: int | None = None,
|
||||
page_size: int = 50,
|
||||
status: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""查看登录任务列表。
|
||||
|
||||
**不传 `page` 时返回裸数组**(上限 200),沿用老调用方(Cookie 页等)的
|
||||
契约;传了 `page` 才返回 ``{items, total, page, page_size}``,供表格分页。
|
||||
|
||||
传 `page` 时同时返回 `total`,是为了让前端能「全选所有筛选结果」——
|
||||
否则跨页选择无从下手,而任务量早就过了单页能装下的规模。
|
||||
"""
|
||||
query = _visible_tasks_query(db, current, batch_id)
|
||||
if status:
|
||||
# 前端看到的是「最近一次尝试」的状态,与列表序列化口径保持一致。
|
||||
query = query.filter(
|
||||
or_(
|
||||
and_(
|
||||
LoginTask.attempt_status != "", LoginTask.attempt_status == status
|
||||
),
|
||||
and_(LoginTask.attempt_status == "", LoginTask.status == status),
|
||||
)
|
||||
)
|
||||
|
||||
ordered = query.order_by(LoginTask.id.desc())
|
||||
|
||||
if page is None:
|
||||
return _serialize_tasks(db, ordered.limit(200).all())
|
||||
|
||||
page = max(1, int(page))
|
||||
page_size = max(1, min(int(page_size), 500))
|
||||
total = ordered.count()
|
||||
rows = ordered.offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {
|
||||
"items": _serialize_tasks(db, rows),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tasks/summary")
|
||||
def tasks_summary(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -223,18 +273,73 @@ def delete_task(
|
||||
@router.delete("/tasks")
|
||||
def delete_tasks(
|
||||
task_ids: str = "",
|
||||
batch_id: str | None = None,
|
||||
status: str | None = None,
|
||||
all_matching: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("login:batch")),
|
||||
):
|
||||
"""批量删除登录任务。"""
|
||||
if not task_ids:
|
||||
raise HTTPException(status_code=400, detail="请指定任务ID")
|
||||
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
|
||||
"""批量删除登录任务。三种模式,互斥:
|
||||
|
||||
1. ``task_ids``:删指定 id(表格勾选);
|
||||
2. ``all_matching=true``:删当前筛选条件下的**全部**(跨页全选用);
|
||||
3. 两者都没给 → 400,绝不默认全删。
|
||||
|
||||
**进行中(pending/running)的任务一律跳过**:删掉会让批次线程找不到记录,
|
||||
而停止批次应该走 ``/stop/{batch_id}``。
|
||||
"""
|
||||
# 护栏:不删在途任务,避免批次线程与数据库状态打架。
|
||||
in_flight = ("pending", "running")
|
||||
not_in_flight = or_(
|
||||
and_(
|
||||
LoginTask.attempt_status != "",
|
||||
LoginTask.attempt_status.notin_(in_flight),
|
||||
),
|
||||
and_(LoginTask.attempt_status == "", LoginTask.status.notin_(in_flight)),
|
||||
)
|
||||
|
||||
if task_ids:
|
||||
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="无效的任务ID")
|
||||
elif all_matching:
|
||||
# 带 join 的 query 不能直接 bulk delete(SQLAlchemy 会拒绝),
|
||||
# 所以先按权限与筛选条件查出 id,再按主键删。
|
||||
query = _visible_tasks_query(db, current, batch_id)
|
||||
if status:
|
||||
query = query.filter(
|
||||
or_(
|
||||
and_(
|
||||
LoginTask.attempt_status != "",
|
||||
LoginTask.attempt_status == status,
|
||||
),
|
||||
and_(LoginTask.attempt_status == "", LoginTask.status == status),
|
||||
)
|
||||
)
|
||||
ids = [row[0] for row in query.with_entities(LoginTask.id).all()]
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="请指定任务ID或 all_matching=true")
|
||||
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="无效的任务ID")
|
||||
return {"message": "没有符合条件的任务", "deleted": 0, "success": True}
|
||||
|
||||
target_ids = [
|
||||
row[0]
|
||||
for row in db.query(LoginTask.id)
|
||||
.filter(LoginTask.id.in_(ids))
|
||||
.filter(not_in_flight)
|
||||
.all()
|
||||
]
|
||||
if not target_ids:
|
||||
return {
|
||||
"message": "没有可删除的任务(在途任务已跳过)",
|
||||
"deleted": 0,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
deleted = (
|
||||
db.query(LoginTask)
|
||||
.filter(LoginTask.id.in_(ids))
|
||||
.filter(LoginTask.id.in_(target_ids))
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -10,13 +10,28 @@ interface CreateBatchParams {
|
||||
mode?: 'login' | 'check';
|
||||
}
|
||||
|
||||
export interface TaskPage {
|
||||
items: LoginTaskItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export const loginApi = {
|
||||
createBatch: (params: CreateBatchParams) =>
|
||||
api.post<BatchLoginResult, BatchLoginResult>('/login/batch', params),
|
||||
// 不传 page 时后端返回裸数组(老调用方契约),传了才返回 {items,total}。
|
||||
listTasks: (batch_id?: string) =>
|
||||
api.get<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
|
||||
listTasksPaged: (params: { page: number; page_size: number; batch_id?: string; status?: string }) =>
|
||||
api.get<TaskPage, TaskPage>('/login/tasks', { params: { ...params, batch_id: params.batch_id ?? '' } }),
|
||||
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/login/tasks/summary'),
|
||||
stop: (batch_id: string) => api.post<MessageResponse, MessageResponse>(`/login/stop/${batch_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(',') } }),
|
||||
// 跨页全选用:按当前筛选条件删除全部(后端会跳过在途任务)。
|
||||
deleteTasksMatching: (params: { batch_id?: string; status?: string }) =>
|
||||
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/login/tasks', {
|
||||
params: { all_matching: true, batch_id: params.batch_id ?? '', status: params.status ?? '' },
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -89,6 +89,16 @@ export default function LoginTasksPage() {
|
||||
useEffect(() => { localStorage.setItem('login_max_login_retries', String(maxLoginRetries)); }, [maxLoginRetries]);
|
||||
useEffect(() => { localStorage.setItem('login_api_strategy', apiStrategy); }, [apiStrategy]);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [selectedAllMatching, setSelectedAllMatching] = useState(false);
|
||||
// 任务列表分页:库里上万条,单页装不下;total 同时用于「全选所有结果」。
|
||||
const [taskPage, setTaskPage] = useState(1);
|
||||
const [taskPageSize, setTaskPageSize] = useState(() => {
|
||||
const v = localStorage.getItem('login_task_page_size');
|
||||
return v ? Math.max(1, Math.min(500, Number(v) || 50)) : 50;
|
||||
});
|
||||
const [taskStatus, setTaskStatus] = useState<string>('');
|
||||
const [taskTotal, setTaskTotal] = useState(0);
|
||||
useEffect(() => { localStorage.setItem('login_task_page_size', String(taskPageSize)); }, [taskPageSize]);
|
||||
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
|
||||
const { can } = usePermissions();
|
||||
|
||||
@@ -199,14 +209,23 @@ export default function LoginTasksPage() {
|
||||
if (tasksLoadingRef.current) return;
|
||||
tasksLoadingRef.current = true;
|
||||
try {
|
||||
const data = await loginApi.listTasks(batchId || undefined);
|
||||
setTasks(data);
|
||||
const data = await loginApi.listTasksPaged({
|
||||
page: taskPage,
|
||||
page_size: taskPageSize,
|
||||
batch_id: batchId || undefined,
|
||||
status: taskStatus || undefined,
|
||||
});
|
||||
setTasks(data.items);
|
||||
setTaskTotal(data.total);
|
||||
// 翻页/筛选后清掉失效的勾选,避免「选中了看不见的行」。
|
||||
const visible = new Set(data.items.map((t) => t.id));
|
||||
setSelectedRowKeys((prev) => (selectedAllMatching ? prev : prev.filter((k) => visible.has(k))));
|
||||
} catch {
|
||||
// 忽略轮询失败,下一次定时刷新会继续尝试。
|
||||
} finally {
|
||||
tasksLoadingRef.current = false;
|
||||
}
|
||||
}, [batchId]);
|
||||
}, [batchId, taskPage, taskPageSize, taskStatus, selectedAllMatching]);
|
||||
|
||||
const hasActiveTasks = useMemo(
|
||||
() => tasks.some((task) => ['pending', 'running'].includes(task.status)),
|
||||
@@ -331,20 +350,54 @@ export default function LoginTasksPage() {
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
if (!selectedAllMatching && selectedRowKeys.length === 0) {
|
||||
message.warning('请选择要删除的任务');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loginApi.deleteTasks(selectedRowKeys);
|
||||
message.success(`已删除 ${selectedRowKeys.length} 个任务`);
|
||||
const res = selectedAllMatching
|
||||
? await loginApi.deleteTasksMatching({
|
||||
batch_id: batchId || undefined,
|
||||
status: taskStatus || undefined,
|
||||
})
|
||||
: await loginApi.deleteTasks(selectedRowKeys);
|
||||
message.success(res.message || `已删除 ${res.deleted} 个任务`);
|
||||
setSelectedRowKeys([]);
|
||||
setSelectedAllMatching(false);
|
||||
setTaskPage(1);
|
||||
loadTasks();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
// 一键清空当前筛选下的失败任务(跨页,不用先翻完所有页去勾)
|
||||
const handleClearFailed = async () => {
|
||||
try {
|
||||
const res = await loginApi.deleteTasksMatching({
|
||||
batch_id: batchId || undefined,
|
||||
status: 'failed',
|
||||
});
|
||||
message.success(res.message || `已删除 ${res.deleted} 个任务`);
|
||||
setSelectedRowKeys([]);
|
||||
setSelectedAllMatching(false);
|
||||
setTaskPage(1);
|
||||
loadTasks();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllMatching = () => {
|
||||
setSelectedAllMatching(true);
|
||||
setSelectedRowKeys([]);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedAllMatching(false);
|
||||
setSelectedRowKeys([]);
|
||||
};
|
||||
|
||||
const successCount = tasks.filter((t) => t.status === 'success').length;
|
||||
const failedCount = tasks.filter((t) => ['failed', 'error'].includes(t.status)).length;
|
||||
const checkedCount = tasks.filter((t) => [
|
||||
@@ -556,19 +609,64 @@ export default function LoginTasksPage() {
|
||||
{/* 概览 + 任务列表区域 */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', paddingTop: 6 }}>
|
||||
{/* 概览 */}
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 16, fontSize: 13, color: token.colorTextSecondary, padding: '4px 0' }}>
|
||||
<span>共 <b>{tasks.length}</b> 个任务</span>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 12, fontSize: 13, color: token.colorTextSecondary, padding: '4px 0', flexWrap: 'wrap' }}>
|
||||
<span>共 <b>{taskTotal}</b> 个任务</span>
|
||||
<span>成功 <b style={{ color: token.colorSuccess }}>{successCount}</b></span>
|
||||
<span>失败 <b style={{ color: token.colorError }}>{failedCount}</b></span>
|
||||
{checkedCount > 0 && <span>检测 <b>{checkedCount}</b></span>}
|
||||
{effectiveBatchId && <span>批次: <b>{effectiveBatchId}</b></span>}
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 130 }}
|
||||
value={taskStatus}
|
||||
onChange={(v) => { setTaskStatus(v); setTaskPage(1); clearSelection(); }}
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'pending', label: '等待中' },
|
||||
{ value: 'running', label: '进行中' },
|
||||
]}
|
||||
/>
|
||||
<div style={{ flex: 1 }} />
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<Popconfirm title={`确定删除选中的 ${selectedRowKeys.length} 个任务?`} onConfirm={handleDeleteSelected} okText="删除" cancelText="取消">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除选中 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
{canBatch && (
|
||||
<>
|
||||
{(selectedRowKeys.length > 0 || selectedAllMatching) && (
|
||||
<Popconfirm
|
||||
title={
|
||||
selectedAllMatching
|
||||
? `确定删除筛选条件下的全部 ${taskTotal} 个任务?(进行中的会跳过)`
|
||||
: `确定删除选中的 ${selectedRowKeys.length} 个任务?`
|
||||
}
|
||||
onConfirm={handleDeleteSelected}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
{selectedAllMatching
|
||||
? `删除全部 (${taskTotal})`
|
||||
: `删除选中 (${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{selectedAllMatching && (
|
||||
<Button type="link" size="small" onClick={clearSelection}>
|
||||
取消全选
|
||||
</Button>
|
||||
)}
|
||||
{failedCount > 0 && (
|
||||
<Popconfirm
|
||||
title={`确定清空当前筛选下的失败任务?共 ${taskStatus === 'failed' ? taskTotal : failedCount} 个(进行中的会跳过)`}
|
||||
onConfirm={handleClearFailed}
|
||||
okText="清空"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
清空失败
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!wsConnected && failedCount > 0 && (
|
||||
<Button
|
||||
@@ -586,16 +684,50 @@ export default function LoginTasksPage() {
|
||||
|
||||
{/* 任务列表 - flex:1 占满剩余空间,内部滚动 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
{selectedAllMatching && (
|
||||
<div style={{ padding: '4px 8px', fontSize: 12, background: token.colorInfoBg, color: token.colorInfoText, borderRadius: 4, marginBottom: 6 }}>
|
||||
已选中当前筛选条件下的全部 <b>{taskTotal}</b> 条(含未显示的分页)。
|
||||
<Button type="link" size="small" onClick={clearSelection} style={{ padding: '0 4px' }}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
sticky
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||
onChange: (keys) => {
|
||||
setSelectedRowKeys(keys as number[]);
|
||||
setSelectedAllMatching(false);
|
||||
},
|
||||
// 「全选所有结果」:跨页,按当前筛选条件选中全部(不只是本页)。
|
||||
selections: [
|
||||
Table.SELECTION_ALL,
|
||||
Table.SELECTION_INVERT,
|
||||
{
|
||||
key: 'all-matching',
|
||||
text: `选择全部 ${taskTotal} 条(含其他页)`,
|
||||
onSelect: () => handleSelectAllMatching(),
|
||||
},
|
||||
Table.SELECTION_NONE,
|
||||
],
|
||||
}}
|
||||
pagination={{
|
||||
current: taskPage,
|
||||
pageSize: taskPageSize,
|
||||
total: taskTotal,
|
||||
size: 'small',
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200', '500'],
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (page, size) => {
|
||||
setTaskPage(page);
|
||||
setTaskPageSize(size);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user