liuq 3 месяцев назад
Родитель
Сommit
ead7db2a58

+ 2 - 1
backend/app/api/v1/api.py

@@ -1,12 +1,13 @@
 from fastapi import APIRouter
 from fastapi import APIRouter
 
 
-from app.api.v1.endpoints import auth, users, apps, utils, simple_auth, oidc, open_api, logs, system_logs, backup
+from app.api.v1.endpoints import auth, users, apps, utils, simple_auth, oidc, open_api, logs, system_logs, backup, login_logs
 
 
 api_router = APIRouter()
 api_router = APIRouter()
 api_router.include_router(auth.router, prefix="/auth", tags=["认证 (Auth)"])
 api_router.include_router(auth.router, prefix="/auth", tags=["认证 (Auth)"])
 api_router.include_router(users.router, prefix="/users", tags=["用户管理 (Users)"])
 api_router.include_router(users.router, prefix="/users", tags=["用户管理 (Users)"])
 api_router.include_router(apps.router, prefix="/apps", tags=["应用管理 (Applications)"])
 api_router.include_router(apps.router, prefix="/apps", tags=["应用管理 (Applications)"])
 api_router.include_router(logs.router, prefix="/logs", tags=["操作日志 (Logs)"])
 api_router.include_router(logs.router, prefix="/logs", tags=["操作日志 (Logs)"])
+api_router.include_router(login_logs.router, prefix="/login-logs", tags=["登录日志 (Login Logs)"])
 api_router.include_router(system_logs.router, prefix="/system-logs", tags=["后台日志 (System Logs)"])
 api_router.include_router(system_logs.router, prefix="/system-logs", tags=["后台日志 (System Logs)"])
 api_router.include_router(backup.router, prefix="/backups", tags=["数据备份 (Backup)"])
 api_router.include_router(backup.router, prefix="/backups", tags=["数据备份 (Backup)"])
 api_router.include_router(utils.router, prefix="/utils", tags=["工具 (Utils)"])
 api_router.include_router(utils.router, prefix="/utils", tags=["工具 (Utils)"])

+ 55 - 0
backend/app/api/v1/endpoints/login_logs.py

@@ -0,0 +1,55 @@
+from typing import Any, Optional
+from datetime import datetime
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from app.api.v1 import deps
+from app.models.user import User, UserRole
+from app.models.login_log import LoginMethod, AuthType
+from app.schemas.login_log import LoginLogListResponse
+from app.services.login_log_service import LoginLogService
+
+router = APIRouter()
+
+@router.get("/", response_model=LoginLogListResponse, summary="获取登录日志列表")
+def get_login_logs(
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+    skip: int = 0,
+    limit: int = 20,
+    mobile: Optional[str] = None,
+    ip_address: Optional[str] = None,
+    status: Optional[int] = Query(None, description="1: Success, 0: Failed"),
+    login_method: Optional[LoginMethod] = None,
+    auth_type: Optional[AuthType] = None,
+    start_date: Optional[datetime] = None,
+    end_date: Optional[datetime] = None
+) -> Any:
+    """
+    获取统一认证登录日志。
+    只有超级管理员可以查看所有日志。
+    普通用户只能查看自己的日志(待定,目前假设只有管理员看运维日志)。
+    """
+    # 鉴权:只有 SUPER_ADMIN 和 DEVELOPER 可以查看运维日志?
+    # 根据需求描述“运维管理里面有一个统一登录日志”,通常隐含管理员权限。
+    if current_user.role not in [UserRole.SUPER_ADMIN, UserRole.DEVELOPER]:
+        raise HTTPException(status_code=403, detail="权限不足")
+    
+    total, items = LoginLogService.get_logs(
+        db=db,
+        skip=skip,
+        limit=limit,
+        mobile=mobile,
+        ip_address=ip_address,
+        status=status,
+        login_method=login_method,
+        auth_type=auth_type,
+        start_date=start_date,
+        end_date=end_date
+    )
+    
+    return {
+        "total": total,
+        "items": items
+    }
+

+ 92 - 12
backend/app/api/v1/endpoints/simple_auth.py

