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:
+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