فهرست منبع

手机/pc端验证码登录

liuq 2 ماه پیش
والد
کامیت
cb6dfc183f

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

@@ -1,6 +1,10 @@
 from fastapi import APIRouter
 
-from app.api.v1.endpoints import auth, users, apps, utils, simple_auth, oidc, open_api, logs, system_logs, backup, login_logs, user_import, system
+from app.api.v1.endpoints import (
+    auth, users, apps, utils, simple_auth, oidc, 
+    open_api, logs, system_logs, backup, login_logs, 
+    user_import, system, system_config, sms_auth
+)
 
 api_router = APIRouter()
 api_router.include_router(auth.router, prefix="/auth", tags=["认证 (Auth)"])
@@ -11,8 +15,10 @@ 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(system.router, prefix="/system", tags=["系统配置 (System)"])
+api_router.include_router(system.router, prefix="/system", tags=["系统管理 (System)"])
+api_router.include_router(system_config.router, prefix="/system/config", tags=["系统配置 (System Config)"])
 api_router.include_router(utils.router, prefix="/utils", tags=["工具 (Utils)"])
 api_router.include_router(simple_auth.router, prefix="/simple", tags=["简易认证 (SimpleAuth)"])
+api_router.include_router(sms_auth.router, prefix="/auth/sms", tags=["短信认证 (SMS Auth)"])
 api_router.include_router(oidc.router, prefix="/oidc", tags=["OIDC (OpenID Connect)"])
 api_router.include_router(open_api.router, prefix="/open", tags=["开放接口 (OpenAPI)"])

+ 118 - 0
backend/app/api/v1/endpoints/sms_auth.py

@@ -0,0 +1,118 @@
+from typing import Any
+from fastapi import APIRouter, Depends, HTTPException, Body, Request
+from sqlalchemy.orm import Session
+from datetime import timedelta
+
+from app.api.v1 import deps
+from app.core import security
+from app.core.config import settings
+from app.core.cache import redis_client
+from app.models.user import User, UserStatus
+from app.services.sms_service import SmsService
+from app.services.system_config_service import SystemConfigService
+from app.services.login_log_service import LoginLogService
+from app.models.login_log import LoginMethod, AuthType
+from app.schemas.login_log import LoginLogCreate
+from app.schemas.token import Token
+
+router = APIRouter()
+
+@router.post("/send-code", summary="发送短信验证码")
+def send_sms_code(
+    mobile: str = Body(..., embed=True),
+    platform: str = Body(..., embed=True, description="pc or mobile"),
+    db: Session = Depends(deps.get_db),
+) -> Any:
+    """
+    发送短信验证码。
+    需检查系统配置是否允许对应平台的短信登录。
+    """
+    # Check config
+    config_key = "sms_login_pc_enabled" if platform == "pc" else "sms_login_mobile_enabled"
+    is_enabled = SystemConfigService.get_config(db, config_key)
+    
+    if is_enabled != "true":
+        raise HTTPException(status_code=403, detail="当前平台未开启短信登录功能")
+
+    # Send code
+    try:
+        SmsService.send_code(mobile)
+    except Exception as e:
+        # Pass through HTTPExceptions from service
+        if isinstance(e, HTTPException):
+            raise e
+        # Log unexpected errors
+        print(f"Error sending SMS: {e}")
+        raise HTTPException(status_code=500, detail="发送验证码失败")
+
+    return {"message": "验证码已发送"}
+
+@router.post("/login", response_model=Token, summary="短信验证码登录")
+def login_with_sms(
+    request: Request,
+    mobile: str = Body(..., embed=True),
+    code: str = Body(..., embed=True),
+    platform: str = Body("pc", embed=True),
+    db: Session = Depends(deps.get_db),
+) -> Any:
+    """
+    使用手机号和验证码登录。
+    """
+    # 1. Check Config (Double check)
+    config_key = "sms_login_pc_enabled" if platform == "pc" else "sms_login_mobile_enabled"
+    is_enabled = SystemConfigService.get_config(db, config_key)
+    
+    if is_enabled != "true":
+        raise HTTPException(status_code=403, detail="当前平台未开启短信登录功能")
+
+    # 2. Verify Code
+    # Redis key from SmsService: SMS:{mobile}
+    key = f"SMS:{mobile}"
+    stored_code = redis_client.get(key)
+    
+    if not stored_code or stored_code != code:
+         raise HTTPException(status_code=400, detail="验证码错误或已过期")
+
+    # 3. Find User
+    user = db.query(User).filter(User.mobile == mobile, User.is_deleted == 0).first()
+    
+    # Log preparation
+    log_create = LoginLogCreate(
+        mobile=mobile,
+        ip_address=request.client.host,
+        login_method=LoginMethod.UNIFIED_PAGE,
+        auth_type=AuthType.SMS,
+        user_agent=request.headers.get("user-agent")
+    )
+    
+    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. Generate Token
+    access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
+    access_token = security.create_access_token(
+        subject=user.id,
+        expires_delta=access_token_expires
+    )
+    
+    # Clear Code
+    redis_client.delete(key)
+    
+    # Log Success
+    LoginLogService.create_log(db, log_create)
+
+    return {
+        "access_token": access_token,
+        "token_type": "bearer",
+    }

