优化登录任务界面 + 修复时间显示 + 增加删除功能
- 筛选栏改为单行横排,概览精简为一行文字 - 任务列表全宽展示,日志移到底部可折叠 - 页面使用百分比布局,一屏展示无外层滚动 - 登录任务增加单条删除和批量删除功能 - 后端: 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
+11
-6
@@ -1,6 +1,6 @@
|
|||||||
"""ORM 模型"""
|
"""ORM 模型"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON,
|
Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON,
|
||||||
)
|
)
|
||||||
@@ -8,6 +8,11 @@ from sqlalchemy.orm import relationship
|
|||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
"""返回时区感知的 UTC 当前时间,替代 _utcnow()。"""
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""系统用户"""
|
"""系统用户"""
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
@@ -19,8 +24,8 @@ class User(Base):
|
|||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
remark = Column(String(256), default="")
|
remark = Column(String(256), default="")
|
||||||
custom_permissions = Column(JSON, nullable=True, comment="自定义权限列表,null表示使用角色默认权限")
|
custom_permissions = Column(JSON, nullable=True, comment="自定义权限列表,null表示使用角色默认权限")
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
# 客服被分配的账号
|
# 客服被分配的账号
|
||||||
assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to")
|
assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to")
|
||||||
@@ -40,7 +45,7 @@ class Account(Base):
|
|||||||
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="")
|
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=_utcnow)
|
||||||
|
|
||||||
assigned_user = relationship("User", back_populates="assigned_accounts", foreign_keys=[assigned_to])
|
assigned_user = relationship("User", back_populates="assigned_accounts", foreign_keys=[assigned_to])
|
||||||
login_tasks = relationship("LoginTask", back_populates="account")
|
login_tasks = relationship("LoginTask", back_populates="account")
|
||||||
@@ -57,7 +62,7 @@ class LoginTask(Base):
|
|||||||
cookie = Column(Text, default="")
|
cookie = Column(Text, default="")
|
||||||
message = Column(String(512), default="")
|
message = Column(String(512), default="")
|
||||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
finished_at = Column(DateTime, nullable=True)
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
account = relationship("Account", back_populates="login_tasks")
|
account = relationship("Account", back_populates="login_tasks")
|
||||||
@@ -88,4 +93,4 @@ class AuditLog(Base):
|
|||||||
action = Column(String(128), nullable=False)
|
action = Column(String(128), nullable=False)
|
||||||
target = Column(String(256), default="")
|
target = Column(String(256), default="")
|
||||||
detail = Column(Text, default="")
|
detail = Column(Text, default="")
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Cookie 管理路由"""
|
"""Cookie 管理路由"""
|
||||||
|
|
||||||
|
from datetime import timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -11,6 +12,15 @@ from ..models import User, LoginTask, Account
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_dt(dt) -> str | None:
|
||||||
|
"""将 datetime 格式化为带时区的 ISO 字符串。"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.isoformat()
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
|
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +49,7 @@ def list_cookies(
|
|||||||
"account_username": acc.username if acc else "",
|
"account_username": acc.username if acc else "",
|
||||||
"assigned_to": acc.assigned_to,
|
"assigned_to": acc.assigned_to,
|
||||||
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None,
|
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None,
|
||||||
"created_at": t.finished_at.isoformat() if t.finished_at else None,
|
"created_at": _fmt_dt(t.finished_at),
|
||||||
}
|
}
|
||||||
# 只有有 cookie:view 权限才返回 cookie 内容
|
# 只有有 cookie:view 权限才返回 cookie 内容
|
||||||
if has_permission(current.role, "cookie:view"):
|
if has_permission(current.role, "cookie:view"):
|
||||||
@@ -70,7 +80,7 @@ def export_cookies(
|
|||||||
for t in tasks:
|
for t in tasks:
|
||||||
acc = db.query(Account).filter(Account.id == t.account_id).first()
|
acc = db.query(Account).filter(Account.id == t.account_id).first()
|
||||||
username = acc.username if acc else ""
|
username = acc.username if acc else ""
|
||||||
writer.writerow([username, t.cookie or "", t.finished_at.isoformat() if t.finished_at else ""])
|
writer.writerow([username, t.cookie or "", _fmt_dt(t.finished_at) or ""])
|
||||||
|
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -111,6 +111,38 @@ def list_tasks(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{task_id}")
|
||||||
|
def delete_task(
|
||||||
|
task_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("login:batch")),
|
||||||
|
):
|
||||||
|
"""删除单个登录任务。"""
|
||||||
|
task = db.query(LoginTask).filter(LoginTask.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
db.delete(task)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "已删除", "success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks")
|
||||||
|
def delete_tasks(
|
||||||
|
task_ids: str = "",
|
||||||
|
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()]
|
||||||
|
if not ids:
|
||||||
|
raise HTTPException(status_code=400, detail="无效的任务ID")
|
||||||
|
deleted = db.query(LoginTask).filter(LoginTask.id.in_(ids)).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
return {"message": f"已删除 {deleted} 个任务", "deleted": deleted, "success": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop/{batch_id}")
|
@router.post("/stop/{batch_id}")
|
||||||
def stop_batch(
|
def stop_batch(
|
||||||
batch_id: str,
|
batch_id: str,
|
||||||
|
|||||||
+58
-9
@@ -1,8 +1,17 @@
|
|||||||
"""Pydantic 请求/响应模型"""
|
"""Pydantic 请求/响应模型"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional, Any
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, ConfigDict, model_serializer
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_tz(dt: Optional[datetime]) -> Optional[datetime]:
|
||||||
|
"""确保 datetime 带有 UTC 时区信息,无时区的视为 UTC。"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
# ---- 认证 ----
|
# ---- 认证 ----
|
||||||
@@ -29,8 +38,20 @@ class UserInfo(BaseModel):
|
|||||||
permissions: list[str] = []
|
permissions: list[str] = []
|
||||||
custom_permissions: Optional[list[str]] = None
|
custom_permissions: Optional[list[str]] = None
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
|
@model_serializer
|
||||||
|
def _serialize(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"username": self.username,
|
||||||
|
"role": self.role,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"remark": self.remark,
|
||||||
|
"created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None,
|
||||||
|
"permissions": self.permissions,
|
||||||
|
"custom_permissions": self.custom_permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---- 用户管理 ----
|
# ---- 用户管理 ----
|
||||||
@@ -82,8 +103,22 @@ class AccountOut(BaseModel):
|
|||||||
remark: str = ""
|
remark: str = ""
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
|
@model_serializer
|
||||||
|
def _serialize(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"username": self.username,
|
||||||
|
"password": self.password,
|
||||||
|
"email": self.email,
|
||||||
|
"email_password": self.email_password,
|
||||||
|
"tag": self.tag,
|
||||||
|
"assigned_to": self.assigned_to,
|
||||||
|
"assigned_username": self.assigned_username,
|
||||||
|
"remark": self.remark,
|
||||||
|
"created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---- 登录任务 ----
|
# ---- 登录任务 ----
|
||||||
@@ -105,8 +140,22 @@ class LoginTaskOut(BaseModel):
|
|||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
finished_at: Optional[datetime] = None
|
finished_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
|
@model_serializer
|
||||||
|
def _serialize(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"batch_id": self.batch_id,
|
||||||
|
"account_id": self.account_id,
|
||||||
|
"account_username": self.account_username,
|
||||||
|
"status": self.status,
|
||||||
|
"cookie": self.cookie,
|
||||||
|
"message": self.message,
|
||||||
|
"created_by": self.created_by,
|
||||||
|
"created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None,
|
||||||
|
"finished_at": _ensure_tz(self.finished_at).isoformat() if self.finished_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---- 代理配置 ----
|
# ---- 代理配置 ----
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -144,7 +144,7 @@ class LoginBatchRunner:
|
|||||||
task.message = str(e)
|
task.message = str(e)
|
||||||
self._push_log("error", f"[{current}] {acc_info['username']} 登录异常: {e}")
|
self._push_log("error", f"[{current}] {acc_info['username']} 登录异常: {e}")
|
||||||
|
|
||||||
task.finished_at = datetime.utcnow()
|
task.finished_at = datetime.now(timezone.utc)
|
||||||
worker_db.commit()
|
worker_db.commit()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
@@ -211,7 +211,7 @@ class LoginBatchRunner:
|
|||||||
if task:
|
if task:
|
||||||
task.status = "error"
|
task.status = "error"
|
||||||
task.message = f"代理不可用: {proxy_msg}"
|
task.message = f"代理不可用: {proxy_msg}"
|
||||||
task.finished_at = datetime.utcnow()
|
task.finished_at = datetime.now(timezone.utc)
|
||||||
worker_db.commit()
|
worker_db.commit()
|
||||||
finally:
|
finally:
|
||||||
worker_db.close()
|
worker_db.close()
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export const loginApi = {
|
|||||||
listTasks: (batch_id?: string) =>
|
listTasks: (batch_id?: string) =>
|
||||||
api.get<any, any[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
|
api.get<any, any[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
|
||||||
stop: (batch_id: string) => api.post<any, any>(`/login/stop/${batch_id}`),
|
stop: (batch_id: string) => api.post<any, any>(`/login/stop/${batch_id}`),
|
||||||
|
deleteTask: (id: number) => api.delete<any, any>(`/login/tasks/${id}`),
|
||||||
|
deleteTasks: (ids: number[]) => api.delete<any, any>(`/login/tasks`, { params: { task_ids: ids.join(',') } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cookieApi = {
|
export const cookieApi = {
|
||||||
|
|||||||
@@ -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 { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
import { cookieApi } from '../api/modules';
|
import { cookieApi } from '../api/modules';
|
||||||
import { getUser, hasPerm } from '../store/auth';
|
import { getUser, hasPerm } from '../store/auth';
|
||||||
|
import { formatTime } from '../utils/time';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ export default function CookiePage() {
|
|||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
width: 180,
|
width: 180,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (val: string) => val ? val.replace('T', ' ').slice(0, 19) : '-',
|
render: (val: string) => formatTime(val),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||||
import {
|
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';
|
} 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 { accountApi, loginApi } from '../api/modules';
|
||||||
import { getUser, hasPerm } from '../store/auth';
|
import { getUser, hasPerm } from '../store/auth';
|
||||||
|
import { formatTime } from '../utils/time';
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
pending: 'default',
|
pending: 'default',
|
||||||
@@ -32,7 +33,10 @@ export default function LoginTasksPage() {
|
|||||||
const [wsConnected, setWsConnected] = useState(false);
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [concurrency, setConcurrency] = useState(3);
|
const [concurrency, setConcurrency] = useState(3);
|
||||||
|
const [logVisible, setLogVisible] = useState(true);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const logEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
const user = getUser();
|
const user = getUser();
|
||||||
|
|
||||||
const canBatch = hasPerm(user, 'login:batch');
|
const canBatch = hasPerm(user, 'login:batch');
|
||||||
@@ -107,6 +111,13 @@ export default function LoginTasksPage() {
|
|||||||
loadAccounts();
|
loadAccounts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 日志自动滚动到底部
|
||||||
|
useEffect(() => {
|
||||||
|
if (logVisible && logEndRef.current) {
|
||||||
|
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [logs, logVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(loadTasks, 3000);
|
const timer = setInterval(loadTasks, 3000);
|
||||||
return () => clearInterval(timer);
|
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 successCount = tasks.filter((t) => t.status === 'success').length;
|
||||||
const failedCount = tasks.filter((t) => ['failed', 'error'].includes(t.status)).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>,
|
render: (status: string) => <Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status] || status}</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '消息', dataIndex: 'message', ellipsis: true },
|
{ title: '消息', dataIndex: 'message', ellipsis: true },
|
||||||
{ title: '时间', dataIndex: 'created_at', width: 180 },
|
{ title: '时间', dataIndex: 'created_at', width: 180, render: (val: string) => formatTime(val) },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 120,
|
||||||
render: (_: any, record: any) => {
|
render: (_: any, record: any) => (
|
||||||
if (['failed', 'error'].includes(record.status) && !wsConnected) {
|
<Space size={4}>
|
||||||
return (
|
{['failed', 'error'].includes(record.status) && !wsConnected && (
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -210,47 +247,40 @@ export default function LoginTasksPage() {
|
|||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
</Button>
|
</Button>
|
||||||
);
|
)}
|
||||||
}
|
<Popconfirm title="确定删除此任务?" onConfirm={() => handleDeleteTask(record.id)} okText="删除" cancelText="取消">
|
||||||
return null;
|
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||||
},
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<h2 style={{ marginTop: 0, flexShrink: 0 }}>登录任务</h2>
|
{/* 标题 + 筛选栏 */}
|
||||||
|
<div style={{ flexShrink: 0, paddingBottom: 8, borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
<h2 style={{ marginTop: 0, marginBottom: 6 }}>登录任务</h2>
|
||||||
{canBatch && (
|
{canBatch && (
|
||||||
<Card size="small" style={{ marginBottom: 12, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
|
||||||
{/* 标签快捷选择 */}
|
|
||||||
{allTags.length > 0 && (
|
{allTags.length > 0 && (
|
||||||
<Space>
|
|
||||||
<FilterOutlined />
|
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
style={{ minWidth: 300 }}
|
style={{ minWidth: 200, maxWidth: 300 }}
|
||||||
placeholder="选择标签快速筛选账号"
|
placeholder="按标签筛选"
|
||||||
value={selectedTags}
|
value={selectedTags}
|
||||||
onChange={handleTagChange}
|
onChange={handleTagChange}
|
||||||
options={allTags.map((t) => ({ value: t, label: t }))}
|
options={allTags.map((t) => ({ value: t, label: t }))}
|
||||||
maxTagCount="responsive"
|
maxTagCount="responsive"
|
||||||
allowClear
|
allowClear
|
||||||
|
size="small"
|
||||||
|
suffixIcon={<FilterOutlined />}
|
||||||
/>
|
/>
|
||||||
{selectedTags.length > 0 && (
|
|
||||||
<span style={{ color: '#888', fontSize: 12 }}>
|
|
||||||
已通过标签选中 {accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length} 个账号
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
{/* 账号详情选择 */}
|
|
||||||
<Space>
|
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
style={{ minWidth: 400 }}
|
style={{ minWidth: 280, flex: 1, maxWidth: 500 }}
|
||||||
placeholder="输入关键词筛选账号"
|
placeholder="选择账号"
|
||||||
value={selectedIds}
|
value={selectedIds}
|
||||||
onChange={setSelectedIds}
|
onChange={setSelectedIds}
|
||||||
options={(() => {
|
options={(() => {
|
||||||
@@ -276,6 +306,7 @@ export default function LoginTasksPage() {
|
|||||||
})()}
|
})()}
|
||||||
maxTagCount="responsive"
|
maxTagCount="responsive"
|
||||||
showSearch
|
showSearch
|
||||||
|
size="small"
|
||||||
filterOption={(input, option) => {
|
filterOption={(input, option) => {
|
||||||
if (!option) return false;
|
if (!option) return false;
|
||||||
const label = (option as any).label as string || '';
|
const label = (option as any).label as string || '';
|
||||||
@@ -295,60 +326,62 @@ export default function LoginTasksPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{selectedTags.length > 0 && (
|
||||||
|
<span style={{ color: '#888', fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||||
|
标签选中 {accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length} 个
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Tooltip title="同时登录的账号数,1为顺序执行">
|
<Tooltip title="同时登录的账号数,1为顺序执行">
|
||||||
|
<Space size={4}>
|
||||||
<ThunderboltOutlined style={{ color: '#888' }} />
|
<ThunderboltOutlined style={{ color: '#888' }} />
|
||||||
</Tooltip>
|
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
value={concurrency}
|
value={concurrency}
|
||||||
onChange={(v) => setConcurrency(v || 1)}
|
onChange={(v) => setConcurrency(v || 1)}
|
||||||
style={{ width: 60 }}
|
style={{ width: 50 }}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
</Space>
|
||||||
|
</Tooltip>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlayCircleOutlined />}
|
icon={<PlayCircleOutlined />}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onClick={handleBatchLogin}
|
onClick={handleBatchLogin}
|
||||||
disabled={selectedIds.length === 0}
|
disabled={selectedIds.length === 0}
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
开始登录
|
开始登录
|
||||||
</Button>
|
</Button>
|
||||||
{batchId && (
|
{batchId && (
|
||||||
<Button danger icon={<StopOutlined />} onClick={handleStop}>
|
<Button danger icon={<StopOutlined />} onClick={handleStop} size="small">
|
||||||
停止
|
停止
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</div>
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Row gutter={16} style={{ marginBottom: 12, flexShrink: 0 }}>
|
{/* 概览 + 任务列表区域 */}
|
||||||
<Col span={6}>
|
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', paddingTop: 6 }}>
|
||||||
<Card size="small"><Statistic title="任务总数" value={tasks.length} /></Card>
|
{/* 概览 */}
|
||||||
</Col>
|
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 16, fontSize: 13, color: '#666', padding: '4px 0' }}>
|
||||||
<Col span={6}>
|
<span>共 <b>{tasks.length}</b> 个任务</span>
|
||||||
<Card size="small"><Statistic title="成功" value={successCount} valueStyle={{ color: '#3f8600' }} /></Card>
|
<span>成功 <b style={{ color: '#3f8600' }}>{successCount}</b></span>
|
||||||
</Col>
|
<span>失败 <b style={{ color: '#cf1322' }}>{failedCount}</b></span>
|
||||||
<Col span={6}>
|
{batchId && <span>批次: <b>{batchId}</b></span>}
|
||||||
<Card size="small"><Statistic title="失败" value={failedCount} valueStyle={{ color: '#cf1322' }} /></Card>
|
<div style={{ flex: 1 }} />
|
||||||
</Col>
|
{selectedRowKeys.length > 0 && (
|
||||||
<Col span={6}>
|
<Popconfirm title={`确定删除选中的 ${selectedRowKeys.length} 个任务?`} onConfirm={handleDeleteSelected} okText="删除" cancelText="取消">
|
||||||
<Card size="small"><Statistic title="当前批次" value={batchId || '-'} /></Card>
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||||
</Col>
|
删除选中 ({selectedRowKeys.length})
|
||||||
</Row>
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
<Row gutter={16} style={{ flex: 1, minHeight: 0 }}>
|
)}
|
||||||
<Col span={14} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
{!wsConnected && failedCount > 0 && (
|
||||||
<Card
|
|
||||||
title="任务列表"
|
|
||||||
size="small"
|
|
||||||
extra={
|
|
||||||
!wsConnected && failedCount > 0 && (
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
onClick={handleRetryFailed}
|
onClick={handleRetryFailed}
|
||||||
@@ -356,31 +389,47 @@ export default function LoginTasksPage() {
|
|||||||
>
|
>
|
||||||
重试失败 ({failedCount})
|
重试失败 ({failedCount})
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)}
|
||||||
}
|
</div>
|
||||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
|
||||||
bodyStyle={{ flex: 1, overflow: 'auto', padding: 0 }}
|
{/* 任务列表 - flex:1 占满剩余空间,内部滚动 */}
|
||||||
>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={tasks}
|
dataSource={tasks}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
pagination={{ pageSize: 15, size: 'small' }}
|
pagination={false}
|
||||||
|
sticky
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</div>
|
||||||
</Col>
|
</div>
|
||||||
<Col span={10} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<Card
|
{/* 实时日志 - 底部可折叠 */}
|
||||||
title="实时日志"
|
<div style={{ flexShrink: 0, borderTop: '1px solid #f0f0f0', marginTop: 4 }}>
|
||||||
size="small"
|
<div
|
||||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '4px 0', userSelect: 'none' }}
|
||||||
bodyStyle={{
|
onClick={() => setLogVisible((v) => !v)}
|
||||||
flex: 1,
|
>
|
||||||
|
<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',
|
overflow: 'auto',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
padding: 12,
|
padding: 4,
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
borderRadius: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
@@ -394,16 +443,17 @@ export default function LoginTasksPage() {
|
|||||||
log.level === 'error' ? '#ff4d4f' :
|
log.level === 'error' ? '#ff4d4f' :
|
||||||
log.level === 'success' ? '#52c41a' :
|
log.level === 'success' ? '#52c41a' :
|
||||||
log.level === 'warning' ? '#fa8c16' :
|
log.level === 'warning' ? '#fa8c16' :
|
||||||
'rgba(0,0,0,0.85)',
|
'rgba(0,0,0,0.65)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{log.message}
|
{log.message}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</Card>
|
<div ref={logEndRef} />
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将后端返回的 ISO 时间字符串格式化为本地可读格式。
|
||||||
|
* 后端统一输出带时区的 ISO 字符串(如 2026-06-22T15:51:26+00:00),
|
||||||
|
* 此函数自动转为本地时区并格式化为 "YYYY-MM-DD HH:mm:ss"。
|
||||||
|
*/
|
||||||
|
export function formatTime(val: string | null | undefined): string {
|
||||||
|
if (!val) return '-';
|
||||||
|
return dayjs(val).format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简短格式,仅显示月-日 时:分
|
||||||
|
*/
|
||||||
|
export function formatTimeShort(val: string | null | undefined): string {
|
||||||
|
if (!val) return '-';
|
||||||
|
return dayjs(val).format('MM-DD HH:mm');
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
allowedHosts: ["www.u499731.nyat.app"],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:8000',
|
target: 'http://127.0.0.1:8000',
|
||||||
|
|||||||
Reference in New Issue
Block a user