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 @@
-
@@ -19,43 +19,43 @@
请使用手机 QQ 扫描二维码登录
-
+
{{ countdown }}s
-
-
-
+
二维码已过期
-
刷新二维码
+
刷新二维码
-
-
-
+
{{ errorMessage }}
-
重试
+
重试
-
+
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 @@
-
+
-
-
-
+
+
+
-
-
-
+
+
+
{{ tokenStatus.is_valid ? '有效' : '无效' }}
-
-
+
+
-
+
{{ formatExpireTime }}
-
+
-
-
+
+
{{ formatRemainTime }}
-
- 已过期
-
+
+ 已过期
+
-
-
+
+
+ 已过期
+
+
{{ tokenStatus.expiring_soon ? '是' : '否' }}
-
-
-
+
+
+
-
- 您的 Token 将在 30 分钟内过期,请及时重新登录!
-
+ />
-
-
+
+
-
-
-
+
+
+
@@ -70,101 +72,103 @@
选择任务并点击下方按钮立即执行打卡操作
-
-
-
+
{{ task.name }}
-
启用
+
+ {{ task.is_active ? '启用' : '禁用' }}
+
-
-
+
+
-
+
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
-
+
-
+
上次打卡
-
-
+
+
{{ formatDateTime(lastCheckIn.check_in_time) }}
-
-
-
+
+
{{
lastCheckIn.status === 'success' ? '成功' :
lastCheckIn.status === 'out_of_time' ? '时间范围外' :
lastCheckIn.status === 'unknown' ? '异常' : '失败'
}}
-
-
-
+
+
+
{{ lastCheckIn.response_text || lastCheckIn.error_message || '-' }}
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
{{ authStore.user?.alias }}
-
-
-
+
+
+
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
-
-
-
+
+
+
{{ authStore.user?.email || '未设置' }}
-
-
+
+
{{ formatDateTime(authStore.user?.created_at, false) }}
-
-
-
-
-
+
+
+
+
+
@@ -371,4 +377,17 @@ onMounted(async () => {
.status-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
+
+/* 修复按钮图标对齐 */
+:deep(.ant-btn) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+:deep(.ant-btn .anticon) {
+ display: inline-flex;
+ align-items: center;
+ vertical-align: middle;
+}
diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue
index 7944d96..5c971a2 100644
--- a/frontend/src/views/LoginView.vue
+++ b/frontend/src/views/LoginView.vue
@@ -1,132 +1,135 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ loading ? '正在登录...' : '扫码登录/注册' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ loading ? '登录中...' : '登录' }}
-
-
-
-
-
- 没有密码?使用扫码登录
-
-
-
-
-
-
-
- 1. 输入您的用户名(用于标识身份)
- 2. 点击"扫码登录/注册"按钮
- 3. 使用手机 QQ 扫描弹出的二维码
- 4. 扫码成功后即可登录系统
- 💡 新用户首次扫码将自动注册账户
+
+
+
+
+
-
- 1. 输入您的用户名和密码
- 2. 点击"登录"按钮直接登录
- 3. 首次使用请先扫码登录/注册,然后在设置中设置密码
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loading ? '正在登录...' : '扫码登录/注册' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loading ? '登录中...' : '登录' }}
+
+
+
+
+
+
+
+
+
+
+ 1. 输入您的用户名(用于标识身份)
+ 2. 点击"扫码登录/注册"按钮
+ 3. 使用手机 QQ 扫描弹出的二维码
+ 4. 扫码成功后即可登录系统
+ 💡 新用户首次扫码将自动注册账户
+
+
+ 1. 输入您的用户名和密码
+ 2. 点击"登录"按钮直接登录
+ 3. 首次使用请先扫码登录/注册,然后在设置中设置密码
+
+
+
+
+
+
+
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
-import { ElMessage } from 'element-plus'
-import { User, Key } from '@element-plus/icons-vue'
+import { message } from 'ant-design-vue'
+import { UserOutlined, KeyOutlined } from '@ant-design/icons-vue'
import { authAPI } from '@/api'
import { useAuthStore } from '@/stores/auth'
import QRCodeModal from '@/components/QRCodeModal.vue'
@@ -209,9 +212,7 @@ const handleQRCodeLogin = async () => {
if (!qrcodeFormRef.value) return
try {
- const valid = await qrcodeFormRef.value.validate()
- if (!valid) return
-
+ await qrcodeFormRef.value.validate()
// 显示 QR 码弹窗
qrcodeVisible.value = true
} catch (error) {
@@ -224,8 +225,7 @@ const handlePasswordLogin = async () => {
if (!passwordFormRef.value) return
try {
- const valid = await passwordFormRef.value.validate()
- if (!valid) return
+ await passwordFormRef.value.validate()
loading.value = true
@@ -242,18 +242,39 @@ const handlePasswordLogin = async () => {
role: response.role || 'user',
is_approved: response.is_approved !== false,
}
- authStore.setAuth(response.authorization, user)
+
+ // 如果没有 authorization(测试账号),使用 user_id 作为认证凭据
+ const authToken = response.authorization || `user_id:${response.user_id}`
+ authStore.setAuth(authToken, user)
+
+ // 只有当有真实 authorization 时才获取完整用户信息
+ if (response.authorization) {
+ try {
+ await authStore.fetchCurrentUser()
+ } catch (err) {
+ console.warn('获取完整用户信息失败,使用基本信息:', err)
+ // 即使失败也继续登录流程
+ }
+ } else {
+ // 没有 authorization 的测试账号,提示用户需要扫码绑定
+ message.info({
+ content: '您正在使用密码登录模式。如需使用打卡功能,请先扫码绑定 QQ。',
+ duration: 5,
+ })
+ }
// 如果有 Token 警告,显示提示
if (response.token_warning && response.warning_message) {
- ElMessage({
- type: 'warning',
- duration: 5000,
- showClose: true,
- message: response.warning_message,
+ message.warning({
+ content: response.warning_message,
+ duration: 5,
})
+ } else if (response.authorization) {
+ // 只有有 token 的用户才显示"欢迎回来"
+ message.success(`欢迎回来,${response.alias}!`)
} else {
- ElMessage.success(`欢迎回来,${response.alias}!`)
+ // 测试账号登录成功提示
+ message.success(`登录成功,${response.alias}!`)
}
// 跳转到重定向页面或仪表盘
@@ -273,36 +294,36 @@ const handlePasswordLogin = async () => {
}
// 处理密码登录错误
-const handlePasswordLoginError = (message) => {
- if (!message) {
- ElMessage.error('登录失败,请稍后重试')
+const handlePasswordLoginError = (msg) => {
+ if (!msg) {
+ message.error('登录失败,请稍后重试')
return
}
// 用户不存在或密码错误
- if (message.includes('用户名或密码错误')) {
- ElMessage.error('用户名或密码错误')
+ if (msg.includes('用户名或密码错误')) {
+ message.error('用户名或密码错误')
return
}
// 未设置密码
- if (message.includes('未设置密码')) {
- ElMessage.warning('该账户未设置密码,请使用扫码登录')
+ if (msg.includes('未设置密码')) {
+ message.warning('该账户未设置密码,请使用扫码登录')
return
}
// 用户不存在
- if (message.includes('用户不存在')) {
- ElMessage.error('用户不存在,请检查用户名或使用扫码登录注册')
+ if (msg.includes('用户不存在')) {
+ message.error('用户不存在,请检查用户名或使用扫码登录注册')
return
}
// 其他错误
- ElMessage.error(message || '登录失败,请稍后重试')
+ message.error(msg || '登录失败,请稍后重试')
}
const handleLoginSuccess = (user) => {
- ElMessage.success(`欢迎回来,${user.alias}!`)
+ message.success(`欢迎回来,${user.alias}!`)
// 跳转到重定向页面或仪表盘
const redirect = route.query.redirect || '/dashboard'
@@ -310,24 +331,33 @@ const handleLoginSuccess = (user) => {
}
const handleLoginError = (error) => {
- ElMessage.error(error.message || '登录失败')
+ message.error(error.message || '登录失败')
}
diff --git a/frontend/src/views/NotFoundView.vue b/frontend/src/views/NotFoundView.vue
index 0c5973d..0e3d6ba 100644
--- a/frontend/src/views/NotFoundView.vue
+++ b/frontend/src/views/NotFoundView.vue
@@ -1,10 +1,10 @@
diff --git a/frontend/src/views/PendingApprovalView.vue b/frontend/src/views/PendingApprovalView.vue
index eabac3a..4537b97 100644
--- a/frontend/src/views/PendingApprovalView.vue
+++ b/frontend/src/views/PendingApprovalView.vue
@@ -16,55 +16,148 @@
您已成功注册,账户信息如下:
-
-
-
用户名
-
{{ user?.alias || '加载中...' }}
-
-
-
注册时间
-
{{ formatDate(user?.created_at) }}
-
-
-
+
+
+ {{ user?.alias || '加载中...' }}
+
+
+
+ {{ user.email }}
+
+
+ 未设置
+
+
+
+
+ 已设置
+
+
+ 未设置
+
+
+
+ {{ formatDate(user?.created_at) }}
+
+
+ 待审批
+
+
-
-
⚠️ 审批说明
-
- - 管理员将在 24 小时内 审核您的注册申请
- - 审核通过后,您将可以使用所有功能
- - 如超过 24 小时未审批,账户将被自动删除
- - 您可以随时刷新此页面查看最新状态
-
-
+
+
+
+ - 管理员将在 24 小时内 审核您的注册申请
+ - 审核通过后,您将可以使用所有功能
+ - 如超过 24 小时未审批,账户将被自动删除
+ - 建议:审批期间可以设置邮箱和密码,方便后续使用
+ - 您可以随时刷新此页面查看最新状态
+
+
+
-
-
+
+
+
+
+
+
+
+ 建议设置邮箱,方便接收审批结果通知
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -160,66 +339,10 @@ onMounted(() => {
margin-bottom: 30px;
}
-.info-table {
- background: #f9f9f9;
- border: 1px solid #ddd;
- border-radius: 5px;
- overflow: hidden;
+.mb-6 {
margin-bottom: 30px;
}
-.info-row {
- display: flex;
- border-bottom: 1px solid #ddd;
-}
-
-.info-row:last-child {
- border-bottom: none;
-}
-
-.info-label {
- flex: 0 0 120px;
- padding: 15px 20px;
- background: #f5f5f5;
- font-weight: bold;
- color: #303133;
- border-right: 1px solid #ddd;
-}
-
-.info-value {
- flex: 1;
- padding: 15px 20px;
- color: #606266;
-}
-
-.status-tag {
- display: inline-block;
- padding: 4px 12px;
- border-radius: 4px;
- font-size: 14px;
- font-weight: 500;
-}
-
-.status-tag.warning {
- background: #fff3cd;
- color: #856404;
- border: 1px solid #ffc107;
-}
-
-.alert-box {
- background: #e7f3ff;
- border-left: 4px solid #409eff;
- padding: 20px;
- margin-bottom: 30px;
- border-radius: 4px;
-}
-
-.alert-title {
- font-weight: bold;
- margin-bottom: 10px;
- color: #303133;
-}
-
.tips-list {
text-align: left;
padding-left: 20px;
@@ -236,34 +359,12 @@ onMounted(() => {
display: flex;
gap: 15px;
justify-content: center;
+ flex-wrap: wrap;
}
-.btn {
- padding: 12px 30px;
- border: none;
- border-radius: 5px;
- font-size: 16px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.3s;
-}
-
-.btn-primary {
- background: #409eff;
- color: white;
-}
-
-.btn-primary:hover {
- background: #66b1ff;
-}
-
-.btn-default {
- background: #f5f5f5;
- color: #606266;
- border: 1px solid #dcdfe6;
-}
-
-.btn-default:hover {
- background: #e8e8e8;
+.form-hint {
+ font-size: 12px;
+ color: #909399;
+ margin-top: 4px;
}
diff --git a/frontend/src/views/RecordsView.vue b/frontend/src/views/RecordsView.vue
index a245d30..555d21b 100644
--- a/frontend/src/views/RecordsView.vue
+++ b/frontend/src/views/RecordsView.vue
@@ -1,107 +1,138 @@
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
-
-
+
+
-
+
-
-
+
-
-
-
-
- {{ formatDateTime(row.check_in_time) }}
+
+
+ {{ formatDateTime(record.check_in_time) }}
-
-
-
-
- ✅ 打卡成功
- 🕐 时间范围外
- ❗ 打卡异常
- ❌ 打卡失败
+
+ ✅ 打卡成功
+ 🕐 时间范围外
+ ❗ 打卡异常
+ ❌ 打卡失败
-
-
-
-
- 手动
- 定时
- 管理员
- {{ row.trigger_type }}
+
+ 手动
+ 定时
+ 管理员
+ {{ record.trigger_type }}
-
+
+
-
-
+
+
+
+
+ {{ record.id }}
+
+ {{ formatDateTime(record.check_in_time) }}
+
+
+ ✅ 打卡成功
+ 🕐 时间范围外
+ ❗ 打卡异常
+ ❌ 打卡失败
+
+
+ 手动
+ 定时
+ 管理员
+ {{ record.trigger_type }}
+
+
+ {{ record.response_text || '-' }}
+
+
+
+
-
+
diff --git a/frontend/src/views/admin/LogsView.vue b/frontend/src/views/admin/LogsView.vue
index d09ab50..7643541 100644
--- a/frontend/src/views/admin/LogsView.vue
+++ b/frontend/src/views/admin/LogsView.vue
@@ -1,40 +1,40 @@
-
-
+
+
-
- 显示最新的系统日志信息(默认显示最近 200 行)
-
+ />
-
@@ -42,15 +42,15 @@
最后更新: {{ lastUpdate }}
-
+