Przeglądaj źródła

feat(frontend): 登录页微信扫码登录入口(SC-G-03)

- auth.js 新增 adminWxScanQr/adminWxScanPoll
- Login.vue 增加密码/微信扫码双 tab;扫码面板 3s 轮询,success 复用 afterLoginSuccess 跳转逻辑(原 handleLogin 成功逻辑抽取公共方法),expired/error 可重新获取,组件卸载清理定时器
Sisyphus 1 tydzień temu
rodzic
commit
4a894d3cc0
2 zmienionych plików z 281 dodań i 45 usunięć
  1. 17 0
      cfc-web/src/api/auth.js
  2. 264 45
      cfc-web/src/views/Login.vue

+ 17 - 0
cfc-web/src/api/auth.js

@@ -27,6 +27,23 @@ export function getAccountRoles(phone) {
   })
 }
 
+// 后台微信扫码登录:获取小程序码(返回 { state, qrImage })
+export function adminWxScanQr() {
+  return request({
+    url: '/api/admin-auth/wechat-scan-qr',
+    method: 'post'
+  })
+}
+
+// 后台微信扫码登录:轮询扫码结果(返回 { status, token?, adminId?, role?, roles?, message? })
+export function adminWxScanPoll(state) {
+  return request({
+    url: '/api/admin-auth/wechat-scan-poll',
+    method: 'post',
+    data: { state }
+  })
+}
+
 // 获取管理员信息
 export function getAdminInfo() {
   return request({

+ 264 - 45
cfc-web/src/views/Login.vue

@@ -135,7 +135,51 @@
           <p class="form-desc">管理员 / 规划师登录</p>
         </div>
 
+        <!-- 登录方式切换:密码登录 / 微信扫码 -->
+        <div class="login-mode-tabs">
+          <span
+            class="login-mode-tab"
+            :class="{ active: loginMode === 'password' }"
+            @click="switchMode('password')"
+          >密码登录</span>
+          <span
+            class="login-mode-tab"
+            :class="{ active: loginMode === 'wechat' }"
+            @click="switchMode('wechat')"
+          >微信扫码登录</span>
+        </div>
+
+        <!-- 微信扫码登录面板 -->
+        <div class="wechat-panel" v-if="loginMode === 'wechat'">
+          <div class="wechat-qr-box">
+            <img
+              v-if="wxQrImage"
+              :src="wxQrImage"
+              class="wechat-qr-img"
+              alt="微信扫码登录二维码"
+            />
+            <div v-else class="wechat-qr-placeholder">
+              <i class="el-icon-loading" v-if="wxQrLoading"></i>
+              <span v-else>测试模式:请配置真实小程序码</span>
+            </div>
+          </div>
+          <div class="wechat-tip">
+            <span v-if="wxStatus === 'success'">登录成功,正在跳转...</span>
+            <span v-else-if="wxStatus === 'expired'">二维码已过期,请重新获取</span>
+            <span v-else-if="wxStatus === 'error'">扫码失败,请重新获取</span>
+            <span v-else>请使用微信扫描上方二维码登录</span>
+          </div>
+          <div class="wechat-actions">
+            <el-button
+              v-if="wxStatus === 'expired' || wxStatus === 'error'"
+              type="text"
+              @click="startWxScan"
+            >重新获取二维码</el-button>
+          </div>
+        </div>
+
         <el-form
+          v-if="loginMode === 'password'"
           :model="loginForm"
           :rules="rules"
           ref="loginForm"
@@ -216,7 +260,7 @@
 </template>
 
 <script>
-import { adminLogin, getAccountRoles } from '@/api/auth'
+import { adminLogin, getAccountRoles, adminWxScanQr, adminWxScanPoll } from '@/api/auth'
 import { getRoleHomePage } from '@/utils/permissions'
 import { orgMy } from '@/api/org'
 import SliderCaptcha from '@/components/SliderCaptcha'
@@ -246,7 +290,14 @@ export default {
       identityOptions: [],
       identityLoading: false,
       lastFetchedPhone: '',
-      phoneInputTimer: null
+      phoneInputTimer: null,
+      // 微信扫码登录
+      loginMode: 'password',
+      wxState: '',
+      wxQrImage: '',
+      wxQrLoading: false,
+      wxStatus: '',
+      wxTimer: null
     }
   },
   mounted() {
@@ -262,6 +313,9 @@ export default {
       })
     }
   },
