diff --git a/.env.example b/.env.example index 6294ea7..dced552 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ # CORS 允许的前端域名(逗号分隔,生产环境必须修改) CORS_ORIGINS=http://localhost:3000 +# 前端 URL 配置(用于邮件中的链接) +FRONTEND_URL=http://localhost:3000 + # 日志级别(可选:DEBUG, INFO, WARNING, ERROR) LOG_LEVEL=INFO diff --git a/README.md b/README.md index af35069..4b7fa01 100644 --- a/README.md +++ b/README.md @@ -670,7 +670,7 @@ CheckInApp/ - `GET /api/check_in/records/count` - 记录统计 ### 管理员 (`/api/admin`) -- `POST /api/admin/batch_toggle_active` - 批量启用/禁用 + - `POST /api/admin/batch_check_in` - 批量打卡 - `GET /api/admin/logs` - 系统日志 - `GET /api/admin/stats` - 系统统计 diff --git a/backend/README.md b/backend/README.md index 0591240..4f3abea 100644 --- a/backend/README.md +++ b/backend/README.md @@ -121,7 +121,6 @@ backend/ ### 管理员 API (`/api/admin`) -- `POST /api/admin/batch_toggle_active` - 批量启用/禁用用户 - `POST /api/admin/batch_check_in` - 批量触发打卡 - `GET /api/admin/logs` - 获取系统日志 - `GET /api/admin/stats` - 获取系统统计 diff --git a/backend/api/auth.py b/backend/api/auth.py index 9f32bcf..ae5e6ed 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -100,6 +100,27 @@ async def get_qrcode_status( ) +@router.delete("/qrcode_session/{session_id}", response_model=dict, summary="取消二维码登录会话") +async def cancel_qrcode_session( + session_id: str +): + """ + 取消二维码登录会话 + + - **session_id**: 会话 ID + + 用于用户关闭二维码对话框时,终止后台的 Selenium 进程 + """ + try: + result = AuthService.cancel_qrcode_session(session_id) + return result + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"取消会话失败: {str(e)}" + ) + + @router.post("/verify_token", response_model=dict, summary="验证 Token 有效性") async def verify_token( request: TokenVerifyRequest, diff --git a/backend/api/tasks.py b/backend/api/tasks.py index 10cd494..55dbea4 100644 --- a/backend/api/tasks.py +++ b/backend/api/tasks.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from typing import List from datetime import datetime, timedelta +from pydantic import BaseModel, Field from backend.models import get_db, User from backend.schemas.task import TaskCreate, TaskUpdate, TaskResponse @@ -11,6 +12,11 @@ from backend.dependencies import get_current_user router = APIRouter() +class CronValidateRequest(BaseModel): + """Cron 表达式验证请求""" + cron_expression: str = Field(..., min_length=9, description="Crontab 表达式") + + @router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, summary="创建打卡任务") async def create_task( task_data: TaskCreate, @@ -181,7 +187,7 @@ async def toggle_task( @router.post("/validate-cron", summary="验证 Crontab 表达式") -async def validate_cron_expression(request: dict): +async def validate_cron_expression(request: CronValidateRequest): """ 验证 Crontab 表达式并预览下一个执行时间 @@ -199,7 +205,7 @@ async def validate_cron_expression(request: dict): "description": "每天 20:00" } """ - cron_expr = request.get('cron_expression', '').strip() + cron_expr = request.cron_expression.strip() if not cron_expr: raise HTTPException( diff --git a/backend/api/users.py b/backend/api/users.py index ab861f5..ecb1b35 100644 --- a/backend/api/users.py +++ b/backend/api/users.py @@ -138,8 +138,11 @@ async def get_current_user_token_status( minutes_until_expiry = (exp_timestamp - current_timestamp) // 60 expiring_soon = minutes_until_expiry <= 30 - except ValueError: - pass + except ValueError as e: + # jwt_exp 格式不正确,记录警告 + import logging + logger = logging.getLogger(__name__) + logger.warning(f"用户 {current_user.id} ({current_user.alias}) 的 jwt_exp 格式不正确: {current_user.jwt_exp}, 错误: {e}") return { "is_valid": is_valid, @@ -256,7 +259,37 @@ async def update_user( ) try: + # 获取更新前的用户状态 + old_user = UserService.get_user_by_id(user_id, db) + if not old_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"用户 ID {user_id} 不存在" + ) + + # 保存更新前的审批状态 (先读取后转换为 Python bool) + old_approved_value = old_user.is_approved + was_approved_before = True if old_approved_value else False + + # 更新用户信息 user = UserService.update_user(user_id, user_data, db) + + # 检查是否需要发送审批通过邮件 + new_approved_value = user.is_approved + is_approved_now = True if new_approved_value else False + + is_admin = (current_user.role == "admin") + needs_notification = (is_admin and (not was_approved_before) and is_approved_now) + + if needs_notification: + try: + from backend.services.email_service import EmailService + EmailService.notify_user_approved(user) + except Exception as e: + # 邮件发送失败不影响审批操作 + import logging + logging.getLogger(__name__).error(f"发送审批通过邮件失败: {e}") + return user except ValueError as e: raise HTTPException( diff --git a/backend/config.py b/backend/config.py index ae36005..d33d15e 100644 --- a/backend/config.py +++ b/backend/config.py @@ -51,6 +51,9 @@ class Settings(BaseSettings): SMTP_SENDER_PASSWORD: str = "" SMTP_USE_SSL: bool = True + # 前端 URL 配置(用于邮件中的链接) + FRONTEND_URL: str = "http://localhost:3000" + # 定时任务配置(可通过环境变量配置) TOKEN_CHECK_INTERVAL_MINUTES: int = 30 # Token 检查间隔(分钟) SESSION_CLEANUP_INTERVAL_HOURS: int = 24 # 会话清理间隔(小时) diff --git a/backend/dependencies.py b/backend/dependencies.py index c7e6af9..7faf7b0 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -1,9 +1,12 @@ from datetime import datetime from typing import Optional +import logging from fastapi import Depends, HTTPException, Header, status from sqlalchemy.orm import Session from backend.models import get_db, User +logger = logging.getLogger(__name__) + async def get_current_user( authorization: Optional[str] = Header(None), @@ -11,7 +14,9 @@ async def get_current_user( ) -> User: """ 获取当前用户 - 从 Authorization header 中验证 Token 并返回用户 + 支持两种认证方式: + 1. Token 认证(QQ 扫码登录) + 2. User ID 认证(密码登录,格式:user_id:xxx) """ if not authorization: raise HTTPException( @@ -23,6 +28,40 @@ async def get_current_user( # 移除 "Bearer " 前缀(如果存在) token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization + # 检查是否为 user_id 格式的认证(用于密码登录) + if token.startswith("user_id:"): + user_id_str = token.replace("user_id:", "") + try: + user_id = int(user_id_str) + user = db.query(User).filter(User.id == user_id).first() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 用户ID认证成功,检查是否设置了密码 + has_password = bool(user.password_hash) + if not has_password: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="该账户未设置密码,请使用扫码登录", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 密码登录的用户可以访问,无需检查 Token + return user + + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的用户ID格式", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Token 认证(原有逻辑) # 从数据库查询用户 user = db.query(User).filter(User.authorization == token).first() @@ -39,13 +78,22 @@ async def get_current_user( exp_timestamp = int(user.jwt_exp) current_timestamp = int(datetime.now().timestamp()) if current_timestamp > exp_timestamp: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token 已过期,请重新登录", - headers={"WWW-Authenticate": "Bearer"}, - ) - except ValueError: - pass # jwt_exp 格式不正确,跳过验证 + # 如果用户设置了密码,允许继续使用(Token 过期但不强制退出) + has_password = bool(user.password_hash) + if has_password: + # Token 过期但有密码,允许访问,但在响应头中添加警告 + # 注意:这里不抛出异常,让用户继续使用 + pass + else: + # 没有密码的用户,Token 过期必须重新扫码登录 + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token 已过期,请重新扫码登录", + headers={"WWW-Authenticate": "Bearer"}, + ) + except ValueError as e: + # jwt_exp 格式不正确,记录警告后跳过 Token 过期验证 + logger.warning(f"用户 {user.id} ({user.alias}) 的 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}") return user diff --git a/backend/models/user.py b/backend/models/user.py index c97d225..cad0007 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -18,7 +18,6 @@ class User(Base): jwt_exp = Column(String(20), default="0", comment="Token 过期时间戳") role = Column(String(20), default="user", index=True, comment="角色: user/admin") is_approved = Column(Boolean, default=False, index=True, comment="是否已通过管理员审批") - registered_ip = Column(String(50), nullable=True, comment="注册时的 IP 地址") created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间") updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间") diff --git a/backend/schemas/user.py b/backend/schemas/user.py index 02c82a1..d332f71 100644 --- a/backend/schemas/user.py +++ b/backend/schemas/user.py @@ -12,6 +12,7 @@ class UserCreate(UserBase): """创建用户 Schema(管理员手动创建,只需要别名)""" role: Optional[str] = Field("user", description="角色: user/admin") email: Optional[str] = Field(None, description="邮箱地址") + is_approved: Optional[bool] = Field(True, description="是否已审批(默认已审批)") class UserUpdate(BaseModel): @@ -36,7 +37,7 @@ class UserResponse(BaseModel): """用户响应 Schema""" id: int alias: str - jwt_sub: str + jwt_sub: Optional[str] = None role: str is_approved: bool jwt_exp: str @@ -58,7 +59,7 @@ class TokenStatus(BaseModel): """Token 状态 Schema""" is_valid: bool jwt_exp: str - jwt_sub: str + jwt_sub: Optional[str] = None expires_at: Optional[int] = None # Unix 时间戳(秒) days_until_expiry: Optional[int] = None expiring_soon: bool = False # 是否即将过期(30分钟内) diff --git a/backend/scripts/migrate_add_parent_id_to_templates.py b/backend/scripts/migrate_add_parent_id_to_templates.py deleted file mode 100644 index bba05e8..0000000 --- a/backend/scripts/migrate_add_parent_id_to_templates.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -数据库迁移脚本:为 task_templates 表添加 parent_id 字段 - -运行方法: - python backend/scripts/migrate_add_parent_id_to_templates.py -""" -import sys -import os -from pathlib import Path - -# 设置 UTF-8 编码输出(Windows 兼容) -if sys.platform == "win32": - import io - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') - -# 添加项目根目录到 Python 路径 -project_root = Path(__file__).parent.parent.parent -sys.path.insert(0, str(project_root)) - -from sqlalchemy import text -from backend.models.database import engine, SessionLocal - - -def migrate(): - """为 task_templates 表添加 parent_id 字段""" - print("=" * 60) - print("开始数据库迁移:添加 parent_id 字段到 task_templates 表") - print("=" * 60) - - db = SessionLocal() - - try: - # 检查字段是否已存在 - result = db.execute(text( - "SELECT COUNT(*) FROM pragma_table_info('task_templates') WHERE name='parent_id'" - )) - field_exists = result.fetchone()[0] > 0 - - if field_exists: - print("⚠️ parent_id 字段已存在,跳过迁移") - return - - # 添加 parent_id 字段 - print("📝 正在添加 parent_id 字段...") - db.execute(text( - "ALTER TABLE task_templates ADD COLUMN parent_id INTEGER" - )) - db.commit() - print("✅ parent_id 字段添加成功") - - # 创建外键约束(SQLite 不支持直接添加外键,需要重建表) - print("\n📝 注意:SQLite 不支持直接添加外键约束") - print(" 如需外键约束,请重建表或在下次完整迁移时处理") - - print("\n" + "=" * 60) - print("✅ 数据库迁移完成!") - print("=" * 60) - - except Exception as e: - print(f"\n❌ 迁移失败: {str(e)}") - db.rollback() - import traceback - traceback.print_exc() - sys.exit(1) - - finally: - db.close() - - -if __name__ == "__main__": - migrate() diff --git a/backend/scripts/migrate_add_payload_config.py b/backend/scripts/migrate_add_payload_config.py deleted file mode 100644 index b4399ba..0000000 --- a/backend/scripts/migrate_add_payload_config.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -添加 payload_config 字段到 check_in_tasks 表的迁移脚本 - -运行方式: - python backend/scripts/migrate_add_payload_config.py - 或 - .venv/Scripts/python.exe backend/scripts/migrate_add_payload_config.py -""" - -import sys -import os -from pathlib import Path - -# 添加项目根目录到 Python 路径 -project_root = Path(__file__).resolve().parent.parent.parent -sys.path.insert(0, str(project_root)) - -from sqlalchemy import text -from backend.models.database import engine - - -def migrate(): - """执行迁移""" - print("开始迁移:添加 payload_config 字段...") - - with engine.connect() as conn: - # 检查字段是否已存在 - result = conn.execute(text("PRAGMA table_info(check_in_tasks)")) - columns = [row[1] for row in result] - - if 'payload_config' in columns: - print("[OK] payload_config 字段已存在,跳过迁移") - return - - # 添加 payload_config 字段(JSON 文本,存储完整的 payload 配置) - print("添加 payload_config 字段...") - conn.execute(text(""" - ALTER TABLE check_in_tasks - ADD COLUMN payload_config TEXT DEFAULT '{}' NOT NULL - """)) - conn.commit() - - print("[OK] payload_config 字段添加成功") - print("\n注意:现有任务的 payload_config 默认为空 JSON {},") - print(" Worker 将使用默认的固定字段值。") - print(" 新创建的任务将从模板继承完整的 payload 配置。") - - -if __name__ == "__main__": - try: - migrate() - print("\n[SUCCESS] 迁移完成!") - except Exception as e: - print(f"\n[ERROR] 迁移失败: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/backend/scripts/migrate_remove_old_columns.py b/backend/scripts/migrate_remove_old_columns.py deleted file mode 100644 index ef75818..0000000 --- a/backend/scripts/migrate_remove_old_columns.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -删除 check_in_tasks 表中不再需要的旧列的迁移脚本 - -删除的列: -- signature (VARCHAR) - 已在 payload_config 中 -- texts (VARCHAR) - 已在 payload_config 中 -- values (TEXT) - 已在 payload_config 中 -- thread_id (VARCHAR) - 已在 payload_config 的 ThreadId 中 -- email (VARCHAR) - 从 user 表的 email 字段获取 - -新架构只保留: -- id, user_id, payload_config, name, is_active, created_at, updated_at - -运行方式: - python backend/scripts/migrate_remove_old_columns.py - 或 - venv/Scripts/python.exe backend/scripts/migrate_remove_old_columns.py -""" - -import sys -import os -from pathlib import Path - -# 添加项目根目录到 Python 路径 -project_root = Path(__file__).resolve().parent.parent.parent -sys.path.insert(0, str(project_root)) - -from sqlalchemy import text, inspect -from backend.models.database import engine - - -def migrate(): - """执行迁移:删除旧列""" - print("开始迁移:删除 check_in_tasks 表中的旧列...") - print("将删除的列: signature, texts, values, thread_id, email") - print("=" * 60) - - with engine.connect() as conn: - # 检查表结构 - inspector = inspect(engine) - columns = [col['name'] for col in inspector.get_columns('check_in_tasks')] - - print(f"\n当前表列: {', '.join(columns)}") - - old_columns = ['signature', 'texts', 'values', 'thread_id', 'email'] - columns_to_remove = [col for col in old_columns if col in columns] - - if not columns_to_remove: - print("\n[OK] 旧列已被删除,跳过迁移") - return - - print(f"\n需要删除的列: {', '.join(columns_to_remove)}") - - # SQLite 不支持直接 DROP COLUMN,需要重建表 - # 步骤: - # 1. 创建新表(只包含需要的列) - # 2. 复制数据 - # 3. 删除旧表 - # 4. 重命名新表 - - print("\n正在重建表结构...") - - # 1. 创建新表 - conn.execute(text(""" - CREATE TABLE check_in_tasks_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - payload_config TEXT NOT NULL DEFAULT '{}', - name VARCHAR(100) DEFAULT '', - is_active BOOLEAN DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """)) - print(" [OK] 创建新表结构") - - # 2. 复制数据(只复制保留的列) - conn.execute(text(""" - INSERT INTO check_in_tasks_new - (id, user_id, payload_config, name, is_active, created_at, updated_at) - SELECT - id, user_id, payload_config, name, is_active, created_at, updated_at - FROM check_in_tasks - """)) - print(" [OK] 复制数据到新表") - - # 3. 删除旧表 - conn.execute(text("DROP TABLE check_in_tasks")) - print(" [OK] 删除旧表") - - # 4. 重命名新表 - conn.execute(text("ALTER TABLE check_in_tasks_new RENAME TO check_in_tasks")) - print(" [OK] 重命名新表") - - # 5. 重建索引 - conn.execute(text(""" - CREATE INDEX ix_check_in_tasks_user_id ON check_in_tasks(user_id) - """)) - conn.execute(text(""" - CREATE INDEX ix_check_in_tasks_id ON check_in_tasks(id) - """)) - conn.execute(text(""" - CREATE INDEX ix_task_user_active ON check_in_tasks(user_id, is_active) - """)) - print(" [OK] 重建索引") - - conn.commit() - - print("\n[SUCCESS] 表结构迁移成功!") - print("\n新的表结构:") - inspector = inspect(engine) - new_columns = [col['name'] for col in inspector.get_columns('check_in_tasks')] - print(f" 列: {', '.join(new_columns)}") - - -if __name__ == "__main__": - try: - migrate() - print("\n" + "=" * 60) - print("[完成] 迁移成功完成!") - print("\n数据库已更新为新架构:") - print(" - 删除了 signature, texts, values, thread_id, email 列") - print(" - 保留了 payload_config 列(存储完整的 JSON payload)") - print(" - ThreadId 现在存储在 payload_config 中") - print(" - Email 现在从 user 表获取") - print("=" * 60) - except Exception as e: - print(f"\n[ERROR] 迁移失败: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/backend/scripts/migrate_remove_registered_ip.py b/backend/scripts/migrate_remove_registered_ip.py new file mode 100644 index 0000000..353975b --- /dev/null +++ b/backend/scripts/migrate_remove_registered_ip.py @@ -0,0 +1,135 @@ +""" +删除 users 表中 registered_ip 列的迁移脚本 + +删除的列: +- registered_ip (VARCHAR) - 注册IP地址,不再需要 + +新架构中移除该字段以保护用户隐私。 + +运行方式: + python backend/scripts/migrate_remove_registered_ip.py + 或 + venv/Scripts/python.exe backend/scripts/migrate_remove_registered_ip.py +""" + +import sys +import os +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy import text, inspect +from backend.models.database import engine + + +def migrate(): + """执行迁移:删除 registered_ip 列""" + print("开始迁移:删除 users 表中的 registered_ip 列...") + print("=" * 60) + + with engine.connect() as conn: + # 检查表结构 + inspector = inspect(engine) + columns = [col['name'] for col in inspector.get_columns('users')] + + print(f"\n当前表列: {', '.join(columns)}") + + if 'registered_ip' not in columns: + print("\n[OK] registered_ip 列已被删除,跳过迁移") + return + + print(f"\n需要删除的列: registered_ip") + + # SQLite 不支持直接 DROP COLUMN,需要重建表 + # 步骤: + # 1. 创建新表(不包含 registered_ip) + # 2. 复制数据 + # 3. 删除旧表 + # 4. 重命名新表 + + print("\n正在重建表结构...") + + # 1. 创建新表 + conn.execute(text(""" + CREATE TABLE users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jwt_sub VARCHAR(200) UNIQUE, + alias VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100), + password_hash VARCHAR(200), + authorization TEXT, + jwt_exp VARCHAR(20) DEFAULT '0', + role VARCHAR(20) DEFAULT 'user', + is_approved BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME + ) + """)) + print(" [OK] 创建新表结构") + + # 2. 复制数据(不包含 registered_ip) + conn.execute(text(""" + INSERT INTO users_new + (id, jwt_sub, alias, email, password_hash, authorization, jwt_exp, + role, is_approved, created_at, updated_at) + SELECT + id, jwt_sub, alias, email, password_hash, authorization, jwt_exp, + role, is_approved, created_at, updated_at + FROM users + """)) + print(" [OK] 复制数据到新表") + + # 3. 删除旧表 + conn.execute(text("DROP TABLE users")) + print(" [OK] 删除旧表") + + # 4. 重命名新表 + conn.execute(text("ALTER TABLE users_new RENAME TO users")) + print(" [OK] 重命名新表") + + # 5. 重建索引 + conn.execute(text(""" + CREATE INDEX ix_users_jwt_sub ON users(jwt_sub) + """)) + conn.execute(text(""" + CREATE INDEX ix_users_alias ON users(alias) + """)) + conn.execute(text(""" + CREATE INDEX ix_users_role ON users(role) + """)) + conn.execute(text(""" + CREATE INDEX ix_users_is_approved ON users(is_approved) + """)) + conn.execute(text(""" + CREATE INDEX ix_users_id ON users(id) + """)) + conn.execute(text(""" + CREATE INDEX ix_user_role_approved ON users(role, is_approved) + """)) + print(" [OK] 重建索引") + + conn.commit() + + print("\n[SUCCESS] 表结构迁移成功!") + print("\n新的表结构:") + inspector = inspect(engine) + new_columns = [col['name'] for col in inspector.get_columns('users')] + print(f" 列: {', '.join(new_columns)}") + + +if __name__ == "__main__": + try: + migrate() + print("\n" + "=" * 60) + print("[完成] 迁移成功完成!") + print("\n数据库已更新为新架构:") + print(" - 删除了 registered_ip 列(保护用户隐私)") + print(" - 保留了所有其他用户数据") + print("=" * 60) + except Exception as e: + print(f"\n[ERROR] 迁移失败: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index 3ab1914..7c51336 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -25,7 +25,7 @@ class AuthService: Args: alias: 用户别名 - client_ip: 客户端 IP 地址 + client_ip: 客户端 IP 地址(用于会话标识) db: 数据库会话 Returns: @@ -42,11 +42,11 @@ class AuthService: if existing_user: # 检查是否为空 jwt_sub(测试账号) - if not existing_user.jwt_sub or existing_user.jwt_sub == "": - logger.warning(f"用户 {alias} 是测试账号(空 jwt_sub),禁止登录") + if not existing_user.jwt_sub: + logger.warning(f"用户 {alias} 是测试账号(未绑定 QQ),禁止扫码登录") return { "status": "error", - "message": "此账户为测试账号,暂未绑定 QQ,无法登录" + "message": "此账户为测试账号,暂未绑定 QQ,无法扫码登录" } # 老用户:刷新 Token @@ -243,7 +243,6 @@ class AuthService: } # 创建新用户(待审批状态) - client_ip = session_data.get("client_ip", "") new_user = User( jwt_sub=jwt_sub, alias=alias, @@ -251,7 +250,6 @@ class AuthService: jwt_exp=jwt_exp, role="user", is_approved=False, # 待审批 - registered_ip=client_ip ) db.add(new_user) @@ -427,7 +425,9 @@ class AuthService: "message": "登录成功", "user_id": user.id, "authorization": user.authorization, - "alias": user.alias + "alias": user.alias, + "role": user.role, + "is_approved": user.is_approved } # 如果 Token 有问题,添加警告信息 @@ -475,3 +475,29 @@ class AuthService: except Exception as e: logger.error(f"密码验证异常:{e}") return False + + @staticmethod + def cancel_qrcode_session(session_id: str) -> Dict[str, Any]: + """ + 取消二维码登录会话 + + Args: + session_id: 会话 ID + + Returns: + 包含取消结果的字典 + """ + from backend.workers.token_refresher import cancel_session + + success = cancel_session(session_id) + + if success: + return { + "success": True, + "message": "会话已取消" + } + else: + return { + "success": False, + "message": "取消失败或会话不存在" + } diff --git a/backend/services/check_in_service.py b/backend/services/check_in_service.py index 0649721..07fc760 100644 --- a/backend/services/check_in_service.py +++ b/backend/services/check_in_service.py @@ -173,8 +173,9 @@ class CheckInService: "status": "failure", "message": f"{error_msg},请重新扫码登录" } - except ValueError: - pass + except ValueError as e: + # jwt_exp 格式不正确,记录警告后跳过 Token 过期验证 + logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}") # 创建待处理记录 record_id = CheckInService.create_pending_check_in_record(task, trigger_type, db) @@ -264,8 +265,9 @@ class CheckInService: "message": f"{error_msg},请重新扫码登录", "record_id": record.id } - except ValueError: - pass + except ValueError as e: + # jwt_exp 格式不正确,记录警告后跳过 Token 过期验证 + logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}") # 执行打卡(传递 task 对象和用户 token) logger.info(f"🤖 调用 Selenium Worker 执行打卡...") @@ -409,8 +411,9 @@ class CheckInService: logger.warning(f"任务 ID: {task.id} 的用户 Token 已过期,跳过") results["skipped"] += 1 continue - except ValueError: - pass + except ValueError as e: + # jwt_exp 格式不正确,记录警告后继续执行打卡 + logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {task.user.jwt_exp}, 错误: {e}") # 执行打卡 result = CheckInService.perform_task_check_in(task, "scheduled", db) @@ -514,7 +517,7 @@ class CheckInService: status: Optional[str] = None ) -> List[CheckInRecord]: """ - 获取所有打卡记录(管理员) + 获取所有打卡记录(管理员)- 使用联表查询优化性能 Args: db: 数据库会话 @@ -526,7 +529,12 @@ class CheckInService: Returns: 打卡记录列表 """ - query = db.query(CheckInRecord) + from sqlalchemy.orm import joinedload + + # 使用 joinedload 预加载关联的 task 和 user,避免 N+1 查询 + query = db.query(CheckInRecord).options( + joinedload(CheckInRecord.task).joinedload(CheckInTask.user) + ) if task_id: query = query.filter(CheckInRecord.task_id == task_id) @@ -543,15 +551,18 @@ class CheckInService: """ 为打卡记录添加用户和任务信息 + 注意:如果使用了 joinedload,task 和 user 已经预加载,不会产生额外查询 + Args: record: 打卡记录对象 - db: 数据库会话 + db: 数据库会话(可选,仅在未使用 joinedload 时使用) Returns: 包含额外信息的记录字典 """ - # 获取任务信息 - task = db.query(CheckInTask).filter(CheckInTask.id == record.task_id).first() + # 尝试使用已加载的关联对象,如果没有则查询 + task = record.task if hasattr(record, 'task') and record.task else \ + db.query(CheckInTask).filter(CheckInTask.id == record.task_id).first() # 获取用户信息 user = None @@ -559,14 +570,17 @@ class CheckInService: thread_id = None if task: - user = db.query(User).filter(User.id == task.user_id).first() + # 尝试使用已加载的 user,否则查询 + user = task.user if hasattr(task, 'user') and task.user else \ + db.query(User).filter(User.id == task.user_id).first() task_name = task.name # 从 payload_config 提取 ThreadId try: payload = json.loads(str(task.payload_config)) thread_id = payload.get('ThreadId') - except: + except (json.JSONDecodeError, KeyError, TypeError, AttributeError) as e: + logger.debug(f"解析任务 {task.id} 的 payload_config 失败: {e}") pass # 转换为字典并添加额外字段 diff --git a/backend/services/email_service.py b/backend/services/email_service.py index 9531ac6..c2b11f0 100644 --- a/backend/services/email_service.py +++ b/backend/services/email_service.py @@ -1,24 +1,33 @@ -import smtplib +""" +邮件业务服务 (高级) + +职能:提供业务相关的邮件操作 +- 新用户注册通知 +- 用户审批通知 +- 打卡结果通知 +- Token 到期提醒 +- 调用底层 EmailNotifier 发送邮件 +""" + import logging -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from typing import List from datetime import datetime +from typing import List from sqlalchemy.orm import Session -from backend.config import settings from backend.models import User +from backend.workers.email_notifier import EmailNotifier +from backend.config import settings logger = logging.getLogger(__name__) class EmailService: - """邮件通知服务""" + """邮件业务服务(高级服务)""" @staticmethod def send_email(to_emails: List[str], subject: str, body_html: str) -> bool: """ - 发送邮件 + 发送邮件(业务层方法,调用底层 EmailNotifier) Args: to_emails: 收件人邮箱列表 @@ -28,39 +37,7 @@ class EmailService: Returns: 是否发送成功 """ - # 检查邮件配置 - if not all([settings.SMTP_SERVER, settings.SMTP_SENDER_EMAIL, settings.SMTP_SENDER_PASSWORD]): - logger.warning("邮件配置不完整,跳过发送邮件") - return False - - try: - # 创建邮件 - msg = MIMEMultipart('alternative') - msg['From'] = settings.SMTP_SENDER_EMAIL - msg['To'] = ', '.join(to_emails) - msg['Subject'] = subject - - # 添加 HTML 正文 - html_part = MIMEText(body_html, 'html', 'utf-8') - msg.attach(html_part) - - # 连接 SMTP 服务器并发送 - if settings.SMTP_USE_SSL: - server = smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT) - else: - server = smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT) - server.starttls() - - server.login(settings.SMTP_SENDER_EMAIL, settings.SMTP_SENDER_PASSWORD) - server.sendmail(settings.SMTP_SENDER_EMAIL, to_emails, msg.as_string()) - server.quit() - - logger.info(f"邮件发送成功: {subject} -> {', '.join(to_emails)}") - return True - - except Exception as e: - logger.error(f"邮件发送失败: {e}") - return False + return EmailNotifier.send_email(to_emails, subject, body_html) @staticmethod def notify_new_user_registration(user: User, db: Session) -> bool: @@ -76,7 +53,12 @@ class EmailService: """ # 查询所有管理员邮箱 admins = db.query(User).filter(User.role == "admin", User.email.isnot(None)).all() - admin_emails = [admin.email for admin in admins if admin.email] + # 使用 str() 转换避免类型检查问题,并过滤空值 + admin_emails: List[str] = [] + for admin in admins: + email_value = admin.email + if email_value is not None: # 使用 is not None 避免布尔转换 + admin_emails.append(str(email_value)) if not admin_emails: logger.warning("没有找到管理员邮箱,无法发送通知") @@ -85,6 +67,10 @@ class EmailService: # 构建邮件内容 subject = f"【接龙自动打卡系统】新用户注册通知 - {user.alias}" + # 安全获取创建时间 + created_at_value = user.created_at + created_time = created_at_value.strftime('%Y-%m-%d %H:%M:%S') if created_at_value is not None else '未知' + body_html = f""" @@ -161,11 +147,7 @@ class EmailService: 注册时间 - {user.created_at.strftime('%Y-%m-%d %H:%M:%S') if user.created_at else '未知'} - - - 注册 IP - {user.registered_ip or '未记录'} + {created_time} @@ -175,7 +157,7 @@ class EmailService:

