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

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

@@ -1,12 +1,13 @@
 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.include_router(auth.router, prefix="/auth", tags=["认证 (Auth)"])
 api_router.include_router(users.router, prefix="/users", tags=["用户管理 (Users)"])
 api_router.include_router(apps.router, prefix="/apps", tags=["应用管理 (Applications)"])
 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(backup.router, prefix="/backups", tags=["数据备份 (Backup)"])
 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.ticket_service import TicketService
 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.login_log import LoginLogCreate, LoginMethod, AuthType
 from fastapi import Request
 
 router = APIRouter()
@@ -28,6 +30,7 @@ router = APIRouter()
 @router.post("/login", response_model=PasswordLoginResponse, summary="密码登录")
 def login_with_password(
     req: PasswordLoginRequest,
+    request: Request,
     db: Session = Depends(deps.get_db),
 ):
     """
@@ -37,27 +40,49 @@ def login_with_password(
     
     # --- Platform Login ---
     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
         user = db.query(User).filter(User.mobile == req.identifier, User.is_deleted == 0).first()
         if not user:
+            log_create.is_success = 0
+            log_create.failure_reason = "用户未找到"
+            LoginLogService.create_log(db, log_create)
             raise HTTPException(status_code=404, detail="用户未找到")
-            
+        
+        log_create.user_id = user.id
+
         is_valid = security.verify_password(req.password, user.password_hash)
         if not is_valid:
             import logging
             logger = logging.getLogger(__name__)
             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="密码错误")
             
         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="用户已禁用")
 
         # Generate JWT Access Token
         access_token = security.create_access_token(user.id)
+        
+        # Log Success
+        LoginLogService.create_log(db, log_create)
+
         return {
             "access_token": access_token, 
             "token_type": "bearer",
@@ -65,9 +90,20 @@ def login_with_password(
         }
 
     # --- 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
     app = db.query(Application).filter(Application.app_id == req.app_id).first()
     if not app:
+        log_create.is_success = 0
+        log_create.failure_reason = "应用未找到"
+        LoginLogService.create_log(db, log_create)
         raise HTTPException(status_code=404, detail="应用未找到")
 
     # 2. Verify Signature (Optional but recommended for server-side calls)
@@ -80,6 +116,9 @@ def login_with_password(
             "sign": 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="签名无效")
 
     # 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()
 
     if not user:
+        log_create.is_success = 0
+        log_create.failure_reason = "用户未找到"
+        LoginLogService.create_log(db, log_create)
         raise HTTPException(status_code=404, detail="用户未找到")
 
+    log_create.user_id = user.id
+
     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="用户已禁用")
 
     # 4. Verify Password
@@ -116,17 +163,19 @@ def login_with_password(
     is_valid = security.verify_password(req.password, user.password_hash)
     if not is_valid:
         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="密码错误")
 
     # 5. Generate Ticket (Self-Targeting)
     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}
 
@@ -356,6 +405,7 @@ def exchange_ticket(
 @router.post("/sso-login", response_model=SsoLoginResponse, summary="SSO 登录 (简易模式)")
 def sso_login(
     req: SsoLoginRequest,
+    request: Request,
     db: Session = Depends(deps.get_db),
     current_user: Optional[User] = Depends(deps.get_current_active_user_optional),
 ):
@@ -368,10 +418,26 @@ def sso_login(
     """
     # 1. Verify App
     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:
+        log_create.is_success = 0
+        log_create.failure_reason = "应用未找到"
+        LoginLogService.create_log(db, log_create)
         raise HTTPException(status_code=404, detail="应用未找到")
 
     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 请使用标准流程。")
 
     user = None
@@ -379,9 +445,13 @@ def sso_login(
     # 2. Try Session Login first
     if 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
     if not user and req.username and req.password:
+        log_create.auth_type = AuthType.PASSWORD
         # Verify User Credentials
         user_query = db.query(User).filter(User.mobile == req.username, User.is_deleted == 0).first()
         if not user_query:
@@ -395,16 +465,26 @@ def sso_login(
         
         if user_query and security.verify_password(req.password, user_query.password_hash):
              user = user_query
+             log_create.user_id = user.id
     
     if not user:
+         log_create.is_success = 0
+         log_create.failure_reason = "认证失败"
+         LoginLogService.create_log(db, log_create)
          raise HTTPException(status_code=401, detail="认证失败")
         
     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="用户已禁用")
 
     # 4. Generate Ticket
     ticket = TicketService.generate_ticket(user.id, req.app_id)
     
+    # Log Success
+    LoginLogService.create_log(db, log_create)
+    
     # 5. Get Redirect URL
     redirect_base = ""
     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 datetime import datetime, date
 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 app.api.v1 import deps
@@ -91,7 +91,16 @@ def download_log(
     if not os.path.exists(file_path):
         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="搜索日志内容")
 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.mapping import AppUserMapping
 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'),
         meta: { requiresAdmin: true }
       },
+      {
+        path: 'login-logs',
+        name: 'LoginLogs',
+        component: () => import('../views/admin/maintenance/LoginLogs.vue'),
+        meta: { requiresAdmin: true }
+      },
       {
         path: 'backup',
         name: 'DataBackup',

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

@@ -43,6 +43,10 @@
               <el-icon><Document /></el-icon>
               <span>后台日志</span>
             </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-icon><Download /></el-icon>
               <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>
+