From 72bcd1274ce14df9425c35a6d9b7323b9a7e51d7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 22 Jun 2026 17:56:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=99=BB=E5=BD=95=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=95=8C=E9=9D=A2=20+=20=E4=BF=AE=E5=A4=8D=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=98=BE=E7=A4=BA=20+=20=E5=A2=9E=E5=8A=A0=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 筛选栏改为单行横排,概览精简为一行文字 - 任务列表全宽展示,日志移到底部可折叠 - 页面使用百分比布局,一屏展示无外层滚动 - 登录任务增加单条删除和批量删除功能 - 后端: datetime.utcnow 替换为 datetime.now(timezone.utc) - 后端: schemas 序列化统一输出带时区 ISO 格式 - 前端: 新增 utils/time.ts 统一时间格式化工具 - 前端: CookiePage/LoginTasksPage 时间列使用 formatTime() Co-Authored-By: Claude Fable 5 --- web/backend/models.py | 17 +- web/backend/routers/cookies.py | 14 +- web/backend/routers/login.py | 32 ++ web/backend/schemas.py | 67 +++- web/backend/services/login_service.py | 6 +- web/frontend/src/api/modules.ts | 2 + web/frontend/src/pages/CookiePage.tsx | 3 +- web/frontend/src/pages/LoginTasksPage.tsx | 382 ++++++++++++---------- web/frontend/src/utils/time.ts | 24 ++ web/frontend/vite.config.ts | 1 + 10 files changed, 361 insertions(+), 187 deletions(-) create mode 100644 web/frontend/src/utils/time.ts diff --git a/web/backend/models.py b/web/backend/models.py index 188a53c..bd68551 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -1,6 +1,6 @@ """ORM 模型""" -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy import ( Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON, ) @@ -8,6 +8,11 @@ from sqlalchemy.orm import relationship from .database import Base +def _utcnow(): + """返回时区感知的 UTC 当前时间,替代 _utcnow()。""" + return datetime.now(timezone.utc) + + class User(Base): """系统用户""" __tablename__ = "users" @@ -19,8 +24,8 @@ class User(Base): is_active = Column(Boolean, default=True) remark = Column(String(256), default="") custom_permissions = Column(JSON, nullable=True, comment="自定义权限列表,null表示使用角色默认权限") - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=_utcnow) + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) # 客服被分配的账号 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) tag = Column(String(64), 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]) login_tasks = relationship("LoginTask", back_populates="account") @@ -57,7 +62,7 @@ class LoginTask(Base): cookie = Column(Text, default="") message = Column(String(512), default="") 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) account = relationship("Account", back_populates="login_tasks") @@ -88,4 +93,4 @@ class AuditLog(Base): action = Column(String(128), nullable=False) target = Column(String(256), default="") detail = Column(Text, default="") - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=_utcnow) diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py index dc95617..d2ae2d4 100644 --- a/web/backend/routers/cookies.py +++ b/web/backend/routers/cookies.py @@ -1,5 +1,6 @@ """Cookie 管理路由""" +from datetime import timezone from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session @@ -11,6 +12,15 @@ from ..models import User, LoginTask, Account from ..deps import get_current_user, require_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管理"]) @@ -39,7 +49,7 @@ def list_cookies( "account_username": acc.username if acc else "", "assigned_to": acc.assigned_to, "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 内容 if has_permission(current.role, "cookie:view"): @@ -70,7 +80,7 @@ def export_cookies( for t in tasks: acc = db.query(Account).filter(Account.id == t.account_id).first() 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) return StreamingResponse( diff --git a/web/backend/routers/login.py b/web/backend/routers/login.py index b01700a..9df433d 100644 --- a/web/backend/routers/login.py +++ b/web/backend/routers/login.py @@ -111,6 +111,38 @@ def list_tasks( 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}") def stop_batch( batch_id: str, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index e713aa9..f5a27da 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1,8 +1,17 @@ """Pydantic 请求/响应模型""" -from datetime import datetime -from typing import Optional -from pydantic import BaseModel, Field +from datetime import datetime, timezone +from typing import Optional, Any +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] = [] custom_permissions: Optional[list[str]] = None - class Config: - from_attributes = True + model_config = ConfigDict(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 = "" created_at: Optional[datetime] = None - class Config: - from_attributes = True + model_config = ConfigDict(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 finished_at: Optional[datetime] = None - class Config: - from_attributes = True + model_config = ConfigDict(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, + } # ---- 代理配置 ---- diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index 9027cbd..0fb88bd 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -4,7 +4,7 @@ import asyncio import threading import uuid from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import datetime +from datetime import datetime, timezone from typing import Optional from sqlalchemy.orm import Session @@ -144,7 +144,7 @@ class LoginBatchRunner: task.message = str(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() finally: @@ -211,7 +211,7 @@ class LoginBatchRunner: if task: task.status = "error" task.message = f"代理不可用: {proxy_msg}" - task.finished_at = datetime.utcnow() + task.finished_at = datetime.now(timezone.utc) worker_db.commit() finally: worker_db.close() diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index 5024547..98e8722 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -61,6 +61,8 @@ export const loginApi = { listTasks: (batch_id?: string) => api.get('/login/tasks', { params: batch_id ? { batch_id } : {} }), stop: (batch_id: string) => api.post(`/login/stop/${batch_id}`), + deleteTask: (id: number) => api.delete(`/login/tasks/${id}`), + deleteTasks: (ids: number[]) => api.delete(`/login/tasks`, { params: { task_ids: ids.join(',') } }), }; export const cookieApi = { diff --git a/web/frontend/src/pages/CookiePage.tsx b/web/frontend/src/pages/CookiePage.tsx index f5fb3f5..8786490 100644 --- a/web/frontend/src/pages/CookiePage.tsx +++ b/web/frontend/src/pages/CookiePage.tsx @@ -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: '操作', diff --git a/web/frontend/src/pages/LoginTasksPage.tsx b/web/frontend/src/pages/LoginTasksPage.tsx index c837653..eb80f46 100644 --- a/web/frontend/src/pages/LoginTasksPage.tsx +++ b/web/frontend/src/pages/LoginTasksPage.tsx @@ -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 = { pending: 'default', @@ -32,7 +33,10 @@ export default function LoginTasksPage() { const [wsConnected, setWsConnected] = useState(false); const [selectedTags, setSelectedTags] = useState([]); const [concurrency, setConcurrency] = useState(3); + const [logVisible, setLogVisible] = useState(true); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); const wsRef = useRef(null); + const logEndRef = useRef(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) => {STATUS_LABELS[status] || status}, }, { 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) => ( + + {['failed', 'error'].includes(record.status) && !wsConnected && ( - ); - } - return null; - }, + )} + handleDeleteTask(record.id)} okText="删除" cancelText="取消"> + - - - {menu} - - )} - /> - - - - setConcurrency(v || 1)} - style={{ width: 60 }} + allowClear size="small" + suffixIcon={} /> - - {batchId && ( - - )} - - - - )} - - - - - - - - - - - - - - - - - - - 0 && ( - - ) - } - style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }} - bodyStyle={{ flex: 1, overflow: 'auto', padding: 0 }} - > - { + const grouped: Record = {}; + 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) => ( + <> +
+ + +
+ {menu} + + )} /> - - -
- 0 && ( + + 标签选中 {accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length} 个 + + )} + + + + setConcurrency(v || 1)} + style={{ width: 50 }} + size="small" + /> + + + + {batchId && ( + + )} + + )} + + + {/* 概览 + 任务列表区域 */} +
+ {/* 概览 */} +
+ {tasks.length} 个任务 + 成功 {successCount} + 失败 {failedCount} + {batchId && 批次: {batchId}} +
+ {selectedRowKeys.length > 0 && ( + + + + )} + {!wsConnected && failedCount > 0 && ( + + )} +
+ + {/* 任务列表 - flex:1 占满剩余空间,内部滚动 */} +
+
setSelectedRowKeys(keys as number[]), + }} + /> + + + + {/* 实时日志 - 底部可折叠 */} +
+
setLogVisible((v) => !v)} + > + 实时日志 + {logVisible ? : } + {logs.length > 0 && {logs.length} 条} + {wsConnected && 连接中} +
+ {logVisible && ( +
{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}
)) )} - - - +
+
+ )} +
); } diff --git a/web/frontend/src/utils/time.ts b/web/frontend/src/utils/time.ts new file mode 100644 index 0000000..8249884 --- /dev/null +++ b/web/frontend/src/utils/time.ts @@ -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'); +} diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts index 18b3ae8..61fcda6 100644 --- a/web/frontend/vite.config.ts +++ b/web/frontend/vite.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ plugins: [react()], server: { port: 5173, + allowedHosts: ["www.u499731.nyat.app"], proxy: { '/api': { target: 'http://127.0.0.1:8000',