增加了账号管理的分组功能

This commit is contained in:
yml2213
2026-06-22 15:09:51 +08:00
parent 5fa91c1bb4
commit 9af92fc2a9
13 changed files with 437 additions and 55 deletions
+165 -38
View File
@@ -1,8 +1,8 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import {
Table, Button, Select, message, Tag, Space, Card, Row, Col, Statistic, Spin,
} from 'antd';
import { PlayCircleOutlined, StopOutlined } from '@ant-design/icons';
import { PlayCircleOutlined, StopOutlined, FilterOutlined } from '@ant-design/icons';
import { accountApi, loginApi } from '../api/modules';
import { getUser, hasPerm } from '../store/auth';
@@ -30,11 +30,31 @@ export default function LoginTasksPage() {
const [batchId, setBatchId] = useState<string | null>(null);
const [logs, setLogs] = useState<{ level: string; message: string }[]>([]);
const [wsConnected, setWsConnected] = useState(false);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const user = getUser();
const canBatch = hasPerm(user, 'login:batch');
// 从账号中提取所有标签
const allTags = useMemo(() => {
const tags = [...new Set(accounts.map((a) => (a.tag || '').trim()).filter(Boolean))];
return tags.sort();
}, [accounts]);
// 标签→账号ID映射
const tagAccountMap = useMemo(() => {
const map: Record<string, number[]> = {};
accounts.forEach((a) => {
const tag = (a.tag || '').trim();
if (tag) {
if (!map[tag]) map[tag] = [];
map[tag].push(a.id);
}
});
return map;
}, [accounts]);
const loadAccounts = async () => {
try {
const data = await accountApi.list();
@@ -44,6 +64,37 @@ export default function LoginTasksPage() {
}
};
// 标签选择变化时,同步更新选中的账号
const handleTagChange = useCallback((tags: string[]) => {
setSelectedTags(tags);
setSelectedIds((prev) => {
const prevTagSet = new Set(selectedTags);
const newTagSet = new Set(tags);
// 新增的标签
const addedTags = tags.filter((t) => !prevTagSet.has(t));
// 移除的标签
const removedTags = selectedTags.filter((t) => !newTagSet.has(t));
// 收集被移除标签下的所有账号ID
const removedIds = new Set(removedTags.flatMap((t) => tagAccountMap[t] || []));
// 收集新增标签下的所有账号ID
const addedIds = addedTags.flatMap((t) => tagAccountMap[t] || []);
// 保留手动选择的账号(不属于任何已选标签的),移除被取消标签的账号,添加新选标签的账号
const manualIds = prev.filter((id) => {
const acc = accounts.find((a) => a.id === id);
if (!acc) return false;
const accTag = (acc.tag || '').trim();
// 保留不属于当前任何已选标签、也不属于被移除标签的
return !prevTagSet.has(accTag) && !removedIds.has(id);
});
return [...new Set([...manualIds, ...addedIds])];
});
}, [selectedTags, tagAccountMap, accounts]);
const loadTasks = async () => {
try {
const data = await loginApi.listTasks(batchId || undefined);
@@ -124,39 +175,103 @@ export default function LoginTasksPage() {
];
return (
<div>
<h2></h2>
<div style={{ height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}>
<h2 style={{ marginTop: 0, flexShrink: 0 }}></h2>
{canBatch && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space>
<Select
mode="multiple"
style={{ minWidth: 400 }}
placeholder="选择要登录的账号"
value={selectedIds}
onChange={setSelectedIds}
options={accounts.map((a) => ({ value: a.id, label: a.username }))}
maxTagCount="responsive"
/>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={loading}
onClick={handleBatchLogin}
>
</Button>
{batchId && (
<Button danger icon={<StopOutlined />} onClick={handleStop}>
</Button>
<Card size="small" style={{ marginBottom: 12, flexShrink: 0 }}>
<Space direction="vertical" style={{ width: '100%' }}>
{/* 标签快捷选择 */}
{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;
})()}
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}
</>
)}
/>
<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: 16 }}>
<Row gutter={16} style={{ marginBottom: 12, flexShrink: 0 }}>
<Col span={6}>
<Card size="small"><Statistic title="任务总数" value={tasks.length} /></Card>
</Col>
@@ -171,23 +286,35 @@ export default function LoginTasksPage() {
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Card title="任务列表" size="small">
<Row gutter={16} style={{ flex: 1, minHeight: 0 }}>
<Col span={14} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Card
title="任务列表"
size="small"
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
bodyStyle={{ flex: 1, overflow: 'auto', padding: 0 }}
>
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
size="small"
pagination={{ pageSize: 15 }}
pagination={{ pageSize: 15, size: 'small' }}
/>
</Card>
</Col>
<Col span={10}>
<Col span={10} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Card
title="实时日志"
size="small"
bodyStyle={{ maxHeight: 500, overflow: 'auto', fontFamily: 'monospace', fontSize: 12 }}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
bodyStyle={{
flex: 1,
overflow: 'auto',
fontFamily: 'monospace',
fontSize: 12,
padding: 12,
}}
>
{logs.length === 0 ? (
<Spin spinning={wsConnected} size="small" />
@@ -197,10 +324,10 @@ export default function LoginTasksPage() {
key={i}
style={{
color:
log.level === 'error' ? '#ff0000' :
log.level === 'success' ? '#008000' :
log.level === 'warning' ? '#FF8C00' :
'#333',
log.level === 'error' ? '#ff4d4f' :
log.level === 'success' ? '#52c41a' :
log.level === 'warning' ? '#fa8c16' :
'rgba(0,0,0,0.85)',
}}
>
{log.message}