优化登录任务界面 + 修复时间显示 + 增加删除功能
- 筛选栏改为单行横排,概览精简为一行文字 - 任务列表全宽展示,日志移到底部可折叠 - 页面使用百分比布局,一屏展示无外层滚动 - 登录任务增加单条删除和批量删除功能 - 后端: datetime.utcnow 替换为 datetime.now(timezone.utc) - 后端: schemas 序列化统一输出带时区 ISO 格式 - 前端: 新增 utils/time.ts 统一时间格式化工具 - 前端: CookiePage/LoginTasksPage 时间列使用 formatTime() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8771d91a30
commit
72bcd1274c
@@ -3,6 +3,7 @@ import { Table, Button, Card, Row, Col, Statistic, message, Tag, Popconfirm, Spa
|
||||
import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { cookieApi } from '../api/modules';
|
||||
import { getUser, hasPerm } from '../store/auth';
|
||||
import { formatTime } from '../utils/time';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -126,7 +127,7 @@ export default function CookiePage() {
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
render: (val: string) => val ? val.replace('T', ' ').slice(0, 19) : '-',
|
||||
render: (val: string) => formatTime(val),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Select, message, Tag, Space, Card, Row, Col, Statistic, Spin, InputNumber, Tooltip,
|
||||
Table, Button, Select, message, Tag, Space, Spin, InputNumber, Tooltip, Popconfirm,
|
||||
} from 'antd';
|
||||
import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DownOutlined, UpOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { accountApi, loginApi } from '../api/modules';
|
||||
import { getUser, hasPerm } from '../store/auth';
|
||||
import { formatTime } from '../utils/time';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'default',
|
||||
@@ -32,7 +33,10 @@ export default function LoginTasksPage() {
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [concurrency, setConcurrency] = useState(3);
|
||||
const [logVisible, setLogVisible] = useState(true);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const user = getUser();
|
||||
|
||||
const canBatch = hasPerm(user, 'login:batch');
|
||||
@@ -107,6 +111,13 @@ export default function LoginTasksPage() {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
// 日志自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (logVisible && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [logs, logVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadTasks, 3000);
|
||||
return () => clearInterval(timer);
|
||||
@@ -183,6 +194,32 @@ export default function LoginTasksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (taskId: number) => {
|
||||
try {
|
||||
await loginApi.deleteTask(taskId);
|
||||
message.success('已删除');
|
||||
loadTasks();
|
||||
setSelectedRowKeys((prev) => prev.filter((k) => k !== taskId));
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请选择要删除的任务');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loginApi.deleteTasks(selectedRowKeys);
|
||||
message.success(`已删除 ${selectedRowKeys.length} 个任务`);
|
||||
setSelectedRowKeys([]);
|
||||
loadTasks();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const successCount = tasks.filter((t) => t.status === 'success').length;
|
||||
const failedCount = tasks.filter((t) => ['failed', 'error'].includes(t.status)).length;
|
||||
|
||||
@@ -195,13 +232,13 @@ export default function LoginTasksPage() {
|
||||
render: (status: string) => <Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status] || status}</Tag>,
|
||||
},
|
||||
{ title: '消息', dataIndex: 'message', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 180 },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 180, render: (val: string) => formatTime(val) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_: any, record: any) => {
|
||||
if (['failed', 'error'].includes(record.status) && !wsConnected) {
|
||||
return (
|
||||
width: 120,
|
||||
render: (_: any, record: any) => (
|
||||
<Space size={4}>
|
||||
{['failed', 'error'].includes(record.status) && !wsConnected && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -210,177 +247,189 @@ export default function LoginTasksPage() {
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)}
|
||||
<Popconfirm title="确定删除此任务?" onConfirm={() => handleDeleteTask(record.id)} okText="删除" cancelText="取消">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<h2 style={{ marginTop: 0, flexShrink: 0 }}>登录任务</h2>
|
||||
|
||||
{canBatch && (
|
||||
<Card size="small" style={{ marginBottom: 12, flexShrink: 0 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{/* 标签快捷选择 */}
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* 标题 + 筛选栏 */}
|
||||
<div style={{ flexShrink: 0, paddingBottom: 8, borderBottom: '1px solid #f0f0f0' }}>
|
||||
<h2 style={{ marginTop: 0, marginBottom: 6 }}>登录任务</h2>
|
||||
{canBatch && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{allTags.length > 0 && (
|
||||
<Space>
|
||||
<FilterOutlined />
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ minWidth: 300 }}
|
||||
placeholder="选择标签快速筛选账号"
|
||||
value={selectedTags}
|
||||
onChange={handleTagChange}
|
||||
options={allTags.map((t) => ({ value: t, label: t }))}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
{selectedTags.length > 0 && (
|
||||
<span style={{ color: '#888', fontSize: 12 }}>
|
||||
已通过标签选中 {accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length} 个账号
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
{/* 账号详情选择 */}
|
||||
<Space>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ minWidth: 400 }}
|
||||
placeholder="输入关键词筛选账号"
|
||||
value={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
options={(() => {
|
||||
const grouped: Record<string, { value: number; label: string }[]> = {};
|
||||
const noTag: { value: number; label: string }[] = [];
|
||||
accounts.forEach((a) => {
|
||||
const tag = (a.tag || '').trim();
|
||||
if (tag) {
|
||||
if (!grouped[tag]) grouped[tag] = [];
|
||||
grouped[tag].push({ value: a.id, label: a.username });
|
||||
} else {
|
||||
noTag.push({ value: a.id, label: a.username });
|
||||
}
|
||||
});
|
||||
const result: any[] = [];
|
||||
Object.keys(grouped).sort().forEach((tag) => {
|
||||
result.push({ label: tag, options: grouped[tag] });
|
||||
});
|
||||
if (noTag.length > 0) {
|
||||
result.push({ label: '未分组', options: noTag });
|
||||
}
|
||||
return result;
|
||||
})()}
|
||||
style={{ minWidth: 200, maxWidth: 300 }}
|
||||
placeholder="按标签筛选"
|
||||
value={selectedTags}
|
||||
onChange={handleTagChange}
|
||||
options={allTags.map((t) => ({ value: t, label: t }))}
|
||||
maxTagCount="responsive"
|
||||
showSearch
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const label = (option as any).label as string || '';
|
||||
return label.toLowerCase().includes(input.toLowerCase());
|
||||
}}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
<div style={{ padding: '4px 8px', borderBottom: '1px solid #f0f0f0', display: 'flex', gap: 8 }}>
|
||||
<Button size="small" type="link" onClick={() => { setSelectedIds(accounts.map((a) => a.id)); setSelectedTags([]); }}>
|
||||
全选 ({accounts.length})
|
||||
</Button>
|
||||
<Button size="small" type="link" onClick={() => { setSelectedIds([]); setSelectedTags([]); }}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
{menu}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Tooltip title="同时登录的账号数,1为顺序执行">
|
||||
<ThunderboltOutlined style={{ color: '#888' }} />
|
||||
</Tooltip>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={concurrency}
|
||||
onChange={(v) => setConcurrency(v || 1)}
|
||||
style={{ width: 60 }}
|
||||
allowClear
|
||||
size="small"
|
||||
suffixIcon={<FilterOutlined />}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleBatchLogin}
|
||||
disabled={selectedIds.length === 0}
|
||||
>
|
||||
开始登录
|
||||
</Button>
|
||||
{batchId && (
|
||||
<Button danger icon={<StopOutlined />} onClick={handleStop}>
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 12, flexShrink: 0 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="任务总数" value={tasks.length} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="成功" value={successCount} valueStyle={{ color: '#3f8600' }} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="失败" value={failedCount} valueStyle={{ color: '#cf1322' }} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="当前批次" value={batchId || '-'} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ flex: 1, minHeight: 0 }}>
|
||||
<Col span={14} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Card
|
||||
title="任务列表"
|
||||
size="small"
|
||||
extra={
|
||||
!wsConnected && failedCount > 0 && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRetryFailed}
|
||||
loading={loading}
|
||||
>
|
||||
重试失败 ({failedCount})
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
bodyStyle={{ flex: 1, overflow: 'auto', padding: 0 }}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
)}
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ minWidth: 280, flex: 1, maxWidth: 500 }}
|
||||
placeholder="选择账号"
|
||||
value={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
options={(() => {
|
||||
const grouped: Record<string, { value: number; label: string }[]> = {};
|
||||
const noTag: { value: number; label: string }[] = [];
|
||||
accounts.forEach((a) => {
|
||||
const tag = (a.tag || '').trim();
|
||||
if (tag) {
|
||||
if (!grouped[tag]) grouped[tag] = [];
|
||||
grouped[tag].push({ value: a.id, label: a.username });
|
||||
} else {
|
||||
noTag.push({ value: a.id, label: a.username });
|
||||
}
|
||||
});
|
||||
const result: any[] = [];
|
||||
Object.keys(grouped).sort().forEach((tag) => {
|
||||
result.push({ label: tag, options: grouped[tag] });
|
||||
});
|
||||
if (noTag.length > 0) {
|
||||
result.push({ label: '未分组', options: noTag });
|
||||
}
|
||||
return result;
|
||||
})()}
|
||||
maxTagCount="responsive"
|
||||
showSearch
|
||||
size="small"
|
||||
pagination={{ pageSize: 15, size: 'small' }}
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const label = (option as any).label as string || '';
|
||||
return label.toLowerCase().includes(input.toLowerCase());
|
||||
}}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
<div style={{ padding: '4px 8px', borderBottom: '1px solid #f0f0f0', display: 'flex', gap: 8 }}>
|
||||
<Button size="small" type="link" onClick={() => { setSelectedIds(accounts.map((a) => a.id)); setSelectedTags([]); }}>
|
||||
全选 ({accounts.length})
|
||||
</Button>
|
||||
<Button size="small" type="link" onClick={() => { setSelectedIds([]); setSelectedTags([]); }}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
{menu}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={10} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Card
|
||||
title="实时日志"
|
||||
{selectedTags.length > 0 && (
|
||||
<span style={{ color: '#888', fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||
标签选中 {accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length} 个
|
||||
</span>
|
||||
)}
|
||||
<Tooltip title="同时登录的账号数,1为顺序执行">
|
||||
<Space size={4}>
|
||||
<ThunderboltOutlined style={{ color: '#888' }} />
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={concurrency}
|
||||
onChange={(v) => setConcurrency(v || 1)}
|
||||
style={{ width: 50 }}
|
||||
size="small"
|
||||
/>
|
||||
</Space>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleBatchLogin}
|
||||
disabled={selectedIds.length === 0}
|
||||
size="small"
|
||||
>
|
||||
开始登录
|
||||
</Button>
|
||||
{batchId && (
|
||||
<Button danger icon={<StopOutlined />} onClick={handleStop} size="small">
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 概览 + 任务列表区域 */}
|
||||
<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: '#666', padding: '4px 0' }}>
|
||||
<span>共 <b>{tasks.length}</b> 个任务</span>
|
||||
<span>成功 <b style={{ color: '#3f8600' }}>{successCount}</b></span>
|
||||
<span>失败 <b style={{ color: '#cf1322' }}>{failedCount}</b></span>
|
||||
{batchId && <span>批次: <b>{batchId}</b></span>}
|
||||
<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>
|
||||
)}
|
||||
{!wsConnected && failedCount > 0 && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRetryFailed}
|
||||
loading={loading}
|
||||
>
|
||||
重试失败 ({failedCount})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 任务列表 - flex:1 占满剩余空间,内部滚动 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
bodyStyle={{
|
||||
flex: 1,
|
||||
pagination={false}
|
||||
sticky
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 实时日志 - 底部可折叠 */}
|
||||
<div style={{ flexShrink: 0, borderTop: '1px solid #f0f0f0', marginTop: 4 }}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '4px 0', userSelect: 'none' }}
|
||||
onClick={() => setLogVisible((v) => !v)}
|
||||
>
|
||||
<span style={{ fontWeight: 500, fontSize: 13 }}>实时日志</span>
|
||||
{logVisible ? <UpOutlined style={{ marginLeft: 6, fontSize: 10 }} /> : <DownOutlined style={{ marginLeft: 6, fontSize: 10 }} />}
|
||||
{logs.length > 0 && <span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>{logs.length} 条</span>}
|
||||
{wsConnected && <Tag color="processing" style={{ marginLeft: 8 }}>连接中</Tag>}
|
||||
</div>
|
||||
{logVisible && (
|
||||
<div
|
||||
style={{
|
||||
height: '20vh',
|
||||
overflow: 'auto',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
padding: 12,
|
||||
padding: 4,
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
@@ -394,16 +443,17 @@ export default function LoginTasksPage() {
|
||||
log.level === 'error' ? '#ff4d4f' :
|
||||
log.level === 'success' ? '#52c41a' :
|
||||
log.level === 'warning' ? '#fa8c16' :
|
||||
'rgba(0,0,0,0.85)',
|
||||
'rgba(0,0,0,0.65)',
|
||||
}}
|
||||
>
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user