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

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
+1
View File
@@ -15,3 +15,4 @@ slice.jpg
# 前端 # 前端
web/frontend/node_modules/ web/frontend/node_modules/
web/frontend/dist/ web/frontend/dist/
data/web.db
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
View File
@@ -29,9 +29,22 @@ def get_db():
def init_db(): def init_db():
"""建表 + 写入初始数据。""" """建表 + 写入初始数据。"""
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate()
_seed() _seed()
def _migrate():
"""数据库迁移:为已有表添加新列。"""
from sqlalchemy import text
with engine.connect() as conn:
# 检查 accounts.tag 列是否存在
result = conn.execute(text("PRAGMA table_info(accounts)"))
columns = [row[1] for row in result]
if 'tag' not in columns:
conn.execute(text("ALTER TABLE accounts ADD COLUMN tag VARCHAR(64) DEFAULT ''"))
conn.commit()
def _seed(): def _seed():
"""写入默认超管账号和角色。""" """写入默认超管账号和角色。"""
from .models import User from .models import User
+1
View File
@@ -37,6 +37,7 @@ class Account(Base):
email_imap_server = Column(String(128), default="") email_imap_server = Column(String(128), default="")
email_imap_port = Column(Integer, default=993) email_imap_port = Column(Integer, default=993)
assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
tag = Column(String(64), default="")
remark = Column(String(256), default="") remark = Column(String(256), default="")
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
+54 -4
View File
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..models import User, Account, AuditLog from ..models import User, Account, AuditLog
from ..schemas import AccountImport, AccountAssign, AccountOut from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut
from ..deps import get_current_user, require_permission from ..deps import get_current_user, require_permission
from ..permissions import has_permission from ..permissions import has_permission
@@ -29,6 +29,7 @@ def _split_account_line(line: str) -> list[str]:
@router.get("", response_model=list[AccountOut]) @router.get("", response_model=list[AccountOut])
def list_accounts( def list_accounts(
assigned_only: bool = Query(False), assigned_only: bool = Query(False),
tag: str = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current: User = Depends(get_current_user), current: User = Depends(get_current_user),
): ):
@@ -45,11 +46,15 @@ def list_accounts(
if assigned_only and has_permission(current.role, "account:view_all"): if assigned_only and has_permission(current.role, "account:view_all"):
query = query.filter(Account.assigned_to.isnot(None)) query = query.filter(Account.assigned_to.isnot(None))
if tag:
query = query.filter(Account.tag == tag)
accounts = query.order_by(Account.id).all() accounts = query.order_by(Account.id).all()
result = [] result = []
for acc in accounts: for acc in accounts:
item = AccountOut( item = AccountOut(
id=acc.id, username=acc.username, remark=acc.remark or "", id=acc.id, username=acc.username, remark=acc.remark or "",
tag=acc.tag or "",
assigned_to=acc.assigned_to, assigned_to=acc.assigned_to,
assigned_username=acc.assigned_user.username if acc.assigned_user else None, assigned_username=acc.assigned_user.username if acc.assigned_user else None,
created_at=acc.created_at, created_at=acc.created_at,
@@ -69,7 +74,7 @@ def import_accounts(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current: User = Depends(require_permission("account:import")), current: User = Depends(require_permission("account:import")),
): ):
"""批量导入账号。格式:用户名|密码|邮箱|邮箱密码""" """批量导入账号。格式:用户名|密码|邮箱|邮箱密码|标签(可选)"""
from core.douyu.email_verifier import get_email_config_for_account from core.douyu.email_verifier import get_email_config_for_account
accounts = [] accounts = []
@@ -79,11 +84,12 @@ def import_accounts(
if not line or line.startswith('#'): if not line or line.startswith('#'):
continue continue
parts = _split_account_line(line) parts = _split_account_line(line)
if len(parts) != 4: if len(parts) < 4:
skipped += 1 skipped += 1
continue continue
username, password, email, email_password = [p.strip() for p in parts] username, password, email, email_password = [p.strip() for p in parts[:4]]
tag = parts[4].strip() if len(parts) > 4 else ""
if not all([username, password, email, email_password]): if not all([username, password, email, email_password]):
skipped += 1 skipped += 1
continue continue
@@ -99,6 +105,7 @@ def import_accounts(
email_password=email_password, email_password=email_password,
email_imap_server=email_cfg['server'], email_imap_server=email_cfg['server'],
email_imap_port=email_cfg.get('port', 993), email_imap_port=email_cfg.get('port', 993),
tag=tag,
)) ))
if accounts: if accounts:
@@ -135,6 +142,49 @@ def assign_account(
return {"message": "已分配", "success": True} return {"message": "已分配", "success": True}
@router.put("/{account_id}/tag")
def set_account_tag(
account_id: int,
req: AccountTag,
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:import")),
):
"""设置单个账号标签。"""
acc = db.query(Account).filter(Account.id == account_id).first()
if not acc:
raise HTTPException(status_code=404, detail="账号不存在")
acc.tag = (req.tag or "").strip()
db.commit()
return {"message": "标签已更新", "success": True}
@router.put("/batch-tag")
def batch_tag(
req: AccountTag,
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:import")),
):
"""批量设置账号标签。"""
if not req.account_ids:
raise HTTPException(status_code=400, detail="请选择账号")
tag = (req.tag or "").strip()
count = db.query(Account).filter(Account.id.in_(req.account_ids)).update(
{Account.tag: tag}, synchronize_session=False
)
db.commit()
return {"message": f"已为 {count} 个账号设置标签", "success": True}
@router.get("/tags/list")
def list_tags(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""获取所有标签列表。"""
tags = db.query(Account.tag).filter(Account.tag != "", Account.tag.isnot(None)).distinct().all()
return [t[0] for t in tags if t[0]]
@router.delete("/{account_id}") @router.delete("/{account_id}")
def delete_account( def delete_account(
account_id: int, account_id: int,
+6
View File
@@ -57,6 +57,11 @@ class AccountAssign(BaseModel):
assigned_to: Optional[int] = None assigned_to: Optional[int] = None
class AccountTag(BaseModel):
tag: Optional[str] = None
account_ids: Optional[list[int]] = None
class AccountOut(BaseModel): class AccountOut(BaseModel):
id: int id: int
username: str username: str
@@ -64,6 +69,7 @@ class AccountOut(BaseModel):
password: Optional[str] = None password: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
email_password: Optional[str] = None email_password: Optional[str] = None
tag: str = ""
assigned_to: Optional[int] = None assigned_to: Optional[int] = None
assigned_username: Optional[str] = None assigned_username: Optional[str] = None
remark: str = "" remark: str = ""
+7 -2
View File
@@ -36,11 +36,16 @@ export const userApi = {
}; };
export const accountApi = { export const accountApi = {
list: (assigned_only?: boolean) => list: (params?: { assigned_only?: boolean; tag?: string }) =>
api.get<any, any[]>('/accounts', { params: assigned_only ? { assigned_only: true } : {} }), api.get<any, any[]>('/accounts', { params }),
import: (text: string) => api.post<any, any>('/accounts/import', { text }), import: (text: string) => api.post<any, any>('/accounts/import', { text }),
assign: (id: number, assigned_to: number | null) => assign: (id: number, assigned_to: number | null) =>
api.put<any, any>(`/accounts/${id}/assign`, { assigned_to }), api.put<any, any>(`/accounts/${id}/assign`, { assigned_to }),
setTag: (id: number, tag: string) =>
api.put<any, any>(`/accounts/${id}/tag`, { tag }),
batchTag: (account_ids: number[], tag: string) =>
api.put<any, any>('/accounts/batch-tag', { account_ids, tag }),
listTags: () => api.get<any, string[]>('/accounts/tags/list'),
delete: (id: number) => api.delete<any, any>(`/accounts/${id}`), delete: (id: number) => api.delete<any, any>(`/accounts/${id}`),
}; };
+185 -6
View File
@@ -1,21 +1,31 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { import {
Table, Button, Modal, Input, Select, message, Popconfirm, Typography, Table, Button, Modal, Input, Select, message, Popconfirm, Typography, Tag, Space,
Row, Col, Card, Statistic,
} from 'antd'; } from 'antd';
import { ImportOutlined, DeleteOutlined } from '@ant-design/icons'; import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined } from '@ant-design/icons';
import { accountApi, userApi } from '../api/modules'; import { accountApi, userApi } from '../api/modules';
import { getUser, hasPerm } from '../store/auth'; import { getUser, hasPerm } from '../store/auth';
const { TextArea } = Input; const { TextArea } = Input;
const { Text } = Typography; const { Text } = Typography;
const TAG_COLORS = [
'blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano',
];
export default function AccountsPage() { export default function AccountsPage() {
const [accounts, setAccounts] = useState<any[]>([]); const [accounts, setAccounts] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [importText, setImportText] = useState(''); const [importText, setImportText] = useState('');
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [tagFilter, setTagFilter] = useState<string>('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchTagInput, setBatchTagInput] = useState('');
const [batchTagVisible, setBatchTagVisible] = useState(false);
const user = getUser(); const user = getUser();
const canViewAll = hasPerm(user, 'account:view_all'); const canViewAll = hasPerm(user, 'account:view_all');
@@ -26,7 +36,9 @@ export default function AccountsPage() {
const loadAccounts = async () => { const loadAccounts = async () => {
setLoading(true); setLoading(true);
try { try {
const data = await accountApi.list(); const params: any = {};
if (tagFilter) params.tag = tagFilter;
const data = await accountApi.list(params);
setAccounts(data); setAccounts(data);
} catch (e: any) { } catch (e: any) {
message.error(e.message); message.error(e.message);
@@ -42,11 +54,31 @@ export default function AccountsPage() {
} catch {} } catch {}
}; };
const loadTags = async () => {
try {
const data = await accountApi.listTags();
setTags(data);
} catch {}
};
useEffect(() => { useEffect(() => {
loadAccounts(); loadAccounts();
if (canAssign) loadUsers(); if (canAssign) loadUsers();
loadTags();
}, []); }, []);
useEffect(() => {
loadAccounts();
}, [tagFilter]);
const tagColorMap = useMemo(() => {
const map: Record<string, string> = {};
tags.forEach((t, i) => {
map[t] = TAG_COLORS[i % TAG_COLORS.length];
});
return map;
}, [tags]);
const handleImport = async () => { const handleImport = async () => {
if (!importText.trim()) { if (!importText.trim()) {
message.warning('请输入账号数据'); message.warning('请输入账号数据');
@@ -59,6 +91,7 @@ export default function AccountsPage() {
setImportOpen(false); setImportOpen(false);
setImportText(''); setImportText('');
loadAccounts(); loadAccounts();
loadTags();
} catch (e: any) { } catch (e: any) {
message.error(e.message); message.error(e.message);
} finally { } finally {
@@ -76,11 +109,42 @@ export default function AccountsPage() {
} }
}; };
const handleSetTag = async (accountId: number, tag: string) => {
try {
await accountApi.setTag(accountId, tag);
message.success('标签已更新');
loadAccounts();
loadTags();
} catch (e: any) {
message.error(e.message);
}
};
const handleBatchTag = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择账号');
return;
}
try {
await accountApi.batchTag(selectedRowKeys as number[], batchTagInput);
message.success(`已为 ${selectedRowKeys.length} 个账号设置标签`);
setBatchTagVisible(false);
setBatchTagInput('');
setSelectedRowKeys([]);
loadAccounts();
loadTags();
} catch (e: any) {
message.error(e.message);
}
};
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
await accountApi.delete(id); await accountApi.delete(id);
message.success('已删除'); message.success('已删除');
setSelectedRowKeys((prev) => prev.filter((k) => k !== id));
loadAccounts(); loadAccounts();
loadTags();
} catch (e: any) { } catch (e: any) {
message.error(e.message); message.error(e.message);
} }
@@ -89,6 +153,48 @@ export default function AccountsPage() {
const columns: any[] = [ const columns: any[] = [
{ title: 'ID', dataIndex: 'id', width: 60 }, { title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username' }, { title: '用户名', dataIndex: 'username' },
{
title: '标签',
dataIndex: 'tag',
width: 120,
render: (tag: string, record: any) => {
if (!tag) {
if (canImport) {
return (
<Input
size="small"
placeholder="输入标签"
style={{ width: 90 }}
onPressEnter={(e) => {
const val = (e.target as HTMLInputElement).value.trim();
if (val) handleSetTag(record.id, val);
}}
onBlur={(e) => {
const val = e.target.value.trim();
if (val) handleSetTag(record.id, val);
}}
/>
);
}
return <Text type="secondary">-</Text>;
}
if (canImport) {
return (
<Tag
color={tagColorMap[tag]}
closable
onClose={(e) => {
e.preventDefault();
handleSetTag(record.id, '');
}}
>
{tag}
</Tag>
);
}
return <Tag color={tagColorMap[tag]}>{tag}</Tag>;
},
},
]; ];
if (canViewAll) { if (canViewAll) {
@@ -135,13 +241,66 @@ export default function AccountsPage() {
<div> <div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}> <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2> <h2></h2>
<Space>
<Select
allowClear
placeholder="按标签筛选"
style={{ width: 150 }}
value={tagFilter || undefined}
onChange={(val) => setTagFilter(val || '')}
options={tags.map((t) => ({ value: t, label: t }))}
prefix={<FilterOutlined />}
/>
{canImport && ( {canImport && (
<>
<Button
disabled={selectedRowKeys.length === 0}
icon={<TagOutlined />}
onClick={() => {
setBatchTagInput('');
setBatchTagVisible(true);
}}
>
</Button>
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}> <Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
</Button> </Button>
</>
)} )}
</Space>
</div> </div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card size="small"><Statistic title="账号总数" value={accounts.length} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="标签数" value={tags.length} /></Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="已分配"
value={accounts.filter((a) => a.assigned_to).length}
/>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="未分配"
value={accounts.filter((a) => !a.assigned_to).length}
/>
</Card>
</Col>
</Row>
<Table <Table
rowSelection={canImport ? {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
} : undefined}
columns={columns} columns={columns}
dataSource={accounts} dataSource={accounts}
rowKey="id" rowKey="id"
@@ -149,6 +308,7 @@ export default function AccountsPage() {
size="small" size="small"
pagination={{ pageSize: 20 }} pagination={{ pageSize: 20 }}
/> />
<Modal <Modal
title="批量导入账号" title="批量导入账号"
open={importOpen} open={importOpen}
@@ -159,16 +319,35 @@ export default function AccountsPage() {
width={600} width={600}
> >
<Text type="secondary"> <Text type="secondary">
||| ||||
</Text> </Text>
<TextArea <TextArea
rows={10} rows={10}
value={importText} value={importText}
onChange={(e) => setImportText(e.target.value)} onChange={(e) => setImportText(e.target.value)}
placeholder="用户名|密码|邮箱|邮箱密码&#10;用户名|密码|邮箱|邮箱密码" placeholder={`用户名|密码|邮箱|邮箱密码|标签\n用户名|密码|邮箱|邮箱密码|标签`}
style={{ marginTop: 8 }} style={{ marginTop: 8 }}
/> />
</Modal> </Modal>
<Modal
title="批量设置标签"
open={batchTagVisible}
onCancel={() => setBatchTagVisible(false)}
onOk={handleBatchTag}
okText="确定"
width={400}
>
<p> {selectedRowKeys.length} </p>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="输入或选择标签"
value={batchTagInput ? [batchTagInput] : []}
onChange={(vals) => setBatchTagInput(vals.length > 0 ? vals[vals.length - 1] : '')}
options={tags.map((t) => ({ value: t, label: t }))}
/>
</Modal>
</div> </div>
); );
} }
+145 -18
View File
@@ -1,8 +1,8 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { import {
Table, Button, Select, message, Tag, Space, Card, Row, Col, Statistic, Spin, Table, Button, Select, message, Tag, Space, Card, Row, Col, Statistic, Spin,
} from 'antd'; } from 'antd';
import { PlayCircleOutlined, StopOutlined } from '@ant-design/icons'; import { PlayCircleOutlined, StopOutlined, FilterOutlined } from '@ant-design/icons';
import { accountApi, loginApi } from '../api/modules'; import { accountApi, loginApi } from '../api/modules';
import { getUser, hasPerm } from '../store/auth'; import { getUser, hasPerm } from '../store/auth';
@@ -30,11 +30,31 @@ export default function LoginTasksPage() {
const [batchId, setBatchId] = useState<string | null>(null); const [batchId, setBatchId] = useState<string | null>(null);
const [logs, setLogs] = useState<{ level: string; message: string }[]>([]); const [logs, setLogs] = useState<{ level: string; message: string }[]>([]);
const [wsConnected, setWsConnected] = useState(false); const [wsConnected, setWsConnected] = useState(false);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const user = getUser(); const user = getUser();
const canBatch = hasPerm(user, 'login:batch'); 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 () => { const loadAccounts = async () => {
try { try {
const data = await accountApi.list(); 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 () => { const loadTasks = async () => {
try { try {
const data = await loginApi.listTasks(batchId || undefined); const data = await loginApi.listTasks(batchId || undefined);
@@ -124,26 +175,89 @@ export default function LoginTasksPage() {
]; ];
return ( return (
<div> <div style={{ height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}>
<h2></h2> <h2 style={{ marginTop: 0, flexShrink: 0 }}></h2>
{canBatch && ( {canBatch && (
<Card size="small" style={{ marginBottom: 16 }}> <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> <Space>
<Select <Select
mode="multiple" mode="multiple"
style={{ minWidth: 400 }} style={{ minWidth: 400 }}
placeholder="选择要登录的账号" placeholder="输入关键词筛选账号"
value={selectedIds} value={selectedIds}
onChange={setSelectedIds} onChange={setSelectedIds}
options={accounts.map((a) => ({ value: a.id, label: a.username }))} 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" 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 <Button
type="primary" type="primary"
icon={<PlayCircleOutlined />} icon={<PlayCircleOutlined />}
loading={loading} loading={loading}
onClick={handleBatchLogin} onClick={handleBatchLogin}
disabled={selectedIds.length === 0}
> >
</Button> </Button>
@@ -153,10 +267,11 @@ export default function LoginTasksPage() {
</Button> </Button>
)} )}
</Space> </Space>
</Space>
</Card> </Card>
)} )}
<Row gutter={16} style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 12, flexShrink: 0 }}>
<Col span={6}> <Col span={6}>
<Card size="small"><Statistic title="任务总数" value={tasks.length} /></Card> <Card size="small"><Statistic title="任务总数" value={tasks.length} /></Card>
</Col> </Col>
@@ -171,23 +286,35 @@ export default function LoginTasksPage() {
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16} style={{ flex: 1, minHeight: 0 }}>
<Col span={14}> <Col span={14} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Card title="任务列表" size="small"> <Card
title="任务列表"
size="small"
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
bodyStyle={{ flex: 1, overflow: 'auto', padding: 0 }}
>
<Table <Table
columns={columns} columns={columns}
dataSource={tasks} dataSource={tasks}
rowKey="id" rowKey="id"
size="small" size="small"
pagination={{ pageSize: 15 }} pagination={{ pageSize: 15, size: 'small' }}
/> />
</Card> </Card>
</Col> </Col>
<Col span={10}> <Col span={10} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Card <Card
title="实时日志" title="实时日志"
size="small" 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 ? ( {logs.length === 0 ? (
<Spin spinning={wsConnected} size="small" /> <Spin spinning={wsConnected} size="small" />
@@ -197,10 +324,10 @@ export default function LoginTasksPage() {
key={i} key={i}
style={{ style={{
color: color:
log.level === 'error' ? '#ff0000' : log.level === 'error' ? '#ff4d4f' :
log.level === 'success' ? '#008000' : log.level === 'success' ? '#52c41a' :
log.level === 'warning' ? '#FF8C00' : log.level === 'warning' ? '#fa8c16' :
'#333', 'rgba(0,0,0,0.85)',
}} }}
> >
{log.message} {log.message}