优化登录任务界面 + 修复时间显示 + 增加删除功能

- 筛选栏改为单行横排,概览精简为一行文字
- 任务列表全宽展示,日志移到底部可折叠
- 页面使用百分比布局,一屏展示无外层滚动
- 登录任务增加单条删除和批量删除功能
- 后端: 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:
yml2213
2026-06-22 17:56:48 +08:00
co-authored by Claude Fable 5
parent 8771d91a30
commit 72bcd1274c
10 changed files with 361 additions and 187 deletions
+11 -6
View File
@@ -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)
+12 -2
View File
@@ -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(
+32
View File
@@ -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
View File
@@ -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,
}
# ---- 代理配置 ---- # ---- 代理配置 ----
+3 -3
View File
@@ -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()
+2
View File
@@ -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 = {
+2 -1
View File
@@ -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: '操作',
+130 -80
View File
@@ -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>
); );
} }
+24
View File
@@ -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');
}
+1
View File
@@ -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',