+  beforeDestroy() {
+    this.stopWxPolling()
+  },
   methods: {
     onCaptchaSuccess() {
       this.captchaPassed = true
@@ -330,49 +384,7 @@ export default {
             password: this.loginForm.password,
             loginRole: this.loginForm.loginRole || undefined
           })
-          localStorage.setItem('token', res.data.token)
-          localStorage.setItem('adminId', res.data.adminId)
-          localStorage.setItem('adminName', res.data.realName)
-          const role = res.data.role || 'admin'
-          localStorage.setItem('role', role)
-          const roles = res.data.roles || [role]
-          localStorage.setItem('roles', JSON.stringify(roles))
-          if (this.$message) {
-            this.$message.success('登录成功')
-          } else {
-            console.log('登录成功')
-          }
-        await this.$nextTick()
-        // A-1 组织管理员(org_admin 且不含平台角色)登录后直跳自身组织的大屏
-        const rolesArr = res.data.roles || [role]
-        if (rolesArr.indexOf('org_admin') >= 0 && rolesArr.indexOf('admin') < 0
-            && rolesArr.indexOf('operator') < 0 && rolesArr.indexOf('finance') < 0) {
-          try {
-            const orgRes = await orgMy()
-            if (orgRes && orgRes.data && orgRes.data.id) {
-              this.$router.replace({ path: '/org/' + orgRes.data.id + '/dashboard' })
-            } else if (orgRes && this.$message) {
-              this.$message.error(orgRes.message || '组织已停用或未关联,无法进入管理端')
-              localStorage.removeItem('token')
-              localStorage.removeItem('roles')
-              localStorage.removeItem('role')
-              this.resetCaptcha()
-            }
-          } catch (eOrg) {
-            // 组织已停用/未关联:my-org 返回错误 → 清理登录态回到登录页(错误提示由 request 拦截器弹出)
-            localStorage.removeItem('token')
-            localStorage.removeItem('roles')
-            localStorage.removeItem('role')
-            if (this.$message) {
-              this.$message.error((eOrg && eOrg.message) ? eOrg.message : '组织已停用,无法进入管理端')
-            }
-            this.resetCaptcha()
-          }
-          return
-        }
-        // B-6 按所选登录身份跳转:入驻角色进个人工作台,平台角色进首页
-        const redirectRole = getRoleHomePage(rolesArr)
-        this.$router.replace({ path: redirectRole })
+          this.afterLoginSuccess(res)
         } catch (e) {
           const msg = e && e.message ? e.message : '登录失败,请检查账号密码'
           if (this.$message) {
@@ -385,6 +397,133 @@ export default {
           this.loading = false
         }
       })
