feat(login-tasks): 按状态导出 CSV + 登录失败写进账号生命周期
一、失败入账号一生(此前完全缺失)
生产实测:`login_ok` 1,456 条、`relogin_failed` 73 条,但**没有 login_failed**,
而 login_tasks 里 attempt_status='failed' 有 247 条 —— 这 247 次失败在账号时间线上
一条痕迹都没有,「这账号为什么一直登录不上」在账号页上无从回答。
- 新增事件类型 login_failed,在 `_mark_attempt_failed` 里写入
- **被停止 / 被中断不写**:分型把「已停止/中断/服务重启」归到 interrupted,
那不是账号的问题 —— 否则运营按一次停止就能给几十个账号刷满假失败
- 事件带分型(captcha/credential/email_code/email_quota/network),
同时落进 detail.kind,以后可直接按「验证码失败」筛账号
- 幂等键 login_task:{id}:failed:{ts},与成功路径同构
二、失败分型下沉成共用 service
`classify_failure` 原在 automation/jobs/ck_relogin.py,手动登录也要用。
挪到 services/login_failure.py(job 改为 import,`ck_relogin.classify_failure`
这个名字保留,旧调用方与用例不受影响),并提供型别中文标签与
`is_account_fault` 判据 —— 与仓库既有的「service 不该依赖 job」分层一致。
三、按状态导出 CSV
- 新增 GET /api/login/tasks/export:不分页,导出当前筛选的**全部**匹配记录;
列:账号/状态/失败原因/失败分型/批次/最近尝试时间(UTC)/标签/归属客服
- 权限与筛选与列表**完全共用**,客服只导出自己名下账号
- 带 UTF-8 BOM(不加 Excel 打开中文表头是乱码),导出动作写审计
四、修一个口径不一致的真 bug
页面「失败 N」= failed + error 之和,但筛选只认 failed ——
所以截图里「失败 389」点进去只有 386 条(差的就是那几条 error);
「清空失败」同样漏删 error。现在把状态组收敛成一份 `_apply_status_filter`,
列表 / 导出 / 批量删除三处共用。
测试:684 passed, 6 skipped(本次新增 13 个);三条关键断言做了反向验证
(去掉 stopped 判断 / 失败组去掉 error / 去掉 BOM,对应用例均变红)。
This commit is contained in:
@@ -26,6 +26,20 @@ export const loginApi = {
|
||||
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'),
|
||||
/**
|
||||
* 导出登录任务 CSV(按当前筛选,不分页)。
|
||||
*
|
||||
* `status` 传 'failed' 时后端会展开成 failed + error —— 页面上「失败」
|
||||
* 本来就是一个组,导出与列表口径必须一致。
|
||||
*/
|
||||
exportTasks: (params: { batch_id?: string; status?: string }) =>
|
||||
api.get<Blob, Blob>('/login/tasks/export', {
|
||||
responseType: 'blob',
|
||||
params: {
|
||||
batch_id: params.batch_id ?? '',
|
||||
status: params.status ?? '',
|
||||
},
|
||||
}),
|
||||
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(',') } }),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Table, Button, Select, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, Modal, Form,
|
||||
Table, Button, Select, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, Modal, Form, Dropdown,
|
||||
} from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import {
|
||||
PlayCircleOutlined, StopOutlined, FilterOutlined, ReloadOutlined, DeleteOutlined,
|
||||
SettingOutlined, SafetyCertificateOutlined,
|
||||
SettingOutlined, SafetyCertificateOutlined, DownloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { accountApi, loginApi, type AccountItem, type LoginTaskItem, type TaskSummary } from '../api/modules';
|
||||
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||
@@ -391,6 +391,34 @@ export default function LoginTasksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出登录任务 CSV。
|
||||
*
|
||||
* `status` 传 undefined 表示「当前筛选」—— 与列表看到的完全是同一批;
|
||||
* 传 'all' 表示不受筛选限制导出全部(后端把空串当不加条件)。
|
||||
* 导出的是**全部匹配记录**,不是当前页。
|
||||
*/
|
||||
const handleExport = async (scope: 'current' | 'all' | 'success' | 'failed') => {
|
||||
const status =
|
||||
scope === 'current' ? taskStatus : scope === 'all' ? '' : scope;
|
||||
try {
|
||||
const blob = await loginApi.exportTasks({
|
||||
batch_id: batchId || undefined,
|
||||
status,
|
||||
});
|
||||
const url = URL.createObjectURL(blob instanceof Blob ? blob : new Blob([blob]));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const label = scope === 'current' ? (taskStatus || 'all') : scope;
|
||||
a.download = `login_tasks_${label}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('已导出,可用 Excel 打开(已带 BOM,中文不乱码)');
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
// 一键清空当前筛选下的失败任务(跨页,不用先翻完所有页去勾)
|
||||
const handleClearFailed = async () => {
|
||||
try {
|
||||
@@ -649,6 +677,21 @@ export default function LoginTasksPage() {
|
||||
{ value: 'running', label: '进行中' },
|
||||
]}
|
||||
/>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'current', label: taskStatus ? '导出当前筛选' : '导出全部' },
|
||||
{ key: 'all', label: '导出全部(忽略筛选)' },
|
||||
{ key: 'success', label: '只导出成功' },
|
||||
{ key: 'failed', label: '只导出失败(含异常)' },
|
||||
],
|
||||
onClick: ({ key }) => void handleExport(key as 'current' | 'all' | 'success' | 'failed'),
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<DownloadOutlined />}>
|
||||
导出 CSV
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<div style={{ flex: 1 }} />
|
||||
{canBatch && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user