Prechádzať zdrojové kódy

feat(web-admin): default login to WeChat QR with password tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 mesiac pred
rodič
commit
29898a107b
1 zmenil súbory, kde vykonal 343 pridanie a 68 odobranie
  1. 343 68
      web-admin/src/views/auth/login/index.vue

+ 343 - 68
web-admin/src/views/auth/login/index.vue

@@ -1,4 +1,4 @@
-<!-- 登录页面(样式对齐 CRM 山峡化工登录页) -->
+<!-- 登录页面(样式对齐 CRM 山峡化工登录页;扫码 + 账号密码 Tab) -->
 <template>
   <div class="login" :class="{ 'bg-loaded': backgroundLoaded }">
     <div v-if="!backgroundLoaded" class="bg-placeholder"></div>
@@ -7,61 +7,103 @@
       <span class="loading-text">背景加载中...</span>
     </div>
 
-    <el-form
-      ref="formRef"
-      :model="formData"
-      :rules="rules"
-      class="login-form"
-      @keyup.enter="handleSubmit"
-    >
+    <div class="login-form">
       <div class="title-box">
         <img class="login-brand-logo" src="/logo.gif" alt="山峡化工" />
         <h3 class="title">{{ pageTitle }}</h3>
       </div>
 
-      <el-form-item prop="username">
-        <el-input
-          v-model.trim="formData.username"
-          type="text"
-          size="large"
-          auto-complete="off"
-          placeholder="请输入用户名"
-        >
-          <template #prefix>
-            <el-icon class="input-icon"><User /></el-icon>
-          </template>
-        </el-input>
-      </el-form-item>
-
-      <el-form-item prop="password">
-        <el-input
-          v-model.trim="formData.password"
-          type="password"
-          size="large"
-          auto-complete="off"
-          placeholder="请输入密码"
-          show-password
-          @keyup.enter="handleSubmit"
-        >
-          <template #prefix>
-            <el-icon class="input-icon"><Lock /></el-icon>
-          </template>
-        </el-input>
-      </el-form-item>
-
-      <el-form-item style="width: 100%">
-        <el-button
-          type="primary"
-          :loading="loading"
-          size="large"
-          class="login-button"
-          @click.prevent="handleSubmit"
-        >
-          <span v-if="!loading">登 录</span>
-          <span v-else>登录中...</span>
-        </el-button>
-      </el-form-item>
-    </el-form>
+      <p class="login-subtitle">
+        {{
+          loginTab === 'wechat'
+            ? '微信扫一扫,授权手机号后自动登录'
+            : '请输入账号密码登录系统'
+        }}
+      </p>
+
+      <el-tabs
+        v-model="loginTab"
+        class="login-tabs"
+        stretch
+        @tab-change="handleLoginTabChange"
+      >
+        <el-tab-pane label="微信扫码" name="wechat">
+          <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>
+        </el-tab-pane>
+
+        <el-tab-pane label="账号密码" name="password">
+          <el-form
+            ref="formRef"
+            :model="formData"
+            :rules="rules"
+            @keyup.enter="handleSubmit"
+          >
+            <el-form-item prop="username">
+              <el-input
+                v-model.trim="formData.username"
+                type="text"
+                size="large"
+                auto-complete="off"
+                placeholder="请输入用户名"
+              >
+                <template #prefix>
+                  <el-icon class="input-icon"><User /></el-icon>
+                </template>
+              </el-input>
+            </el-form-item>
+
+            <el-form-item prop="password">
+              <el-input
+                v-model.trim="formData.password"
+                type="password"
+                size="large"
+                auto-complete="off"
+                placeholder="请输入密码"
+                show-password
+                @keyup.enter="handleSubmit"
+              >
+                <template #prefix>
+                  <el-icon class="input-icon"><Lock /></el-icon>
+                </template>
+              </el-input>
+            </el-form-item>
+
+            <el-form-item style="width: 100%">
+              <el-button
+                type="primary"
+                :loading="loading"
+                size="large"
+                class="login-button"
+                @click.prevent="handleSubmit"
+              >
+                <template v-if="!loading">
+                  <el-icon><Check /></el-icon>
+                  登 录
+                </template>
+                <span v-else>登录中...</span>
+              </el-button>
+            </el-form-item>
+          </el-form>
+        </el-tab-pane>
+      </el-tabs>
+    </div>
 
     <div class="el-login-footer">
       <span>Copyright © 2025-{{ currentYear }} 山峡化工有限公司 All Rights Reserved.</span>
