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:
yml2213
2026-10-03 10:37:52 +08:00
parent 5995f16c39
commit a0db26def3
5 changed files with 550 additions and 34 deletions
+33
View File
@@ -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。
+231
View File
@@ -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
View File
@@ -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()
+15
View File
@@ -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 ?? '' },
}),
};
+148 -16
View File
@@ -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>