@@ -20,7 +20,9 @@ from app.schemas.simple_auth import (
 from app.services.signature_service import SignatureService
 from app.services.signature_service import SignatureService
 from app.services.ticket_service import TicketService
 from app.services.ticket_service import TicketService
 from app.services.log_service import LogService
 from app.services.log_service import LogService
+from app.services.login_log_service import LoginLogService
 from app.schemas.operation_log import ActionType
 from app.schemas.operation_log import ActionType
+from app.schemas.login_log import LoginLogCreate, LoginMethod, AuthType
 from fastapi import Request
 from fastapi import Request
 
 
 router = APIRouter()
 router = APIRouter()
@@ -28,6 +30,7 @@ router = APIRouter()
 @router.post("/login", response_model=PasswordLoginResponse, summary="密码登录")
 @router.post("/login", response_model=PasswordLoginResponse, summary="密码登录")
 def login_with_password(
 def login_with_password(
     req: PasswordLoginRequest,
     req: PasswordLoginRequest,
+    request: Request,
     db: Session = Depends(deps.get_db),
     db: Session = Depends(deps.get_db),
 ):
 ):
     """
     """
@@ -37,27 +40,49 @@ def login_with_password(
     
     
     # --- Platform Login ---
     # --- Platform Login ---
     if not req.app_id:
     if not req.app_id:
+        # Prepare Log
+        log_create = LoginLogCreate(
+            mobile=req.identifier,
+            ip_address=request.client.host,
+            login_method=LoginMethod.UNIFIED_PAGE,
+            auth_type=AuthType.PASSWORD,
+            user_agent=request.headers.get("user-agent")
+        )
+
         # Find user by mobile only
         # Find user by mobile only
         user = db.query(User).filter(User.mobile == req.identifier, User.is_deleted == 0).first()
         user = db.query(User).filter(User.mobile == req.identifier, User.is_deleted == 0).first()
         if not user:
         if not user:
+            log_create.is_success = 0
+            log_create.failure_reason = "用户未找到"
+            LoginLogService.create_log(db, log_create)
             raise HTTPException(status_code=404, detail="用户未找到")
             raise HTTPException(status_code=404, detail="用户未找到")
-            
+        
+        log_create.user_id = user.id
+
         is_valid = security.verify_password(req.password, user.password_hash)
         is_valid = security.verify_password(req.password, user.password_hash)
         if not is_valid:
         if not is_valid:
             import logging
             import logging
             logger = logging.getLogger(__name__)
             logger = logging.getLogger(__name__)
             logger.error(f"Platform Login failed for user {user.mobile}")
             logger.error(f"Platform Login failed for user {user.mobile}")
-            logger.error(f"Input password length: {len(req.password)}")
-            logger.error(f"Stored hash: {user.password_hash}")
-            if req.password.strip() != req.password:
-                logger.error("WARNING: Input password has leading/trailing whitespace!")
+            
+            log_create.is_success = 0
+            log_create.failure_reason = "密码错误"
+            LoginLogService.create_log(db, log_create)
+            
             raise HTTPException(status_code=401, detail="密码错误")
             raise HTTPException(status_code=401, detail="密码错误")
             
             
         if user.status != UserStatus.ACTIVE:
         if user.status != UserStatus.ACTIVE:
+            log_create.is_success = 0
+            log_create.failure_reason = "用户已禁用"
+            LoginLogService.create_log(db, log_create)
             raise HTTPException(status_code=400, detail="用户已禁用")
             raise HTTPException(status_code=400, detail="用户已禁用")
 
 
         # Generate JWT Access Token
         # Generate JWT Access Token
         access_token = security.create_access_token(user.id)
         access_token = security.create_access_token(user.id)
+        
+        # Log Success
+        LoginLogService.create_log(db, log_create)
+
         return {
         return {
             "access_token": access_token, 
             "access_token": access_token, 
             "token_type": "bearer",
             "token_type": "bearer",
@@ -65,9 +90,20 @@ def login_with_password(
         }
         }
 
 
     # --- App SSO Login ---
     # --- App SSO Login ---
+    log_create = LoginLogCreate(
+        mobile=req.identifier,
+        ip_address=request.client.host,
+        login_method=LoginMethod.CUSTOM_PAGE, # 假设应用自定义页面调用此接口
+        auth_type=AuthType.PASSWORD,
+        user_agent=request.headers.get("user-agent")
+    )
+
     # 1. Verify App
     # 1. Verify App
     app = db.query(Application).filter(Application.app_id == req.app_id).first()
     app = db.query(Application).filter(Application.app_id == req.app_id).first()
     if not app:
     if not app:
+        log_create.is_success = 0
+        log_create.failure_reason = "应用未找到"
+        LoginLogService.create_log(db, log_create)
         raise HTTPException(status_code=404, detail="应用未找到")
         raise HTTPException(status_code=404, detail="应用未找到")
 
 
     # 2. Verify Signature (Optional but recommended for server-side calls)
     # 2. Verify Signature (Optional but recommended for server-side calls)
@@ -80,6 +116,9 @@ def login_with_password(
             "sign": req.sign
             "sign": req.sign
         }
         }
         if not SignatureService.verify_signature(app.app_secret, params, req.sign):
         if not SignatureService.verify_signature(app.app_secret, params, req.sign):
+            log_create.is_success = 0
+            log_create.failure_reason = "签名无效"
+            LoginLogService.create_log(db, log_create)
             raise HTTPException(status_code=400, detail="签名无效")
             raise HTTPException(status_code=400, detail="签名无效")
 
 
     # 3. Find User
     # 3. Find User
@@ -103,9 +142,17 @@ def login_with_password(
             user = db.query(User).filter(User.id == mapping.user_id, User.is_deleted == 0).first()
             user = db.query(User).filter(User.id == mapping.user_id, User.is_deleted == 0).first()
 
 
     if not user:
     if not user:
+        log_create.is_success = 0
+        log_create.failure_reason = "用户未找到"
+        LoginLogService.create_log(db, log_create)
         raise HTTPException(status_code=404, detail="用户未找到")
         raise HTTPException(status_code=404, detail="用户未找到")
 
 
+    log_create.user_id = user.id
+
     if user.status != UserStatus.ACTIVE:
     if user.status != UserStatus.ACTIVE:
+        log_create.is_success = 0
+        log_create.failure_reason = "用户已禁用"
+        LoginLogService.create_log(db, log_create)
         raise HTTPException(status_code=400, detail="用户已禁用")
         raise HTTPException(status_code=400, detail="用户已禁用")
 
 
     # 4. Verify Password
     # 4. Verify Password
@@ -116,17 +163,19 @@ def login_with_password(
     is_valid = security.verify_password(req.password, user.password_hash)
     is_valid = security.verify_password(req.password, user.password_hash)
     if not is_valid:
     if not is_valid:
         logger.error(f"Password verification failed for user {user.mobile}")
         logger.error(f"Password verification failed for user {user.mobile}")
-        logger.error(f"Input password length: {len(req.password)}")
-        logger.error(f"Stored hash: {user.password_hash}")
-        # Check for surrounding whitespace
-        if req.password.strip() != req.password:
-            logger.error("WARNING: Input password has leading/trailing whitespace!")
-            
-    if not is_valid:
+        
+        log_create.is_success = 0
+        log_create.failure_reason = "密码错误"
+        LoginLogService.create_log(db, log_create)
+        
         raise HTTPException(status_code=401, detail="密码错误")
         raise HTTPException(status_code=401, detail="密码错误")
 
 
     # 5. Generate Ticket (Self-Targeting)
     # 5. Generate Ticket (Self-Targeting)
     ticket = TicketService.generate_ticket(user.id, req.app_id)
     ticket = TicketService.generate_ticket(user.id, req.app_id)
+    
+    # Log Success (AuthType is PASSWORD leading to TICKET generation, keeping PASSWORD is fine or TICKET)
+    # User requirement: "包括...认证方式". Here the auth method was PASSWORD.
+    LoginLogService.create_log(db, log_create)
 
 
     return {"ticket": ticket}
     return {"ticket": ticket}
 
 
@@ -356,6 +405,7 @@ def exchange_ticket(
 @router.post("/sso-login", response_model=SsoLoginResponse, summary="SSO 登录 (简易模式)")
 @router.post("/sso-login", response_model=SsoLoginResponse, summary="SSO 登录 (简易模式)")
 def sso_login(
 def sso_login(
     req: SsoLoginRequest,
     req: SsoLoginRequest,
+    request: Request,
     db: Session = Depends(deps.get_db),
     db: Session = Depends(deps.get_db),
     current_user: Optional[User] = Depends(deps.get_current_active_user_optional),
     current_user: Optional[User] = Depends(deps.get_current_active_user_optional),
 ):
 ):
@@ -368,10 +418,26 @@ def sso_login(
     """
     """
     # 1. Verify App
     # 1. Verify App
     app = db.query(Application).filter(Application.app_id == req.app_id).first()
     app = db.query(Application).filter(Application.app_id == req.app_id).first()
+    
+    # Prepare Log
+    log_create = LoginLogCreate(
+        ip_address=request.client.host,
+        login_method=LoginMethod.DIRECT_JUMP,
+        auth_type=AuthType.SSO,
+        user_agent=request.headers.get("user-agent"),
+        mobile=req.username
+    )
+    
     if not app:
     if not app:
+        log_create.is_success = 0
+        log_create.failure_reason = "应用未找到"
+        LoginLogService.create_log(db, log_create)
         raise HTTPException(status_code=404, detail="应用未找到")
         raise HTTPException(status_code=404, detail="应用未找到")
 
 
     if app.protocol_type != "SIMPLE_API":
     if app.protocol_type != "SIMPLE_API":
+         log_create.is_success = 0
+         log_create.failure_reason = "协议不支持"
+         LoginLogService.create_log(db, log_create)
          raise HTTPException(status_code=400, detail="SSO 登录仅支持简易 API 应用。OIDC 请使用标准流程。")
          raise HTTPException(status_code=400, detail="SSO 登录仅支持简易 API 应用。OIDC 请使用标准流程。")
 
 
     user = None
     user = None
@@ -379,9 +445,13 @@ def sso_login(
     # 2. Try Session Login first
     # 2. Try Session Login first
     if current_user:
     if current_user:
         user = current_user
         user = current_user
+        log_create.user_id = user.id
+        log_create.mobile = user.mobile
+        log_create.auth_type = AuthType.TOKEN # Used existing session
     
     
     # 3. If no session, try Credentials Login
     # 3. If no session, try Credentials Login
     if not user and req.username and req.password:
     if not user and req.username and req.password:
+        log_create.auth_type = AuthType.PASSWORD
         # Verify User Credentials
         # Verify User Credentials
         user_query = db.query(User).filter(User.mobile == req.username, User.is_deleted == 0).first()
         user_query = db.query(User).filter(User.mobile == req.username, User.is_deleted == 0).first()
         if not user_query:
         if not user_query:
@@ -395,16 +465,26 @@ def sso_login(
         
         
         if user_query and security.verify_password(req.password, user_query.password_hash):
         if user_query and security.verify_password(req.password, user_query.password_hash):
              user = user_query
              user = user_query
+             log_create.user_id = user.id
     
     
     if not user:
     if not user:
+         log_create.is_success = 0
+         log_create.failure_reason = "认证失败"
+         LoginLogService.create_log(db, log_create)
          raise HTTPException(status_code=401, detail="认证失败")
          raise HTTPException(status_code=401, detail="认证失败")
         
         
     if user.status != "ACTIVE":
     if user.status != "ACTIVE":
+        log_create.is_success = 0
+        log_create.failure_reason = "用户已禁用"
+        LoginLogService.create_log(db, log_create)
         raise HTTPException(status_code=400, detail="用户已禁用")
         raise HTTPException(status_code=400, detail="用户已禁用")
 
 
     # 4. Generate Ticket
     # 4. Generate Ticket
     ticket = TicketService.generate_ticket(user.id, req.app_id)
     ticket = TicketService.generate_ticket(user.id, req.app_id)
     
     
+    # Log Success
+    LoginLogService.create_log(db, log_create)
+    
     # 5. Get Redirect URL
     # 5. Get Redirect URL
     redirect_base = ""
     redirect_base = ""
     if app.redirect_uris:
     if app.redirect_uris:

+ 11 - 2
backend/app/api/v1/endpoints/system_logs.py

@@ -5,7 +5,7 @@ import asyncio
 from typing import List, Optional
 from typing import List, Optional
 from datetime import datetime, date
 from datetime import datetime, date
 from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
 from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, StreamingResponse
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.api.v1 import deps
 from app.api.v1 import deps
@@ -91,7 +91,16 @@ def download_log(
     if not os.path.exists(file_path):
     if not os.path.exists(file_path):
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File not found")
         
         
-    return FileResponse(file_path, media_type="text/plain", filename=filename)
+    def iterfile():
+        with open(file_path, mode="rb") as file_like:
+            while chunk := file_like.read(1024 * 64):
+                yield chunk
+
+    return StreamingResponse(
+        iterfile(), 
+        media_type="text/plain", 
+        headers={"Content-Disposition": f'attachment; filename="{filename}"'}
+    )
 
 
 @router.get("/search", summary="搜索日志内容")
 @router.get("/search", summary="搜索日志内容")
 def search_logs(
 def search_logs(

+ 1 - 0
backend/app/models/__init__.py

@@ -2,3 +2,4 @@ from app.models.application import Application
 from app.models.user import User
 from app.models.user import User
 from app.models.mapping import AppUserMapping
 from app.models.mapping import AppUserMapping
 from app.models.operation_log import OperationLog
 from app.models.operation_log import OperationLog
+from app.models.login_log import LoginLog

+ 44 - 0
backend/app/models/login_log.py

@@ -0,0 +1,44 @@
+import enum
+from sqlalchemy import Column, Integer, String, Enum, DateTime, ForeignKey, Text
+from sqlalchemy.sql import func
+from app.core.database import Base
+
+class LoginMethod(str, enum.Enum):
+    UNIFIED_PAGE = "UNIFIED_PAGE"       # 统一认证自有页面
+    CUSTOM_PAGE = "CUSTOM_PAGE"         # 用户自定义登录页面
+    DIRECT_JUMP = "DIRECT_JUMP"         # 统一认证平台直接跳转
+    INTER_PLATFORM = "INTER_PLATFORM"   # 平台间相互跳转
+    UNKNOWN = "UNKNOWN"
+
+class AuthType(str, enum.Enum):
+    PASSWORD = "PASSWORD"               # 账号密码
+    SMS = "SMS"                         # 短信验证码
+    TICKET = "TICKET"                   # 票据置换
+    TOKEN = "TOKEN"                     # Token 验证
+    SSO = "SSO"                         # 单点登录
+    OTHER = "OTHER"
+
+class LoginLog(Base):
+    __tablename__ = "login_logs"
+
+    id = Column(Integer, primary_key=True, index=True)
+    
+    # User Info
+    user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Maybe null if login failed
+    mobile = Column(String(20), nullable=True, index=True)
+    
+    # Network Info
+    ip_address = Column(String(50), nullable=True)
+    location = Column(String(100), nullable=True) # e.g. "Shanghai, China"
+    user_agent = Column(Text, nullable=True)
+    
+    # Context
+    login_method = Column(Enum(LoginMethod), default=LoginMethod.UNIFIED_PAGE, nullable=False)
+    auth_type = Column(Enum(AuthType), default=AuthType.PASSWORD, nullable=False)
+    
+    # Status
+    is_success = Column(Integer, default=1) # 1: Success, 0: Failed
+    failure_reason = Column(String(255), nullable=True)
+    
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
+

+ 30 - 0
backend/app/schemas/login_log.py

@@ -0,0 +1,30 @@
+from typing import Optional
+from datetime import datetime
+from pydantic import BaseModel
+from app.models.login_log import LoginMethod, AuthType
+
+class LoginLogBase(BaseModel):
+    mobile: Optional[str] = None
+    ip_address: Optional[str] = None
+    location: Optional[str] = None
+    user_agent: Optional[str] = None
+    login_method: Optional[LoginMethod] = LoginMethod.UNIFIED_PAGE
+    auth_type: Optional[AuthType] = AuthType.PASSWORD
+    is_success: int = 1
+    failure_reason: Optional[str] = None
+
+class LoginLogCreate(LoginLogBase):
+    user_id: Optional[int] = None
+
+class LoginLogResponse(LoginLogBase):
+    id: int
+    user_id: Optional[int] = None
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+class LoginLogListResponse(BaseModel):
+    total: int
+    items: list[LoginLogResponse]
+

+ 65 - 0
backend/app/services/login_log_service.py

@@ -0,0 +1,65 @@
+from sqlalchemy.orm import Session
+from sqlalchemy import desc
+from typing import Optional
+from datetime import datetime
+
+from app.models.login_log import LoginLog, LoginMethod, AuthType
+from app.schemas.login_log import LoginLogCreate
+
+class LoginLogService:
+    @staticmethod
+    def create_log(
+        db: Session,
+        log_in: LoginLogCreate
+    ) -> LoginLog:
+        # TODO: Implement IP to Location lookup here if needed
+        # if log_in.ip_address and not log_in.location:
+        #     log_in.location = get_location_by_ip(log_in.ip_address)
+        
+        log = LoginLog(**log_in.model_dump())
+        db.add(log)
+        db.commit()
+        db.refresh(log)
+        return log
+
+    @staticmethod
+    def get_logs(
+        db: Session,
+        skip: int = 0,
+        limit: int = 20,
+        mobile: Optional[str] = None,
+        ip_address: Optional[str] = None,
+        status: Optional[int] = None, # 1 or 0
+        login_method: Optional[LoginMethod] = None,
+        auth_type: Optional[AuthType] = None,
+        start_date: Optional[datetime] = None,
+        end_date: Optional[datetime] = None
+    ):
+        query = db.query(LoginLog)
+        
+        if mobile:
+            query = query.filter(LoginLog.mobile.ilike(f"%{mobile}%"))
+            
+        if ip_address:
+            query = query.filter(LoginLog.ip_address.ilike(f"%{ip_address}%"))
+            
+        if status is not None:
+            query = query.filter(LoginLog.is_success == status)
+            
+        if login_method:
+            query = query.filter(LoginLog.login_method == login_method)
+            
+        if auth_type:
+            query = query.filter(LoginLog.auth_type == auth_type)
+            
+        if start_date:
+            query = query.filter(LoginLog.created_at >= start_date)
+            
+        if end_date:
+            query = query.filter(LoginLog.created_at <= end_date)
+            
+        total = query.count()
+        logs = query.order_by(desc(LoginLog.created_at)).offset(skip).limit(limit).all()
+        
+        return total, logs
+

+ 32 - 0
frontend/src/api/login_logs.ts

@@ -0,0 +1,32 @@
+import api from '../utils/request'
+
+export interface LoginLog {
+  id: number
+  user_id?: number
+  mobile?: string
+  ip_address?: string
+  location?: string
+  user_agent?: string
+  login_method: string
+  auth_type: string
+  is_success: number
+  failure_reason?: string
+  created_at: string
+}
+
+export interface LoginLogQueryParams {
+    skip: number
+    limit: number
+    mobile?: string
+    ip_address?: string
+    status?: number
+    login_method?: string
+    auth_type?: string
+    start_date?: string
+    end_date?: string
+}
+
+export const getLoginLogs = (params: LoginLogQueryParams) => {
+  return api.get('/login-logs/', { params })
+}
+

+ 6 - 0
frontend/src/router/index.ts

@@ -78,6 +78,12 @@ const routes: Array<RouteRecordRaw> = [
         component: () => import('../views/admin/maintenance/SystemLogs.vue'),
         component: () => import('../views/admin/maintenance/SystemLogs.vue'),
         meta: { requiresAdmin: true }
         meta: { requiresAdmin: true }
       },
       },
+      {
+        path: 'login-logs',
+        name: 'LoginLogs',
+        component: () => import('../views/admin/maintenance/LoginLogs.vue'),
+        meta: { requiresAdmin: true }
+      },
       {
       {
         path: 'backup',
         path: 'backup',
         name: 'DataBackup',
         name: 'DataBackup',

+ 4 - 0
frontend/src/views/Dashboard.vue

@@ -43,6 +43,10 @@
               <el-icon><Document /></el-icon>
               <el-icon><Document /></el-icon>
               <span>后台日志</span>
               <span>后台日志</span>
             </el-menu-item>
             </el-menu-item>
+            <el-menu-item index="/dashboard/login-logs">
+              <el-icon><List /></el-icon>
+              <span>登录日志</span>
+            </el-menu-item>
             <el-menu-item index="/dashboard/backup">
             <el-menu-item index="/dashboard/backup">
               <el-icon><Download /></el-icon>
               <el-icon><Download /></el-icon>
               <span>数据备份</span>
               <span>数据备份</span>

+ 295 - 0
frontend/src/views/admin/maintenance/LoginLogs.vue

@@ -0,0 +1,295 @@
+<template>
+  <div class="app-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>统一登录日志</span>
+        </div>
+      </template>
+
+      <!-- Filter Section -->
+      <div class="filter-container">
+        <el-form :inline="true" :model="queryParams" class="demo-form-inline">
+          <el-form-item label="手机号">
+            <el-input v-model="queryParams.mobile" placeholder="输入手机号" clearable @keyup.enter="handleSearch" />
+          </el-form-item>
+          <el-form-item label="IP地址">
+            <el-input v-model="queryParams.ip_address" placeholder="输入IP" clearable @keyup.enter="handleSearch" />
+          </el-form-item>
+          <el-form-item label="状态">
+            <el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 100px" @change="handleSearch">
+              <el-option label="成功" :value="1" />
+              <el-option label="失败" :value="0" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="登录方式">
+            <el-select v-model="queryParams.login_method" placeholder="全部" clearable style="width: 150px" @change="handleSearch">
+              <el-option label="统一页面" value="UNIFIED_PAGE" />
+              <el-option label="自定义页面" value="CUSTOM_PAGE" />
+              <el-option label="直接跳转" value="DIRECT_JUMP" />
+              <el-option label="平台间跳转" value="INTER_PLATFORM" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="认证方式">
+            <el-select v-model="queryParams.auth_type" placeholder="全部" clearable style="width: 120px" @change="handleSearch">
+              <el-option label="密码" value="PASSWORD" />
+              <el-option label="短信" value="SMS" />
+              <el-option label="Token" value="TOKEN" />
+              <el-option label="SSO" value="SSO" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="时间范围">
+             <el-date-picker
+                v-model="dateRange"
+                type="daterange"
+                range-separator="至"
+                start-placeholder="开始日期"
+                end-placeholder="结束日期"
+                value-format="YYYY-MM-DD"
+                @change="handleSearch"
+                style="width: 240px"
+            />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="handleSearch" :loading="loading">
+              <el-icon><Search /></el-icon> 查询
+            </el-button>
+            <el-button @click="resetQuery">
+              <el-icon><Refresh /></el-icon> 重置
+            </el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+
+      <!-- Table Section -->
+      <el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
+        <el-table-column prop="id" label="ID" width="80" align="center" />
+        <el-table-column prop="mobile" label="账号" min-width="120">
+             <template #default="scope">
+                {{ scope.row.mobile || 'Unknown' }}
+             </template>
+        </el-table-column>
+        <el-table-column prop="ip_address" label="IP 地址" width="130" />
+        <el-table-column prop="location" label="归属地" width="120" show-overflow-tooltip />
+        <el-table-column prop="login_method" label="登录方式" width="120">
+          <template #default="scope">
+            <el-tag effect="plain">{{ getLoginMethodLabel(scope.row.login_method) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="auth_type" label="认证方式" width="100">
+          <template #default="scope">
+             <el-tag effect="plain" type="info">{{ getAuthTypeLabel(scope.row.auth_type) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="is_success" label="状态" width="100">
+          <template #default="scope">
+            <el-tag :type="scope.row.is_success === 1 ? 'success' : 'danger'">
+              {{ scope.row.is_success === 1 ? '成功' : '失败' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="failure_reason" label="失败原因" min-width="150" show-overflow-tooltip>
+             <template #default="scope">
+                <span v-if="scope.row.is_success === 0" class="text-danger">{{ scope.row.failure_reason }}</span>
+                <span v-else>-</span>
+             </template>
+        </el-table-column>
+        <el-table-column prop="created_at" label="登录时间" width="170">
+          <template #default="scope">
+            {{ formatDate(scope.row.created_at) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="详情" width="80" align="center">
+            <template #default="scope">
+                <el-button link type="primary" size="small" @click="showDetails(scope.row)">查看</el-button>
+            </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- Pagination -->
+      <div class="pagination-container">
+        <el-pagination
+          v-model:current-page="pagination.current"
+          v-model:page-size="pagination.size"
+          :page-sizes="[10, 20, 50, 100]"
+          layout="total, sizes, prev, pager, next, jumper"
+          :total="pagination.total"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+
+    </el-card>
+
+    <!-- Details Dialog -->
+    <el-dialog v-model="detailsVisible" title="日志详情" width="500px">
+        <el-descriptions :column="1" border>
+            <el-descriptions-item label="日志 ID">{{ currentLog?.id }}</el-descriptions-item>
+            <el-descriptions-item label="用户账号">{{ currentLog?.mobile }}</el-descriptions-item>
+            <el-descriptions-item label="用户 ID">{{ currentLog?.user_id || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="登录状态">
+                <el-tag :type="currentLog?.is_success === 1 ? 'success' : 'danger'">
+                  {{ currentLog?.is_success === 1 ? '成功' : '失败' }}
+                </el-tag>
+            </el-descriptions-item>
+            <el-descriptions-item label="失败原因" v-if="currentLog?.is_success === 0">
+                {{ currentLog?.failure_reason }}
+            </el-descriptions-item>
+            <el-descriptions-item label="登录方式">{{ getLoginMethodLabel(currentLog?.login_method) }}</el-descriptions-item>
+            <el-descriptions-item label="认证方式">{{ getAuthTypeLabel(currentLog?.auth_type) }}</el-descriptions-item>
+            <el-descriptions-item label="IP 地址">{{ currentLog?.ip_address }}</el-descriptions-item>
+            <el-descriptions-item label="归属地">{{ currentLog?.location || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="时间">{{ formatDate(currentLog?.created_at) }}</el-descriptions-item>
+            <el-descriptions-item label="User Agent">
+                <div style="word-break: break-all; max-height: 100px; overflow-y: auto;">
+                    {{ currentLog?.user_agent }}
+                </div>
+            </el-descriptions-item>
+        </el-descriptions>
+        <template #footer>
+            <el-button @click="detailsVisible = false">关闭</el-button>
+        </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { Search, Refresh } from '@element-plus/icons-vue'
+import { getLoginLogs, LoginLog } from '../../../api/login_logs'
+
+// --- State ---
+const loading = ref(false)
+const tableData = ref<LoginLog[]>([])
+const dateRange = ref<[string, string] | null>(null)
+
+const queryParams = reactive({
+  mobile: '',
+  ip_address: '',
+  status: undefined as number | undefined,
+  login_method: undefined as string | undefined,
+  auth_type: undefined as string | undefined,
+})
+
+const pagination = reactive({
+  current: 1,
+  size: 20,
+  total: 0
+})
+
+// Details
+const detailsVisible = ref(false)
+const currentLog = ref<LoginLog | null>(null)
+
+// --- Methods ---
+
+const fetchData = async () => {
+  loading.value = true
+  try {
+    const params: any = {
+      skip: (pagination.current - 1) * pagination.size,
+      limit: pagination.size,
+      mobile: queryParams.mobile || undefined,
+      ip_address: queryParams.ip_address || undefined,
+      status: queryParams.status,
+      login_method: queryParams.login_method || undefined,
+      auth_type: queryParams.auth_type || undefined,
+    }
+
+    if (dateRange.value) {
+      params.start_date = dateRange.value[0]
+      params.end_date = dateRange.value[1] + ' 23:59:59'
+    }
+
+    const res = await getLoginLogs(params)
+    tableData.value = res.data.items
+    pagination.total = res.data.total
+  } catch (e) {
+    console.error(e)
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleSearch = () => {
+  pagination.current = 1
+  fetchData()
+}
+
+const resetQuery = () => {
+  queryParams.mobile = ''
+  queryParams.ip_address = ''
+  queryParams.status = undefined
+  queryParams.login_method = undefined
+  queryParams.auth_type = undefined
+  dateRange.value = null
+  handleSearch()
+}
+
+const handleSizeChange = (val: number) => {
+  pagination.size = val
+  fetchData()
+}
+
+const handleCurrentChange = (val: number) => {
+  pagination.current = val
+  fetchData()
+}
+
+const showDetails = (row: LoginLog) => {
+    currentLog.value = row
+    detailsVisible.value = true
+}
+
+// Helpers
+const formatDate = (dateStr?: string) => {
+    if (!dateStr) return '-'
+    return new Date(dateStr).toLocaleString()
+}
+
+const getLoginMethodLabel = (val?: string) => {
+    const map: Record<string, string> = {
+        'UNIFIED_PAGE': '统一认证页面',
+        'CUSTOM_PAGE': '自定义页面',
+        'DIRECT_JUMP': '直接跳转',
+        'INTER_PLATFORM': '平台间跳转',
+        'UNKNOWN': '未知'
+    }
+    return val ? (map[val] || val) : '-'
+}
+
+const getAuthTypeLabel = (val?: string) => {
+    const map: Record<string, string> = {
+        'PASSWORD': '密码',
+        'SMS': '短信',
+        'TICKET': '票据',
+        'TOKEN': 'Token',
+        'SSO': 'SSO',
+        'OTHER': '其他'
+    }
+    return val ? (map[val] || val) : '-'
+}
+
+// --- Lifecycle ---
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<style scoped>
+.app-container {
+  padding: 20px;
+}
+.filter-container {
+  margin-bottom: 20px;
+}
+.pagination-container {
+  margin-top: 20px;
+  display: flex;
+  justify-content: flex-end;
+}
+.text-danger {
+    color: #F56C6C;
+}
+</style>
+