请登录管理后台进行审批操作。

-

登录地址:http://localhost:5173/admin/users

+

登录地址:{settings.FRONTEND_URL}/admin/users

@@ -233,14 +274,36 @@ import { ref, computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useAuthStore } from '@/stores/auth' -import { ElMessageBox } from 'element-plus' +import { useUserStore } from '@/stores/user' +import { useTokenMonitor } from '@/composables/useTokenMonitor' +import { useBreakpoint } from '@/composables/useBreakpoint' +import { Modal, message } from 'ant-design-vue' +import QRCodeModal from './QRCodeModal.vue' +import { + MenuOutlined, + HomeOutlined, + FileTextOutlined, + UnorderedListOutlined, + SettingOutlined, + UserOutlined, + FileOutlined, + CheckSquareOutlined, + BarChartOutlined, + LogoutOutlined, + DownOutlined, + CheckCircleOutlined, + ClockCircleOutlined, +} from '@ant-design/icons-vue' const router = useRouter() const route = useRoute() const authStore = useAuthStore() +const userStore = useUserStore() +const { isMobile } = useBreakpoint() +const { getRemainingMinutes, tokenStatus } = useTokenMonitor() -const showAdminMenu = ref(false) -const showUserMenu = ref(false) +const drawerVisible = ref(false) +const qrcodeModalVisible = ref(false) const isAdminPath = computed(() => route.path.startsWith('/admin')) @@ -249,19 +312,140 @@ const userInitial = computed(() => { return name.charAt(0).toUpperCase() }) +// Token 状态计算 +const remainingMinutes = computed(() => { + return getRemainingMinutes() +}) + +const showTokenStatus = computed(() => { + if (!authStore.isAuthenticated || !tokenStatus.value) return false + + const mins = remainingMinutes.value + // 显示条件:Token 即将过期(60分钟内)或已过期(5分钟内) + if (mins === null) return false + return mins <= 60 || (mins < 0 && Math.abs(mins) <= 5) +}) + +const tokenBadgeStatus = computed(() => { + const mins = remainingMinutes.value + if (mins === null) return 'default' + if (mins < 0) return 'error' // 已过期 + if (mins <= 10) return 'error' // 10分钟内过期 + if (mins <= 30) return 'warning' // 30分钟内过期 + return 'processing' // 正常但快过期 +}) + +const tokenBadgeText = computed(() => { + const mins = remainingMinutes.value + if (mins === null) return '' + if (mins < 0) return 'Token 已过期' + if (mins < 60) return `Token 剩余:${mins}分钟` + return '' +}) + +const tokenIconClass = computed(() => { + const mins = remainingMinutes.value + if (mins === null) return 'text-gray-500' + if (mins < 0) return 'text-red-500' // 已过期 + if (mins <= 10) return 'text-red-500 animate-pulse' // 10分钟内,闪烁 + if (mins <= 30) return 'text-orange-500' // 30分钟内 + return 'text-blue-500' // 正常 +}) + +const tokenStatusTooltip = computed(() => { + const mins = remainingMinutes.value + if (mins === null) return 'Token 状态未知' + if (mins < 0) { + const expiredMins = Math.abs(mins) + return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新` + } + if (mins < 60) { + return `Token 剩余时间:${mins} 分钟,过期后可刷新)` + } + return 'Token 状态正常' +}) + +const handleTokenStatusClick = () => { + const mins = remainingMinutes.value + + // Token 已过期时提醒刷新 + if (mins !== null && mins < 0) { + message.info('Token 已过期,请进行刷新') + } + // Token 未过期时,点击无效果 +} + +const currentMenuKey = computed(() => { + const path = route.path + if (path.startsWith('/admin/users')) return 'admin-users' + if (path.startsWith('/admin/templates')) return 'admin-templates' + if (path.startsWith('/admin/records')) return 'admin-records' + if (path.startsWith('/admin/stats')) return 'admin-stats' + if (path.startsWith('/admin/logs')) return 'admin-logs' + if (path.startsWith('/dashboard')) return 'dashboard' + if (path.startsWith('/tasks')) return 'tasks' + if (path.startsWith('/records')) return 'records' + if (path.startsWith('/settings')) return 'settings' + return '' +}) + +const handleMenuClick = ({ key }) => { + const routes = { + 'dashboard': '/dashboard', + 'tasks': '/tasks', + 'records': '/records', + 'admin-users': '/admin/users', + 'admin-templates': '/admin/templates', + 'admin-records': '/admin/records', + 'admin-stats': '/admin/stats', + 'admin-logs': '/admin/logs', + 'settings': '/settings', + } + + if (key === 'logout') { + handleLogout() + } else if (routes[key]) { + router.push(routes[key]) + drawerVisible.value = false + } +} + const handleLogout = () => { - ElMessageBox.confirm('确定要退出登录吗?', '提示', { - confirmButtonText: '确定', - cancelButtonText: '取消', - type: 'warning', - }) - .then(() => { + Modal.confirm({ + title: '提示', + content: '确定要退出登录吗?', + okText: '确定', + cancelText: '取消', + onOk() { authStore.logout() router.push('/login') - }) - .catch(() => { - // 取消操作 - }) + drawerVisible.value = false + }, + }) +} + +// 处理 Token 刷新 +const handleRefreshToken = () => { + qrcodeModalVisible.value = true +} + +// 处理 QR 码扫码成功 +const handleQRCodeSuccess = async () => { + message.success('Token 刷新成功') + qrcodeModalVisible.value = false + + // 刷新用户信息和 Token 状态 + try { + await authStore.fetchCurrentUser() + await userStore.fetchTokenStatus() + } catch (error) { + console.error('刷新用户信息失败:', error) + } +} + +// 处理 QR 码扫码失败 +const handleQRCodeError = (error) => { + message.error(error?.message || 'Token 刷新失败') } diff --git a/frontend/src/components/QRCodeModal.vue b/frontend/src/components/QRCodeModal.vue index e587740..019bfda 100644 --- a/frontend/src/components/QRCodeModal.vue +++ b/frontend/src/components/QRCodeModal.vue @@ -1,17 +1,17 @@ diff --git a/frontend/src/composables/useBreakpoint.js b/frontend/src/composables/useBreakpoint.js new file mode 100644 index 0000000..7fb0542 --- /dev/null +++ b/frontend/src/composables/useBreakpoint.js @@ -0,0 +1,65 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +/** + * 响应式断点检测 Composable + * 基于 Ant Design 的断点系统 + * - xs: <576px (手机) + * - sm: ≥576px (平板竖屏) + * - md: ≥768px (平板横屏) + * - lg: ≥992px (桌面) + * - xl: ≥1200px (大屏) + * - xxl: ≥1600px (超大屏) + */ +export function useBreakpoint() { + const isMobile = ref(window.innerWidth < 768) + const isTablet = ref(window.innerWidth >= 768 && window.innerWidth < 992) + const isDesktop = ref(window.innerWidth >= 992) + + // Ant Design 断点 + const isXs = ref(window.innerWidth < 576) + const isSm = ref(window.innerWidth >= 576 && window.innerWidth < 768) + const isMd = ref(window.innerWidth >= 768 && window.innerWidth < 992) + const isLg = ref(window.innerWidth >= 992 && window.innerWidth < 1200) + const isXl = ref(window.innerWidth >= 1200 && window.innerWidth < 1600) + const isXxl = ref(window.innerWidth >= 1600) + + const updateBreakpoints = () => { + const width = window.innerWidth + + // 简化断点 + isMobile.value = width < 768 + isTablet.value = width >= 768 && width < 992 + isDesktop.value = width >= 992 + + // Ant Design 断点 + isXs.value = width < 576 + isSm.value = width >= 576 && width < 768 + isMd.value = width >= 768 && width < 992 + isLg.value = width >= 992 && width < 1200 + isXl.value = width >= 1200 && width < 1600 + isXxl.value = width >= 1600 + } + + onMounted(() => { + window.addEventListener('resize', updateBreakpoints) + }) + + onUnmounted(() => { + window.removeEventListener('resize', updateBreakpoints) + }) + + return { + // 简化断点(常用) + isMobile, + isTablet, + isDesktop, + + // Ant Design 断点(详细) + isXs, + isSm, + isMd, + isLg, + isXl, + isXxl, + } +} diff --git a/frontend/src/composables/useTokenMonitor.js b/frontend/src/composables/useTokenMonitor.js new file mode 100644 index 0000000..22f3e17 --- /dev/null +++ b/frontend/src/composables/useTokenMonitor.js @@ -0,0 +1,161 @@ +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { message } from 'ant-design-vue' +import { useAuthStore } from '@/stores/auth' +import { useUserStore } from '@/stores/user' +import { useRouter } from 'vue-router' + +/** + * Token 过期监控 Composable + * + * 功能: + * 1. 定时检查 Token 状态 + * 2. Token 过期后 5 分钟内提醒用户 + * 3. 为有密码的用户提供友好的过期处理 + */ + +let monitorTimer = null +let warningShown = false + +export function useTokenMonitor() { + const authStore = useAuthStore() + const userStore = useUserStore() + const router = useRouter() + + const tokenStatus = computed(() => userStore.tokenStatus) + const hasPassword = computed(() => authStore.user?.has_password || false) + + // 计算 Token 剩余分钟数 + const getRemainingMinutes = () => { + if (!tokenStatus.value?.expires_at) return null + + const now = Math.floor(Date.now() / 1000) + const expiresAt = tokenStatus.value.expires_at + const diffSeconds = expiresAt - now + + return Math.floor(diffSeconds / 60) + } + + // 检查 Token 状态并显示提醒 + const checkTokenStatus = async () => { + // 如果未登录,不检查 + if (!authStore.isAuthenticated) { + return + } + + try { + // 获取最新的 Token 状态 + await userStore.fetchTokenStatus() + + const remainingMinutes = getRemainingMinutes() + + // Token 已过期(负数分钟) + if (remainingMinutes !== null && remainingMinutes < 0) { + const expiredMinutes = Math.abs(remainingMinutes) + + // Token 过期后 5 分钟内提醒 + if (expiredMinutes <= 5) { + if (hasPassword.value) { + // 有密码的用户:友好提示 + if (!warningShown) { + message.warning({ + content: `您的登录凭证已过期 ${expiredMinutes} 分钟,部分功能可能受限。建议您扫码刷新凭证。`, + duration: 8, + key: 'token-expired-warning', + }) + warningShown = true + } + } else { + // 没有密码的用户:必须重新登录 + message.error({ + content: '您的登录凭证已过期,请重新扫码登录', + duration: 5, + key: 'token-expired-error', + }) + + // 清除登录状态并跳转 + authStore.logout() + router.push('/login') + } + } else if (expiredMinutes > 5) { + // 过期超过 5 分钟 + if (!hasPassword.value) { + // 没有密码的用户:强制退出 + authStore.logout() + router.push('/login') + } + } + } + // Token 即将过期(1小时内) + else if (remainingMinutes !== null && remainingMinutes > 0 && remainingMinutes <= 60) { + if (!warningShown) { + message.warning({ + content: `您的登录凭证将在 ${remainingMinutes} 分钟后过期,建议您提前刷新`, + duration: 6, + key: 'token-expiring-warning', + }) + warningShown = true + } + } + // Token 状态正常 + else if (remainingMinutes !== null && remainingMinutes > 60) { + // 重置警告标志 + warningShown = false + } + + } catch (error) { + console.error('检查 Token 状态失败:', error) + } + } + + // 启动监控 + const startMonitoring = () => { + // 避免重复启动 + if (monitorTimer) { + return + } + + // 立即检查一次 + checkTokenStatus() + + // 每 2 分钟检查一次 + monitorTimer = setInterval(() => { + checkTokenStatus() + }, 2 * 60 * 1000) // 2 分钟 + } + + // 停止监控 + const stopMonitoring = () => { + if (monitorTimer) { + clearInterval(monitorTimer) + monitorTimer = null + } + warningShown = false + } + + // 手动触发检查 + const checkNow = () => { + warningShown = false // 重置警告标志,允许再次显示 + checkTokenStatus() + } + + // 组件挂载时启动监控 + onMounted(() => { + if (authStore.isAuthenticated) { + startMonitoring() + } + }) + + // 组件卸载时停止监控 + onUnmounted(() => { + stopMonitoring() + }) + + return { + tokenStatus, + hasPassword, + startMonitoring, + stopMonitoring, + checkNow, + getRemainingMinutes, + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js index 4b1a912..8716bd8 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,23 +1,12 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' -import ElementPlus from 'element-plus' -import 'element-plus/dist/index.css' -import zhCn from 'element-plus/dist/locale/zh-cn.mjs' -import { - User, - Key, - Calendar, - Refresh, - Document, - List, - Plus, - UserFilled, - DataAnalysis, - Loading, - SuccessFilled, - WarningFilled, - CircleCloseFilled -} from '@element-plus/icons-vue' + +// Ant Design Vue +import Antd from 'ant-design-vue' +import 'ant-design-vue/dist/reset.css' +import zhCN from 'ant-design-vue/es/locale/zh_CN' +import { ConfigProvider } from 'ant-design-vue' +import antdTheme from './antd-theme' import App from './App.vue' import router from './router' @@ -26,29 +15,16 @@ import './style.css' const app = createApp(App) const pinia = createPinia() -// 按需注册 Element Plus 图标(仅注册使用的13个) -const icons = { - User, - Key, - Calendar, - Refresh, - Document, - List, - Plus, - UserFilled, - DataAnalysis, - Loading, - SuccessFilled, - WarningFilled, - CircleCloseFilled -} - -for (const [key, component] of Object.entries(icons)) { - app.component(key, component) -} - app.use(pinia) app.use(router) -app.use(ElementPlus, { locale: zhCn }) + +// Ant Design Vue with custom theme +app.use(Antd) + +// Configure Ant Design globally +app.config.globalProperties.$antdConfig = { + theme: antdTheme, + locale: zhCN, +} app.mount('#app') diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index 81a835b..300ec58 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -34,16 +34,6 @@ export const useAdminStore = defineStore('admin', { } }, - // 批量启用/禁用用户 - async batchToggleActive(userIds, isActive) { - try { - const result = await adminAPI.batchToggleActive(userIds, isActive) - return result - } catch (error) { - throw new Error(error.message || '批量操作失败') - } - }, - // 批量触发打卡 async batchCheckIn(userIds) { try { diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 48d0b6c..cb91a86 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -91,6 +91,15 @@ export const useAuthStore = defineStore('auth', { } }, + // 取消扫码会话 + async cancelQRCodeSession(sessionId) { + try { + await authAPI.cancelQRCodeSession(sessionId) + } catch (error) { + console.error('取消会话失败:', error) + } + }, + // 验证 Token async verifyToken(token) { try { diff --git a/frontend/src/stores/task.js b/frontend/src/stores/task.js index d14b0d4..13d1eb8 100644 --- a/frontend/src/stores/task.js +++ b/frontend/src/stores/task.js @@ -107,7 +107,13 @@ export const useTaskStore = defineStore('task', { const updatedTask = await api.task.toggleTask(taskId) const index = this.tasks.findIndex(t => t.id === taskId) if (index !== -1) { - this.tasks[index] = updatedTask + // 保留原任务的 last_check_in_time 和 last_check_in_status + const originalTask = this.tasks[index] + this.tasks[index] = { + ...updatedTask, + last_check_in_time: updatedTask.last_check_in_time || originalTask.last_check_in_time, + last_check_in_status: updatedTask.last_check_in_status || originalTask.last_check_in_status, + } } return updatedTask } catch (error) { diff --git a/frontend/src/style.css b/frontend/src/style.css index a044f71..49fb475 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -3,6 +3,9 @@ @tailwind components; @tailwind utilities; +/* Ant Design Vue Reset (imported in main.js via import, keeping this comment for reference) */ +/* The actual import is: import 'ant-design-vue/dist/reset.css' */ + /* Global styles */ @layer base { :root { @@ -131,15 +134,6 @@ } } -/* Element Plus customization to work with Tailwind */ -.el-button { - @apply transition-smooth; -} - -.el-card { - @apply transition-smooth; -} - /* Scrollbar styling */ ::-webkit-scrollbar { width: 8px; @@ -153,3 +147,297 @@ ::-webkit-scrollbar-thumb { @apply bg-gray-300 rounded hover:bg-gray-400; } + +/* ======================================== + Ant Design Vue Customization + ======================================== */ + +/* Ant Design Card - Match Material Design 3 style */ +.ant-card { + border-radius: 12px; + box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.08); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.ant-card:hover { + box-shadow: 0 4px 8px 3px rgba(0, 0, 0, 0.10); +} + +/* Fluent glass effect for Ant Design cards */ +.ant-card.fluent-card { + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +/* Ant Design Button - Match MD3 rounded style */ +.ant-btn { + border-radius: 24px; + font-weight: 500; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.ant-btn .anticon { + display: inline-flex; + align-items: center; + vertical-align: middle; + line-height: 1; +} + +.ant-btn-primary { + background: var(--md-sys-color-primary); + border-color: var(--md-sys-color-primary); +} + +.ant-btn-primary:hover { + background: #45a049; + border-color: #45a049; + box-shadow: 0 2px 4px rgba(76, 175, 80, 0.3); +} + +/* Ant Design Input - Match MD3 style */ +.ant-input, +.ant-input-password, +.ant-select-selector { + border-radius: 12px; + transition: all 0.2s ease; +} + +.ant-input:focus, +.ant-input-password:focus, +.ant-select-focused .ant-select-selector { + border-color: var(--md-sys-color-primary); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); +} + +/* Ant Design Modal - Match MD3 style */ +.ant-modal-content { + border-radius: 16px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); +} + +.ant-modal-header { + border-radius: 16px 16px 0 0; +} + +/* Ant Design Table - Match current style */ +.ant-table { + border-radius: 12px; +} + +.ant-table-thead > tr > th { + background: #f5f7fa; + font-weight: 600; +} + +/* Ant Design Tabs */ +.ant-tabs { + color: var(--md-sys-color-on-surface); +} + +.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn { + color: var(--md-sys-color-primary); +} + +.ant-tabs-ink-bar { + background: var(--md-sys-color-primary); +} + +/* Ant Design Tag - Match current style */ +.ant-tag { + border-radius: 16px; + font-weight: 500; +} + +/* Ant Design Progress */ +.ant-progress-success-bg, +.ant-progress-bg { + background-color: var(--md-sys-color-primary); +} + +/* Ant Design Descriptions */ +.ant-descriptions-bordered .ant-descriptions-item-label { + background: #f5f7fa; +} + +/* Ant Design Statistic */ +.ant-statistic-title { + color: #64748b; +} + +.ant-statistic-content { + color: var(--md-sys-color-on-surface); +} + +/* Ant Design Drawer */ +.ant-drawer-content { + border-radius: 16px 0 0 16px; +} + +.ant-drawer-header { + border-bottom: 1px solid #e5e7eb; +} + +/* Ant Design Alert - Match current style */ +.ant-alert { + border-radius: 12px; +} + +/* Ant Design Pagination */ +.ant-pagination-item-active { + border-color: var(--md-sys-color-primary); +} + +.ant-pagination-item-active a { + color: var(--md-sys-color-primary); +} + +/* Responsive utilities for Ant Design */ +@media (max-width: 768px) { + .ant-modal { + max-width: 100vw !important; + margin: 0; + } + + .ant-modal-content { + border-radius: 0; + } + + .ant-drawer-content-wrapper { + width: 280px !important; + } + + /* 移动端表格优化 */ + .ant-table { + font-size: 13px; + } + + .ant-table-thead > tr > th { + padding: 8px 12px; + font-size: 13px; + } + + .ant-table-tbody > tr > td { + padding: 8px 12px; + font-size: 13px; + } + + /* 移动端表单优化 */ + .ant-form-item { + margin-bottom: 16px; + } + + .ant-form-item-label > label { + font-size: 13px; + } + + /* 移动端卡片优化 */ + .ant-card { + border-radius: 8px; + } + + .ant-card-head { + padding: 12px 16px; + } + + .ant-card-body { + padding: 16px; + } + + /* 移动端按钮优化 */ + .ant-btn { + height: 36px; + padding: 4px 15px; + font-size: 14px; + } + + .ant-btn-lg { + height: 40px; + } + + /* 移动端描述列表优化 */ + .ant-descriptions-item-label, + .ant-descriptions-item-content { + padding: 8px 12px; + font-size: 13px; + } +} + +/* 小屏手机优化 */ +@media (max-width: 576px) { + /* 更小的内边距 */ + .ant-card-head { + padding: 10px 12px; + font-size: 15px; + } + + .ant-card-body { + padding: 12px; + } + + /* 更小的表格 */ + .ant-table { + font-size: 12px; + } + + .ant-table-thead > tr > th, + .ant-table-tbody > tr > td { + padding: 6px 8px; + font-size: 12px; + } + + /* 更小的按钮 */ + .ant-btn { + height: 32px; + padding: 4px 12px; + font-size: 13px; + } + + .ant-btn-lg { + height: 36px; + font-size: 14px; + } + + /* Tag 优化 */ + .ant-tag { + font-size: 11px; + padding: 0 6px; + } + + /* 选择器优化 */ + .ant-select { + font-size: 13px; + } + + .ant-select-selection-item { + font-size: 13px; + } +} + +/* 横屏优化 */ +@media (max-height: 600px) and (orientation: landscape) { + .ant-modal-body { + max-height: 60vh; + overflow-y: auto; + } + + .ant-drawer-body { + padding: 12px 16px; + } +} + +/* 平板优化 */ +@media (min-width: 768px) and (max-width: 992px) { + .ant-card-body { + padding: 20px; + } + + .ant-table-thead > tr > th, + .ant-table-tbody > tr > td { + padding: 10px 14px; + } +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index b5c5b8d..bf934bc 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -1,67 +1,69 @@