Ver código fonte

feat: make web-admin login QR-primary with password fallback

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 mês atrás
pai
commit
6a5d93165b
2 arquivos alterados com 298 adições e 22 exclusões
  1. 39 0
      web-admin/src/api/auth.ts
  2. 259 22
      web-admin/src/views/auth/login/index.vue

+ 39 - 0
web-admin/src/api/auth.ts

@@ -29,6 +29,45 @@ export async function fetchLogin(params: { username: string; password: string })
   })
 }
 
+export function fetchWxQrCreate() {
+  return request.post<{
+    sessionId: string
+    expiresIn: number
+    qrImageBase64: string
+  }>({
+    url: '/web/auth/wx-qr/create',
+    data: {},
+    headers: { 'X-Api-Crypto': 'off' },
+    showErrorMessage: true
+  })
+}
+
+export function fetchWxQrStatus(sessionId: string) {
+  return request.get<{
+    status: string
+    message?: string
+    token?: string
+    refreshToken?: string
+    expiresIn?: number
+    userInfo?: {
+      id: string
+      username: string
+      realName: string
+      phone: string
+      role: string
+      roleNames?: string[]
+      regionId: string
+      regionName: string
+      avatarUrl?: string | null
+    }
+  }>({
+    url: '/web/auth/wx-qr/status',
+    params: { sessionId },
+    headers: { 'X-Api-Crypto': 'off' },
+    showErrorMessage: false
+  })
+}
+
 /**
  * 获取当前用户信息
  * @returns 用户信息

+ 259 - 22
web-admin/src/views/auth/login/index.vue

@@ -31,10 +31,37 @@
       <div class="form-container">
         <div class="form-header">
           <h2>欢迎登录</h2>
-          <p>请输入您的账号信息</p>
+          <p>微信扫一扫,授权手机号后自动登录</p>
+        </div>
+
+        <div class="qr-login">
+          <div class="qr-frame">
+            <img v-if="qrImage" class="qr-image" :src="qrImage" alt="登录二维码" />
+            <div v-else class="qr-placeholder">{{ qrStatus === 'creating' ? '生成中…' : '暂无二维码' }}</div>
+          </div>
+          <p class="qr-status" :class="{ error: qrStatus === 'failed' || qrStatus === 'expired' }">
+            {{ qrStatusText }}
+          </p>
+          <el-button :loading="qrLoading" @click="refreshQr">
+            <el-icon><Refresh /></el-icon>
+            刷新二维码
+          </el-button>
+        </div>
+
+        <div class="password-toggle" @click="showPasswordLogin = !showPasswordLogin">
+          <span class="divider-line"></span>
+          <button type="button" class="toggle-btn">
+            <el-icon>
+              <ArrowDown v-if="!showPasswordLogin" />
+              <ArrowUp v-else />
+            </el-icon>
+            或使用账号密码登录
+          </button>
+          <span class="divider-line"></span>
         </div>
 
         <el-form
+          v-show="showPasswordLogin"
           ref="formRef"
           :model="formData"
           :rules="rules"
@@ -70,6 +97,7 @@
               class="login-btn"
               @click="handleSubmit"
             >
+              <el-icon><Check /></el-icon>
               登 录
             </el-button>
           </el-form-item>
@@ -85,12 +113,12 @@
 
 <script setup lang="ts">
   import { useUserStore } from '@/store/modules/user'
-  import { fetchLogin } from '@/api/auth'
+  import { fetchLogin, fetchWxQrCreate, fetchWxQrStatus } from '@/api/auth'
   import { resetRouteInitState } from '@/router/guards/beforeEach'
   import { resolveSafeRedirectTarget } from '@/utils/router'
   import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
   import { ElMessage } from 'element-plus'
-  import { User, Lock } from '@element-plus/icons-vue'
+  import { User, Lock, Refresh, Check, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
 
   defineOptions({ name: 'Login' })
 
@@ -101,6 +129,7 @@
   const route = useRoute()
 
   const formRef = ref<FormInstance>()
+  const showPasswordLogin = ref(false)
 
   const formData = reactive({
     username: '',
@@ -113,6 +142,144 @@
   }
 
   const loading = ref(false)
+  const qrLoading = ref(false)
+  const qrImage = ref('')
+  const sessionId = ref('')
+  const qrStatus = ref<'creating' | 'pending' | 'scanned' | 'failed' | 'expired' | 'success'>(
+    'creating'
+  )
+  const qrMessage = ref('')
+  let pollTimer: ReturnType<typeof setInterval> | null = null
+  let loginHandled = false
+
+  const qrStatusText = computed(() => {
+    switch (qrStatus.value) {
+      case 'creating':
+        return '正在生成二维码…'
+      case 'pending':
+        return '请使用微信扫一扫'
+      case 'scanned':
+        return '已扫码,请在手机上确认授权'
+      case 'failed':
+        return qrMessage.value || '扫码登录失败,请刷新后重试'
+      case 'expired':
+        return qrMessage.value || '二维码已过期,请刷新后重扫'
+      case 'success':
+        return '登录成功,正在跳转…'
+      default:
+        return ''
+    }
+  })
+
+  const stopPoll = () => {
+    if (pollTimer) {
+      clearInterval(pollTimer)
+      pollTimer = null
+    }
+  }
+
+  const finishLogin = async (payload: {
+    token: string
+    refreshToken?: string
+    userInfo: {
+      id: string
+      username: string
+      realName: string
+      phone: string
+      role: string
+      roleNames?: string[]
+      regionId: string
+      regionName: string
+      avatarUrl?: string | null
+    }
+  }) => {
+    userStore.setToken(payload.token, payload.refreshToken)
+    userStore.setLoginStatus(true)
+    userStore.setUserInfo(payload.userInfo)
+
+    ElNotification({
+      title: '登录成功',
+      type: 'success',
+      duration: 2500,
+      message: `欢迎回来,${payload.userInfo.realName || payload.userInfo.username}!`
+    })
+
+    resetRouteInitState()
+    const target = resolveSafeRedirectTarget(route.query.redirect)
+    await router.push(target)
+  }
+
+  const pollStatus = async () => {
+    if (!sessionId.value || loginHandled) return
+
+    try {
+      const data = await fetchWxQrStatus(sessionId.value)
+      const status = (data?.status || '') as typeof qrStatus.value
+      if (!status) return
+
+      if (status === 'pending' || status === 'scanned') {
+        qrStatus.value = status
+        return
+      }
+
+      if (status === 'success') {
+        stopPoll()
+        if (loginHandled) return
+        if (!data.token || !data.userInfo) {
+          qrStatus.value = 'failed'
+          qrMessage.value = '登录失败:未收到令牌'
+          return
+        }
+        loginHandled = true
+        qrStatus.value = 'success'
+        await finishLogin({
+          token: data.token,
+          refreshToken: data.refreshToken,
+          userInfo: data.userInfo
+        })
+        return
+      }
+
+      if (status === 'failed' || status === 'expired') {
+        stopPoll()
+        qrStatus.value = status
+        qrMessage.value = data.message || ''
+      }
+    } catch {
+      // 轮询失败不打断;保留上次状态
+    }
+  }
+
+  const startPoll = () => {
+    stopPoll()
+    pollTimer = setInterval(() => {
+      void pollStatus()
+    }, 1500)
+  }
+
+  const refreshQr = async () => {
+    stopPoll()
+    loginHandled = false
+    qrLoading.value = true
+    qrStatus.value = 'creating'
+    qrMessage.value = ''
+    qrImage.value = ''
+    sessionId.value = ''
+
+    try {
+      const data = await fetchWxQrCreate()
+      sessionId.value = data.sessionId
+      qrImage.value = data.qrImageBase64
+      qrStatus.value = 'pending'
+      startPoll()
+    } catch (error: any) {
+      qrStatus.value = 'failed'
+      qrMessage.value = error?.message || '二维码生成失败,请稍后重试'
+      ElMessage.error(qrMessage.value)
+    } finally {
+      qrLoading.value = false
+    }
+  }
 
   // 登录
   const handleSubmit = async () => {
@@ -133,28 +300,22 @@
         throw new Error('登录失败:未收到令牌')
       }
 
-      // 存储 token 和登录状态
-      userStore.setToken(token, refreshToken)
-      userStore.setLoginStatus(true)
-      userStore.setUserInfo(userInfo)
-
-      ElNotification({
-        title: '登录成功',
-        type: 'success',
-        duration: 2500,
-        message: `欢迎回来,${userInfo.realName || userInfo.username}!`
-      })
-
-      // 避免 redirect 指回登录页(如登出时误写入 /auth/login)
-      resetRouteInitState()
-      const target = resolveSafeRedirectTarget(route.query.redirect)
-      await router.push(target)
+      stopPoll()
+      await finishLogin({ token, refreshToken, userInfo })
     } catch (error: any) {
       ElMessage.error(error?.message || '登录失败,请稍后重试')
     } finally {
       loading.value = false
     }
   }
+
+  onMounted(() => {
+    void refreshQr()
+  })
+
+  onUnmounted(() => {
+    stopPoll()
+  })
 </script>
 
 <style lang="scss" scoped>
@@ -167,7 +328,7 @@
 
   .login-left {
     flex: 1;
-    background: linear-gradient(135deg, #047857 0%, #065f46 100%);
+    background: linear-gradient(135deg, #10b981 0%, #059669 100%);
     display: flex;
     align-items: center;
     justify-content: center;
@@ -254,7 +415,7 @@
   }
 
   .form-header {
-    margin-bottom: 40px;
+    margin-bottom: 28px;
     text-align: center;
 
     h2 {
@@ -271,7 +432,83 @@
     }
   }
 
+  .qr-login {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 12px;
+    margin-bottom: 8px;
+  }
+
+  .qr-frame {
+    width: 200px;
+    height: 200px;
+    border: 1px solid #e5e7eb;
+    border-radius: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+    background: #fff;
+  }
+
+  .qr-image {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+  }
+
+  .qr-placeholder {
+    font-size: 13px;
+    color: #94a3b8;
+  }
+
+  .qr-status {
+    margin: 0;
+    font-size: 13px;
+    color: #64748b;
+    text-align: center;
+    min-height: 20px;
+
+    &.error {
+      color: #dc2626;
+    }
+  }
+
+  .password-toggle {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin: 24px 0 8px;
+    cursor: pointer;
+  }
+
+  .divider-line {
+    flex: 1;
+    height: 1px;
+    background: #e5e7eb;
+  }
+
+  .toggle-btn {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    border: none;
+    background: transparent;
+    color: #64748b;
+    font-size: 13px;
+    cursor: pointer;
+    padding: 0;
+    white-space: nowrap;
+
+    &:hover {
+      color: #10b981;
+    }
+  }
+
   .login-form {
+    margin-top: 16px;
+
     :deep(.el-form-item) {
       margin-bottom: 24px;
     }
@@ -291,7 +528,7 @@
     height: 48px;
     font-size: 16px;
     border-radius: 8px;
-    background: linear-gradient(135deg, #047857 0%, #065f46 100%);
+    background: linear-gradient(135deg, #10b981 0%, #059669 100%);
     border: none;
 
     &:hover {