+    },
+    switchMode(mode) {
+      if (this.loginMode === mode) {
+        return
+      }
+      if (mode === 'wechat') {
+        this.loginMode = 'wechat'
+        this.startWxScan()
+      } else {
+        this.stopWxPolling()
+        this.loginMode = 'password'
+      }
+    },
+    // 获取小程序码并开始轮询
+    async startWxScan() {
+      this.stopWxPolling()
+      this.wxState = ''
+      this.wxQrImage = ''
+      this.wxStatus = ''
+      this.wxQrLoading = true
+      try {
+        const res = await adminWxScanQr()
+        this.wxQrLoading = false
+        if (!res || !res.data || !res.data.state) {
+          this.wxStatus = 'error'
+          if (this.$message) this.$message.error('获取二维码失败,请重试')
+          return
+        }
+        this.wxState = res.data.state
+        this.wxQrImage = res.data.qrImage || ''
+        this.wxStatus = 'pending'
+        // 每 3s 轮询扫码结果
+        this.wxTimer = setInterval(() => {
+          this.doWxPoll()
+        }, 3000)
+      } catch (e) {
+        this.wxQrLoading = false
+        this.wxStatus = 'error'
+        const msg = e && e.message ? e.message : '获取二维码失败'
+        if (this.$message) this.$message.error(msg)
+      }
+    },
+    async doWxPoll() {
+      if (!this.wxState) {
+        return
+      }
+      try {
+        const res = await adminWxScanPoll(this.wxState)
+        const status = res && res.data ? res.data.status : 'expired'
+        if (status === 'success') {
+          this.stopWxPolling()
+          this.wxStatus = 'success'
+          this.afterLoginSuccess(res)
+        } else if (status === 'pending') {
+          this.wxStatus = 'pending'
+        } else if (status === 'expired') {
+          this.stopWxPolling()
+          this.wxStatus = 'expired'
+        } else {
+          // error
+          this.stopWxPolling()
+          this.wxStatus = 'error'
+          const msg = res && res.data && res.data.message ? res.data.message : '扫码登录失败'
+          if (this.$message) this.$message.error(msg)
+        }
+      } catch (e) {
+        this.stopWxPolling()
+        this.wxStatus = 'error'
+        const msg = e && e.message ? e.message : '扫码登录失败'
+        if (this.$message) this.$message.error(msg)
+      }
+    },
+    stopWxPolling() {
+      if (this.wxTimer) {
+        clearInterval(this.wxTimer)
+        this.wxTimer = null
+      }
+    },
+    // 登录成功后的公共跳转逻辑(密码登录与微信扫码登录共用)
+    async afterLoginSuccess(res) {
+      if (!res || !res.data || !res.data.token) {
+        if (this.$message) this.$message.error('登录失败,请重试')
+        return
+      }
+      localStorage.setItem('token', res.data.token)
+      localStorage.setItem('adminId', res.data.adminId)
+      localStorage.setItem('adminName', res.data.realName)
+      const role = res.data.role || 'admin'
+      localStorage.setItem('role', role)
+      const roles = res.data.roles || [role]
+      localStorage.setItem('roles', JSON.stringify(roles))
+      if (this.$message) {
+        this.$message.success('登录成功')
+      } else {
+        console.log('登录成功')
+      }
+      await this.$nextTick()
+      // A-1 组织管理员(org_admin 且不含平台角色)登录后直跳自身组织的大屏
+      const rolesArr = res.data.roles || [role]
+      if (rolesArr.indexOf('org_admin') >= 0 && rolesArr.indexOf('admin') < 0
+          && rolesArr.indexOf('operator') < 0 && rolesArr.indexOf('finance') < 0) {
+        try {
+          const orgRes = await orgMy()
+          if (orgRes && orgRes.data && orgRes.data.id) {
+            this.$router.replace({ path: '/org/' + orgRes.data.id + '/dashboard' })
+          } else if (orgRes && this.$message) {
+            this.$message.error(orgRes.message || '组织已停用或未关联,无法进入管理端')
+            localStorage.removeItem('token')
+            localStorage.removeItem('roles')
+            localStorage.removeItem('role')
+            this.resetCaptcha()
+          }
+        } catch (eOrg) {
+          // 组织已停用/未关联:my-org 返回错误 → 清理登录态回到登录页(错误提示由 request 拦截器弹出)
+          localStorage.removeItem('token')
+          localStorage.removeItem('roles')
+          localStorage.removeItem('role')
+          if (this.$message) {
+            this.$message.error((eOrg && eOrg.message) ? eOrg.message : '组织已停用,无法进入管理端')
+          }
+          this.resetCaptcha()
+        }
+        return
+      }
+      // B-6 按所选登录身份跳转:入驻角色进个人工作台,平台角色进首页
+      const redirectRole = getRoleHomePage(rolesArr)
+      this.$router.replace({ path: redirectRole })
     }
   }
 }
@@ -951,4 +1090,84 @@ export default {
 .role-tag-blue { border-color:var(--adm-primary); color:#4096FF; }
 .role-tag-pink { border-color:#FF6B9D; color:#FF8CB3; }
 .role-tag-green { border-color:#10B981; color:#34D399; }
+
+/* ==================== 登录方式切换 ==================== */
+.login-mode-tabs {
+  display: flex;
+  border-bottom: 1px solid #E2E8F0;
+  margin-bottom: 24px;
+}
+
+.login-mode-tab {
+  flex: 1;
+  text-align: center;
+  padding: 10px 0;
+  font-size: 14px;
+  color: #94A3B8;
+  cursor: pointer;
+  border-bottom: 2px solid transparent;
+  transition: all 0.25s;
+  font-family: inherit;
+  letter-spacing: 2px;
+}
+
+.login-mode-tab:hover {
+  color: #4A9BD7;
+}
+
+.login-mode-tab.active {
+  color: #3A7CC4;
+  border-bottom-color: #4A9BD7;
+  font-weight: 600;
+}
+
+/* ==================== 微信扫码登录面板 ==================== */
+.wechat-panel {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 12px 0 20px;
+}
+
+.wechat-qr-box {
+  width: 200px;
+  height: 200px;
+  border: 1px solid #E2E8F0;
+  border-radius: 12px;
+  background: #F8FAFC;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+
+.wechat-qr-img {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+}
+
+.wechat-qr-placeholder {
+  font-size: 13px;
+  color: #94A3B8;
+  text-align: center;
+  padding: 0 16px;
+  line-height: 1.8;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+}
+
+.wechat-tip {
+  margin-top: 16px;
+  font-size: 13px;
+  color: #64748B;
+  letter-spacing: 1px;
+}
+
+.wechat-actions {
+  margin-top: 8px;
+  min-height: 28px;
+}
 </style>