@@ -72,12 +114,12 @@
 <script setup lang="ts">
   import AppConfig from '@/config'
   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 { ElNotification, type FormInstance, type FormRules, type TabPaneName } from 'element-plus'
   import { ElMessage } from 'element-plus'
-  import { User, Lock } from '@element-plus/icons-vue'
+  import { User, Lock, Refresh, Check } from '@element-plus/icons-vue'
 
   defineOptions({ name: 'Login' })
 
@@ -89,6 +131,7 @@
   const route = useRoute()
 
   const formRef = ref<FormInstance>()
+  const loginTab = ref<'wechat' | 'password'>('wechat')
 
   const formData = reactive({
     username: '',
@@ -104,6 +147,35 @@
   const backgroundLoaded = ref(false)
   const loadingProgress = ref(0)
 
+  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 preloadBackgroundImage = () => {
     const img = new Image()
     const backgroundImageUrl = new URL('@/assets/images/login/sx1.jpg', import.meta.url).href
@@ -130,6 +202,130 @@
     img.src = backgroundImageUrl
   }
 
+  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
+      roles?: 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 handleLoginTabChange = (name: TabPaneName) => {
+    if (name === 'password') {
+      stopPoll()
+      return
+    }
+    if (name === 'wechat') {
+      if (!sessionId.value || qrStatus.value === 'failed' || qrStatus.value === 'expired') {
+        void refreshQr()
+      } else if (qrStatus.value === 'pending' || qrStatus.value === 'scanned') {
+        startPoll()
+      }
+    }
+  }
+
   const handleSubmit = async () => {
     if (!formRef.value) return
 
@@ -148,20 +344,8 @@
         throw new Error('登录失败:未收到令牌')
       }
 
-      userStore.setToken(token, refreshToken)
-      userStore.setLoginStatus(true)
-      userStore.setUserInfo(userInfo)
-
-      ElNotification({
-        title: '登录成功',
-        type: 'success',
-        duration: 2500,
-        message: `欢迎回来,${userInfo.realName || userInfo.username}!`
-      })
-
-      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 {
@@ -171,6 +355,11 @@
 
   onMounted(() => {
     preloadBackgroundImage()
+    void refreshQr()
+  })
+
+  onUnmounted(() => {
+    stopPoll()
   })
 </script>
 
@@ -264,6 +453,13 @@
     }
   }
 
+  .login-subtitle {
+    margin: -18px 0 16px;
+    text-align: center;
+    font-size: 13px;
+    color: #64748b;
+  }
+
   .login-form {
     border-radius: 6px;
     background: rgba(255, 255, 255, 0.55);
@@ -313,6 +509,81 @@
     }
   }
 
+  .login-tabs {
+    :deep(.el-tabs__header) {
+      margin-bottom: 20px;
+    }
+
+    :deep(.el-tabs__nav-wrap::after) {
+      height: 1px;
+      background-color: #e5e7eb;
+    }
+
+    :deep(.el-tabs__item) {
+      font-size: 15px;
+      color: #64748b;
+
+      &:hover {
+        color: var(--el-color-primary);
+      }
+
+      &.is-active {
+        color: var(--el-color-primary);
+        font-weight: 600;
+      }
+    }
+
+    :deep(.el-tabs__active-bar) {
+      background-color: var(--el-color-primary);
+    }
+
+    :deep(.el-tabs__content) {
+      overflow: visible;
+    }
+  }
+
+  .qr-login {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 12px;
+    padding-bottom: 8px;
+  }
+
+  .qr-frame {
+    width: 200px;
+    height: 200px;
+    border: 1px solid #e5e7eb;
+    border-radius: 10px;
+    background: #fff;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+  }
+
+  .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;
+
+    &.error {
+      color: #dc2626;
+    }
+  }
+
   .el-login-footer {
     height: 40px;
     line-height: 40px;
@@ -332,6 +603,10 @@
     color: #fff;
     background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-dark-2) 100%) !important;
     border: none;
+
+    .el-icon {
+      margin-right: 6px;
+    }
   }
 
   @media (max-width: 768px) {