+ 62 - 0
backend/app/api/v1/endpoints/system_config.py

@@ -0,0 +1,62 @@
+from typing import Any, List
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from app.api.v1 import deps
+from app.models.user import User, UserRole
+from app.schemas.system_config import SystemConfig, SystemConfigUpdate, SystemConfigCreate
+from app.services.system_config_service import SystemConfigService
+
+router = APIRouter()
+
+@router.get("/", response_model=List[SystemConfig], summary="获取所有系统配置")
+def read_system_configs(
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    """
+    获取所有系统配置。
+    需要超级管理员权限。
+    """
+    if current_user.role != UserRole.SUPER_ADMIN:
+         raise HTTPException(status_code=403, detail="权限不足")
+    return SystemConfigService.get_all_configs(db)
+
+@router.post("/", response_model=SystemConfig, summary="设置系统配置")
+def update_system_config(
+    config_in: SystemConfigCreate,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    """
+    创建或更新系统配置。
+    需要超级管理员权限。
+    """
+    if current_user.role != UserRole.SUPER_ADMIN:
+        raise HTTPException(status_code=403, detail="权限不足")
+        
+    config = SystemConfigService.set_config(db, config_in.key, config_in.value, config_in.description)
+    return config
+
+@router.get("/public", response_model=List[SystemConfig], summary="获取公开系统配置")
+def read_public_system_configs(
+    db: Session = Depends(deps.get_db),
+) -> Any:
+    """
+    获取公开的系统配置(如登录开关状态)。
+    无需鉴权。
+    """
+    # Define which keys are public
+    public_keys = ["sms_login_pc_enabled", "sms_login_mobile_enabled"]
+    
+    configs = []
+    for key in public_keys:
+        val = SystemConfigService.get_config(db, key)
+        # Defaults to 'false' if not set
+        if val is None:
+             val = "false"
+        configs.append({"key": key, "value": val, "description": "Public Config", "id": 0}) # Dummy ID for schema compliance if needed or handle better
+        
+    return configs
+

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

@@ -4,3 +4,5 @@ from app.models.mapping import AppUserMapping
 from app.models.operation_log import OperationLog
 from app.models.login_log import LoginLog
 from app.models.import_log import ImportLog
+from app.models.system_config import SystemConfig
+from app.models.backup import BackupRecord, BackupSettings

+ 15 - 0
backend/app/models/system_config.py

@@ -0,0 +1,15 @@
+from sqlalchemy import Column, Integer, String, DateTime
+from sqlalchemy.sql import func
+from app.core.database import Base
+
+class SystemConfig(Base):
+    __tablename__ = "system_configs"
+
+    id = Column(Integer, primary_key=True, index=True)
+    key = Column(String(50), unique=True, index=True, nullable=False, comment="Configuration Key")
+    value = Column(String(255), nullable=True, comment="Configuration Value")
+    description = Column(String(255), nullable=True, comment="Description")
+    
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+    updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
+

+ 21 - 0
backend/app/schemas/system_config.py

@@ -0,0 +1,21 @@
+from typing import Optional
+from pydantic import BaseModel
+
+class SystemConfigBase(BaseModel):
+    key: str
+    value: str
+    description: Optional[str] = None
+
+class SystemConfigCreate(SystemConfigBase):
+    pass
+
+class SystemConfigUpdate(BaseModel):
+    value: str
+    description: Optional[str] = None
+
+class SystemConfig(SystemConfigBase):
+    id: int
+    
+    class Config:
+        from_attributes = True
+

+ 28 - 0
backend/app/services/system_config_service.py

@@ -0,0 +1,28 @@
+from sqlalchemy.orm import Session
+from app.models.system_config import SystemConfig
+
+class SystemConfigService:
+    @staticmethod
+    def get_config(db: Session, key: str) -> str | None:
+        config = db.query(SystemConfig).filter(SystemConfig.key == key).first()
+        return config.value if config else None
+
+    @staticmethod
+    def set_config(db: Session, key: str, value: str, description: str = None) -> SystemConfig:
+        config = db.query(SystemConfig).filter(SystemConfig.key == key).first()
+        if config:
+            config.value = value
+            if description:
+                config.description = description
+        else:
+            config = SystemConfig(key=key, value=value, description=description)
+            db.add(config)
+        
+        db.commit()
+        db.refresh(config)
+        return config
+
+    @staticmethod
+    def get_all_configs(db: Session):
+        return db.query(SystemConfig).all()
+

+ 21 - 0
frontend/src/api/smsAuth.ts

@@ -0,0 +1,21 @@
+import request from '../utils/request'
+
+export const sendSmsCode = (mobile: string, platform: 'pc' | 'mobile') => {
+  return request({
+    url: '/auth/sms/send-code',
+    method: 'post',
+    data: {
+      mobile,
+      platform
+    }
+  })
+}
+
+export const loginWithSms = (data: { mobile: string, code: string, platform: 'pc' | 'mobile' }) => {
+  return request({
+    url: '/auth/sms/login',
+    method: 'post',
+    data
+  })
+}
+

+ 37 - 0
frontend/src/api/systemConfig.ts

@@ -0,0 +1,37 @@
+import request from '../utils/request'
+
+export interface SystemConfig {
+  key: string
+  value: string
+  description?: string
+  id?: number
+}
+
+export interface SystemConfigUpdate {
+  key: string
+  value: string
+  description?: string
+}
+
+export const getPublicConfigs = () => {
+  return request<SystemConfig[]>({
+    url: '/system/config/public',
+    method: 'get'
+  })
+}
+
+export const getAllConfigs = () => {
+  return request<SystemConfig[]>({
+    url: '/system/config',
+    method: 'get'
+  })
+}
+
+export const updateConfig = (data: SystemConfigUpdate) => {
+  return request<SystemConfig>({
+    url: '/system/config',
+    method: 'post',
+    data
+  })
+}
+

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

@@ -123,6 +123,12 @@ const routes: Array<RouteRecordRaw> = [
         component: () => import('../views/admin/maintenance/SSLConfig.vue'),
         meta: { requiresAdmin: true }
       },
+      {
+        path: 'login-config',
+        name: 'LoginConfig',
+        component: () => import('../views/admin/maintenance/LoginConfig.vue'),
+        meta: { requiresAdmin: true }
+      },
       {
         path: 'changelog',
         name: 'Changelog',

+ 5 - 1
frontend/src/views/Dashboard.vue

@@ -59,6 +59,10 @@
               <el-icon><Lock /></el-icon>
               <span>证书配置</span>
             </el-menu-item>
+            <el-menu-item index="/dashboard/login-config">
+              <el-icon><Setting /></el-icon>
+              <span>登录配置</span>
+            </el-menu-item>
           </el-sub-menu>
 
           <el-menu-item 
@@ -125,7 +129,7 @@
 import { computed, onMounted, ref, reactive } from 'vue'
 import { useRouter } from 'vue-router'
 import { useAuthStore } from '../store/auth'
-import { Grid, List, QuestionFilled, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight, Lock } from '@element-plus/icons-vue'
+import { Grid, List, QuestionFilled, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight, Lock, Setting } from '@element-plus/icons-vue'
 import { ElMessage, FormInstance, FormRules } from 'element-plus'
 import api from '../utils/request'
 

+ 149 - 3
frontend/src/views/Login.vue

@@ -9,16 +9,73 @@
           <div v-if="ssoAppName" style="color: #67C23A; background-color: #f0f9eb; border: 1px solid #e1f3d8; padding: 6px 12px; border-radius: 4px; font-size: 16px; margin: 10px 0; display: inline-block;">{{ ssoAppName }}</div>
         </div>
       </template>
-      <el-form :model="loginForm" label-width="0px" class="login-form">
+
+      <!-- SMS Enabled and NOT OIDC/SSO (Simple implementation for now) -->
+      <el-tabs v-if="smsEnabled && !loginChallenge" v-model="activeTab" class="login-tabs" stretch>
+        <el-tab-pane label="密码登录" name="password">
+            <el-form :model="loginForm" label-width="0px" class="login-form">
+                <el-form-item>
+                <el-input v-model="loginForm.mobile" placeholder="手机号" :prefix-icon="Iphone" />
+                </el-form-item>
+                <el-form-item>
+                <el-input 
+                    v-model="loginForm.password" 
+                    type="password" 
+                    placeholder="密码" 
+                    :prefix-icon="Lock" 
+                    show-password
+                />
+                </el-form-item>
+                <el-form-item>
+                <el-checkbox v-model="loginForm.remember_me">记住我 (30天免登录)</el-checkbox>
+                </el-form-item>
+                <el-form-item>
+                <el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%">
+                    登录
+                </el-button>
+                </el-form-item>
+                <div style="text-align: center; margin-top: 10px;">
+                <router-link to="/reset-password">忘记密码?</router-link>
+                </div>
+            </el-form>
+        </el-tab-pane>
+        <el-tab-pane label="验证码登录" name="sms">
+             <el-form :model="smsForm" label-width="0px" class="login-form">
+                <el-form-item>
+                    <el-input v-model="smsForm.mobile" placeholder="手机号" :prefix-icon="Iphone" />
+                </el-form-item>
+                <el-form-item>
+                    <el-row :gutter="10" style="width: 100%">
+                        <el-col :span="15">
+                            <el-input v-model="smsForm.code" placeholder="验证码" :prefix-icon="Key" />
+                        </el-col>
+                        <el-col :span="9">
+                            <el-button @click="handleSendCode" :disabled="countdown > 0" style="width: 100%">
+                                {{ countdown > 0 ? `${countdown}s` : '发送验证码' }}
+                            </el-button>
+                        </el-col>
+                    </el-row>
+                </el-form-item>
+                <el-form-item>
+                    <el-button type="primary" :loading="loading" @click="handleSmsLogin" style="width: 100%">
+                        登录
+                    </el-button>
+                </el-form-item>
+             </el-form>
+        </el-tab-pane>
+      </el-tabs>
+
+      <!-- Original Layout for fallback (OIDC/SSO or SMS disabled) -->
+      <el-form v-else :model="loginForm" label-width="0px" class="login-form">
         <el-form-item>
-          <el-input v-model="loginForm.mobile" placeholder="手机号" prefix-icon="Iphone" />
+          <el-input v-model="loginForm.mobile" placeholder="手机号" :prefix-icon="Iphone" />
         </el-form-item>
         <el-form-item>
           <el-input 
             v-model="loginForm.password" 
             type="password" 
             placeholder="密码" 
-            prefix-icon="Lock" 
+            :prefix-icon="Lock" 
             show-password
           />
         </el-form-item>
@@ -44,24 +101,99 @@ import { useRouter, useRoute } from 'vue-router'
 import { useAuthStore } from '../store/auth'
 import { getLoginRequest, acceptLogin } from '../api/oidc'
 import { getSystemStatus, getAppPublicInfo, ssoLogin } from '../api/public'
+import { getPublicConfigs } from '../api/systemConfig'
+import { sendSmsCode, loginWithSms } from '../api/smsAuth'
 import { ElMessage } from 'element-plus'
+import { Iphone, Lock, Key } from '@element-plus/icons-vue'
 
 const router = useRouter()
 const route = useRoute()
 const authStore = useAuthStore()
 
+const activeTab = ref('password')
+const smsEnabled = ref(false)
+const countdown = ref(0)
+let timer: any = null
+
 const loginForm = reactive({
   mobile: '',
   password: '',
   remember_me: true
 })
 
+const smsForm = reactive({
+  mobile: '',
+  code: ''
+})
+
 const loading = ref(false)
 const checkLoading = ref(true)
 const loginChallenge = ref('')
 const ssoAppId = ref('')
 const ssoAppName = ref('')
 
+const handleSendCode = async () => {
+    if (!smsForm.mobile) {
+        ElMessage.warning('请输入手机号')
+        return
+    }
+    try {
+        await sendSmsCode(smsForm.mobile, 'pc')
+        ElMessage.success('验证码已发送')
+        countdown.value = 60
+        timer = setInterval(() => {
+            countdown.value--
+            if (countdown.value <= 0) {
+                clearInterval(timer)
+            }
+        }, 1000)
+    } catch (e) {
+        // handled by interceptor
+    }
+}
+
+const handleSmsLogin = async () => {
+    if (!smsForm.mobile || !smsForm.code) {
+        ElMessage.warning('请输入手机号和验证码')
+        return
+    }
+    loading.value = true
+    try {
+        const res = await loginWithSms({
+            mobile: smsForm.mobile,
+            code: smsForm.code,
+            platform: 'pc'
+        })
+        
+        // Manual login success handling since authStore.login is for password
+        const token = res.data.access_token
+        authStore.token = token
+        localStorage.setItem('token', token)
+        await authStore.fetchUser()
+        
+        // SSO Redirect Check
+        if (ssoAppId.value) {
+             const ssoRes = await ssoLogin({
+                app_id: ssoAppId.value,
+                username: '',
+                password: ''
+            })
+            if (ssoRes.data.redirect_url) {
+                ElMessage.success('登录成功,正在跳转...')
+                window.location.href = ssoRes.data.redirect_url
+                return
+            }
+        }
+
+        ElMessage.success('登录成功')
+        router.push('/dashboard/launchpad')
+    } catch (e) {
+        // handled
+    } finally {
+        loading.value = false
+    }
+}
+
 const handleLogin = async () => {
   if (!loginForm.mobile || !loginForm.password) {
     ElMessage.warning('请输入手机号和密码')
@@ -111,6 +243,20 @@ onMounted(async () => {
   const challenge = route.query.login_challenge as string
   const appid = route.query.appid as string || route.query.app_id as string
   
+  // Fetch Config
+  try {
+      const configRes = await getPublicConfigs()
+      if (configRes.data) {
+          const pcConfig = configRes.data.find((c: any) => c.key === 'sms_login_pc_enabled')
+          if (pcConfig && pcConfig.value === 'true') {
+              smsEnabled.value = true
+              activeTab.value = 'sms'
+          }
+      }
+  } catch(e) {
+      console.error("Failed to fetch public config", e)
+  }
+
   console.log('Login page mounted. Params:', { challenge, appid, fullQuery: route.query })
 
   // 1. Check System Initialization Status

+ 105 - 0
frontend/src/views/admin/maintenance/LoginConfig.vue

@@ -0,0 +1,105 @@
+<template>
+  <div class="login-config-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>登录配置</span>
+        </div>
+      </template>
+      
+      <el-form :model="form" label-width="200px" v-loading="loading">
+        <el-form-item label="PC端验证码登录">
+          <el-switch
+            v-model="form.sms_login_pc_enabled"
+            active-text="开启"
+            inactive-text="关闭"
+            @change="handleUpdate('sms_login_pc_enabled', $event)"
+          />
+          <div class="form-tip">开启后,PC端登录页将显示短信验证码登录选项</div>
+        </el-form-item>
+
+        <el-form-item label="手机端验证码登录">
+          <el-switch
+            v-model="form.sms_login_mobile_enabled"
+            active-text="开启"
+            inactive-text="关闭"
+            @change="handleUpdate('sms_login_mobile_enabled', $event)"
+          />
+          <div class="form-tip">开启后,移动端登录页将显示短信验证码登录选项</div>
+        </el-form-item>
+      </el-form>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, reactive } from 'vue'
+import { getAllConfigs, updateConfig } from '../../../api/systemConfig'
+import { ElMessage } from 'element-plus'
+
+const loading = ref(false)
+const form = reactive({
+  sms_login_pc_enabled: false,
+  sms_login_mobile_enabled: false
+})
+
+const fetchConfigs = async () => {
+  loading.value = true
+  try {
+    const res = await getAllConfigs() as any
+    // Map array to object
+    if (res) {
+        // Find specific keys
+        const pcConfig = res.find((c: any) => c.key === 'sms_login_pc_enabled')
+        const mobileConfig = res.find((c: any) => c.key === 'sms_login_mobile_enabled')
+        
+        if (pcConfig) {
+            form.sms_login_pc_enabled = pcConfig.value === 'true'
+        }
+        if (mobileConfig) {
+            form.sms_login_mobile_enabled = mobileConfig.value === 'true'
+        }
+    }
+  } catch (error) {
+    console.error('Failed to fetch configs', error)
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleUpdate = async (key: string, value: boolean) => {
+  try {
+    await updateConfig({
+      key: key,
+      value: value ? 'true' : 'false',
+      description: key === 'sms_login_pc_enabled' ? 'Enable SMS login for PC' : 'Enable SMS login for Mobile'
+    })
+    ElMessage.success('配置已更新')
+  } catch (error) {
+    ElMessage.error('更新失败')
+    // Revert switch
+    if (key === 'sms_login_pc_enabled') {
+        form.sms_login_pc_enabled = !value
+    } else {
+        form.sms_login_mobile_enabled = !value
+    }
+  }
+}
+
+onMounted(() => {
+  fetchConfigs()
+})
+</script>
+
+<style scoped>
+.login-config-container {
+  padding: 20px;
+}
+.form-tip {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 5px;
+  line-height: 1.2;
+}
+</style>
+

+ 154 - 4
frontend/src/views/mobile/MobileLogin.vue

@@ -7,16 +7,73 @@
     </div>
     
     <div class="login-content">
-      <el-form :model="loginForm" label-width="0px" class="login-form">
+      <!-- Tabs if SMS enabled and NO SSO context (or simple check) -->
+      <el-tabs v-if="smsEnabled" v-model="activeTab" class="login-tabs" stretch>
+        <el-tab-pane label="密码登录" name="password">
+            <el-form :model="loginForm" label-width="0px" class="login-form">
+                <el-form-item>
+                <el-input v-model="loginForm.mobile" placeholder="手机号" :prefix-icon="Iphone" size="large" />
+                </el-form-item>
+                <el-form-item>
+                <el-input 
+                    v-model="loginForm.password" 
+                    type="password" 
+                    placeholder="密码" 
+                    :prefix-icon="Lock" 
+                    show-password
+                    size="large"
+                />
+                </el-form-item>
+                <el-form-item>
+                <el-checkbox v-model="loginForm.remember_me">记住我 (30天免登录)</el-checkbox>
+                </el-form-item>
+                <el-form-item>
+                <el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%" size="large">
+                    登录
+                </el-button>
+                </el-form-item>
+                <div class="login-links">
+                <router-link to="/mobile/reset-password">忘记密码?</router-link>
+                </div>
+            </el-form>
+        </el-tab-pane>
+        <el-tab-pane label="验证码登录" name="sms">
+            <el-form :model="smsForm" label-width="0px" class="login-form">
+                <el-form-item>
+                    <el-input v-model="smsForm.mobile" placeholder="手机号" :prefix-icon="Iphone" size="large" />
+                </el-form-item>
+                <el-form-item>
+                    <el-row :gutter="10" style="width: 100%">
+                        <el-col :span="15">
+                             <el-input v-model="smsForm.code" placeholder="验证码" :prefix-icon="Key" size="large" />
+                        </el-col>
+                        <el-col :span="9">
+                            <el-button @click="handleSendCode" :disabled="countdown > 0" style="width: 100%" size="large">
+                                {{ countdown > 0 ? `${countdown}s` : '发送' }}
+                            </el-button>
+                        </el-col>
+                    </el-row>
+                </el-form-item>
+                <el-form-item>
+                    <el-button type="primary" :loading="loading" @click="handleSmsLogin" style="width: 100%" size="large">
+                        登录
+                    </el-button>
+                </el-form-item>
+            </el-form>
+        </el-tab-pane>
+      </el-tabs>
+
+      <!-- Fallback / SSO Layout -->
+      <el-form v-else :model="loginForm" label-width="0px" class="login-form">
         <el-form-item>
-          <el-input v-model="loginForm.mobile" placeholder="手机号" prefix-icon="Iphone" size="large" />
+          <el-input v-model="loginForm.mobile" placeholder="手机号" :prefix-icon="Iphone" size="large" />
         </el-form-item>
         <el-form-item>
           <el-input 
             v-model="loginForm.password" 
             type="password" 
             placeholder="密码" 
-            prefix-icon="Lock" 
+            :prefix-icon="Lock" 
             show-password
             size="large"
           />
@@ -42,22 +99,97 @@ import { ref, reactive, onMounted } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { useAuthStore } from '../../store/auth'
 import { getAppPublicInfo, ssoLogin } from '../../api/public'
+import { getPublicConfigs } from '../../api/systemConfig'
+import { sendSmsCode, loginWithSms } from '../../api/smsAuth'
 import { ElMessage } from 'element-plus'
+import { Iphone, Lock, Key } from '@element-plus/icons-vue'
 
 const router = useRouter()
 const route = useRoute()
 const authStore = useAuthStore()
 
+const activeTab = ref('password')
+const smsEnabled = ref(false)
+const countdown = ref(0)
+let timer: any = null
+
 const loginForm = reactive({
   mobile: '',
   password: '',
   remember_me: true
 })
 
+const smsForm = reactive({
+  mobile: '',
+  code: ''
+})
+
 const loading = ref(false)
 const ssoAppId = ref('')
 const ssoAppName = ref('')
 
+const handleSendCode = async () => {
+    if (!smsForm.mobile) {
+        ElMessage.warning('请输入手机号')
+        return
+    }
+    try {
+        await sendSmsCode(smsForm.mobile, 'mobile')
+        ElMessage.success('验证码已发送')
+        countdown.value = 60
+        timer = setInterval(() => {
+            countdown.value--
+            if (countdown.value <= 0) {
+                clearInterval(timer)
+            }
+        }, 1000)
+    } catch (e) {
+        // handled
+    }
+}
+
+const handleSmsLogin = async () => {
+    if (!smsForm.mobile || !smsForm.code) {
+        ElMessage.warning('请输入手机号和验证码')
+        return
+    }
+    loading.value = true
+    try {
+        const res = await loginWithSms({
+            mobile: smsForm.mobile,
+            code: smsForm.code,
+            platform: 'mobile'
+        })
+        
+        // Manual login success handling since authStore.login is for password
+        const token = res.data.access_token
+        authStore.token = token
+        localStorage.setItem('token', token)
+        await authStore.fetchUser()
+        
+        // SSO Redirect Check
+        if (ssoAppId.value) {
+             const ssoRes = await ssoLogin({
+                app_id: ssoAppId.value,
+                username: '',
+                password: ''
+            })
+            if (ssoRes.data.redirect_url) {
+                ElMessage.success('登录成功,正在跳转...')
+                window.location.href = ssoRes.data.redirect_url
+                return
+            }
+        }
+
+        ElMessage.success('登录成功')
+        router.push('/mobile/dashboard')
+    } catch (e) {
+        // handled
+    } finally {
+        loading.value = false
+    }
+}
+
 const handleLogin = async () => {
   if (!loginForm.mobile || !loginForm.password) {
     ElMessage.warning('请输入手机号和密码')
@@ -97,6 +229,20 @@ const handleLogin = async () => {
 onMounted(async () => {
   const appid = route.query.appid as string || route.query.app_id as string
   
+  // Fetch Config
+  try {
+      const configRes = await getPublicConfigs()
+      if (configRes.data) {
+          const mobileConfig = configRes.data.find((c: any) => c.key === 'sms_login_mobile_enabled')
+          if (mobileConfig && mobileConfig.value === 'true') {
+              smsEnabled.value = true
+              activeTab.value = 'sms'
+          }
+      }
+  } catch(e) {
+      console.error("Failed to fetch public config", e)
+  }
+
   if (appid) {
     try {
       const appInfo = await getAppPublicInfo(appid)
@@ -188,5 +334,9 @@ onMounted(async () => {
   font-size: 18px;
   border-radius: 8px;
 }
-</style>
 
+/* Tabs Styles */
+:deep(.el-tabs__item) {
+    font-size: 16px;
+}
+</style>