Browse Source

merge(SC-G-03): web 后台微信扫码登录(复用小程序码 SSO)合入 cfclub

Agnes Agent 1 tuần trước cách đây
mục cha
commit
eec0db368

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -60,6 +60,8 @@ private static final String[] PUBLIC_PATHS = {
          "/api/admin-auth/login",
          "/api/admin-auth/login-by-password",
          "/api/admin-auth/account-roles",
+         "/api/admin-auth/wechat-scan-qr",
+         "/api/admin-auth/wechat-scan-poll",
          "/api/articles/list",
          "/api/articles/detail",
          "/api/articles/categories",

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -108,6 +108,8 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/admin-auth/login",
                 "/api/admin-auth/login-by-password",
                 "/api/admin-auth/account-roles",
+                "/api/admin-auth/wechat-scan-qr",
+                "/api/admin-auth/wechat-scan-poll",
                 "/api/product/list",
                 "/api/product/detail",
                 "/api/special-price/info",
@@ -158,6 +160,8 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/admin-auth/login",
                 "/api/admin-auth/login-by-password",
                 "/api/admin-auth/account-roles",
+                "/api/admin-auth/wechat-scan-qr",
+                "/api/admin-auth/wechat-scan-poll",
                 "/api/product/list",
                 "/api/product/detail",
                 "/api/special-price/info",

+ 117 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuthController.java

@@ -12,6 +12,7 @@ import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.LoginAttemptService;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.VerificationCodeService;
+import com.etotem.cfc.service.WechatWebLoginService;
 import lombok.extern.slf4j.Slf4j;
 import javax.annotation.Resource;
 import org.springframework.beans.factory.annotation.Value;
@@ -24,6 +25,7 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 
 @Slf4j
 @RestController
@@ -47,6 +49,9 @@ public class AdminAuthController {
     @Resource
     private LoginAttemptService loginAttemptService;
 
+    @Resource
+    private WechatWebLoginService wechatWebLoginService;
+
     @Value("${admin.skip-captcha:false}")
     private boolean skipCaptcha;
 
@@ -330,6 +335,118 @@ public class AdminAuthController {
         return Result.success(result);
     }
 
+    /**
+     * 后台管理端微信扫码登录:获取小程序码。
+     *
+     * <p>复用小程序端「小程序码扫码 SSO」的 state 机制({@link WechatWebLoginService}),
+     * 但与 {@code /api/auth/wechat-web/*} 完全独立:此处不调用 {@code UserService.ssoLogin}
+     * (该方法拒绝 admin 账号),改为按后台角色白名单签发管理端 JWT。
+     */
+    @PostMapping("/wechat-scan-qr")
+    public Result<Map<String, Object>> wechatScanQr() {
+        String state = UUID.randomUUID().toString().replace("-", "");
+        wechatWebLoginService.putState(state, "P");
+        Map<String, Object> data = new HashMap<>();
+        data.put("state", state);
+        try {
+            String base64 = wechatWebLoginService.generateQrImage(state);
+            data.put("qrImage", base64 == null || base64.isEmpty() ? "" : "data:image/png;base64," + base64);
+        } catch (RuntimeException e) {
+            log.error("生成后台登录小程序码失败: state={}", state, e);
+            wechatWebLoginService.removeState(state);
+            return Result.error("生成二维码失败:" + e.getMessage());
+        }
+        return Result.success(data);
+    }
+
+    /**
+     * 后台管理端微信扫码登录:轮询扫码结果。
+     *
+     * <p>status: pending(待扫码)/ expired(二维码过期)/ success(已签发 JWT)/ error(无管理权限等)。
+     * 成功返回字段与 {@code /api/admin-auth/login} 对齐,token 的 role claim 为选中后台身份。
+     */
+    @PostMapping("/wechat-scan-poll")
+    public Result<Map<String, Object>> wechatScanPoll(@RequestBody Map<String, String> body) {
+        String state = body != null ? body.get("state") : null;
+        Map<String, Object> data = new HashMap<>();
+        if (state == null || state.trim().isEmpty()) {
+            return Result.error("缺少 state");
+        }
+        Object stored = wechatWebLoginService.getState(state);
+        if (stored == null) {
+            data.put("status", "expired");
+            return Result.success(data);
+        }
+        String value = stored.toString();
+        if ("P".equals(value)) {
+            data.put("status", "pending");
+            return Result.success(data);
+        }
+        if (!value.startsWith("U")) {
+            data.put("status", "error");
+            data.put("message", "未知状态");
+            return Result.success(data);
+        }
+
+        // 已确认扫码即为终态:先清除 state,防止重复轮询重放
+        wechatWebLoginService.removeState(state);
+
+        Long userId;
+        try {
+            userId = Long.valueOf(value.substring(1));
+        } catch (NumberFormatException e) {
+            data.put("status", "error");
+            data.put("message", "二维码状态异常");
+            return Result.success(data);
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            data.put("status", "error");
+            data.put("message", "用户不存在");
+            return Result.success(data);
+        }
+
+        // parent/child 等 C 端账号不允许登录后台
+        List<String> backendRoles = AdminRoles.backendRolesOf(user.getRoles(), user.getRole());
+        if (backendRoles.isEmpty()) {
+            log.warn("用户 {} 扫码登录后台管理,无后台角色,角色为: {}", userId, user.getRole());
+            data.put("status", "error");
+            data.put("message", "该账号没有管理权限");
+            return Result.success(data);
+        }
+
+        // 身份选择:多角色账号可指定以哪个角色登录(对齐 /login 的身份选择逻辑)
+        String selected = backendRoles.get(0);
+        String loginRole = body.get("loginRole");
+        if (loginRole != null && !loginRole.isEmpty()) {
+            String normalized = AdminRoles.normalize(loginRole);
+            if (!backendRoles.contains(normalized)) {
+                data.put("status", "error");
+                data.put("message", "该账号没有【" + AdminRoles.roleLabel(normalized) + "】身份");
+                return Result.success(data);
+            }
+            selected = normalized;
+        }
+
+        List<String> roles = userService.getUserRoles(user.getId());
+        if (roles.isEmpty()) {
+            roles = Collections.singletonList(selected);
+        }
+
+        String token = jwtConfig.generateToken(user.getId(), buildTokenRoles(selected, roles));
+        log.info("后台微信扫码登录成功: userId={}, role={}", userId, selected);
+
+        data.put("status", "success");
+        data.put("token", token);
+        data.put("adminId", user.getId());
+        data.put("username", user.getNickname() != null ? user.getNickname() : user.getPhone());
+        data.put("realName", user.getRealName() != null ? user.getRealName() : user.getNickname());
+        data.put("role", selected);
+        data.put("roles", roles);
+        return Result.success(data);
+    }
+
     /**
      * 查询账号可用的后台身份列表(登录页身份选择下拉框用,公开接口)
      * 返回: [{role: 'admin', label: '超级管理员'}, ...]

+ 249 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminAuthWechatScanTest.java

@@ -0,0 +1,249 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.WechatWebLoginService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 后台管理端微信扫码登录(复用小程序码扫码 SSO 的 state 机制)。
+ *
+ * <p>覆盖:二维码生成、待扫码/过期轮询、成功后签发管理端 JWT 并清除 state(防重放)、
+ * 无后台角色(parent/child)拒绝、身份不匹配拒绝。</p>
+ */
+class AdminAuthWechatScanTest {
+
+    private AdminAuthController controller;
+    private WechatWebLoginService wechatWebLoginService;
+    private UserMapper userMapper;
+    private UserService userService;
+    private JwtConfig jwtConfig;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new AdminAuthController();
+        wechatWebLoginService = mock(WechatWebLoginService.class);
+        userMapper = mock(UserMapper.class);
+        userService = mock(UserService.class);
+        jwtConfig = mock(JwtConfig.class);
+        inject("wechatWebLoginService", wechatWebLoginService);
+        inject("userMapper", userMapper);
+        inject("userService", userService);
+        inject("jwtConfig", jwtConfig);
+    }
+
+    @Test
+    @DisplayName("wechat-scan-qr:返回非空 state,test-mode 下 qrImage 为空串")
+    void scanQr_returnsStateAndQrImage() {
+        when(wechatWebLoginService.generateQrImage(anyString())).thenReturn("");
+
+        Result<Map<String, Object>> result = controller.wechatScanQr();
+
+        assertEquals(200, result.getCode());
+        assertNotNull(result.getData());
+        Object state = result.getData().get("state");
+        assertNotNull(state);
+        assertTrue(state.toString().length() > 0);
+        assertEquals("", result.getData().get("qrImage"));
+        verify(wechatWebLoginService).putState(state.toString(), "P");
+    }
+
+    @Test
+    @DisplayName("wechat-scan-qr:generateQrImage 异常时清 state 并返回错误")
+    void scanQr_qrImageFailure_removesStateAndErrors() {
+        when(wechatWebLoginService.generateQrImage(anyString()))
+                .thenThrow(new RuntimeException("微信接口不可用"));
+
+        Result<Map<String, Object>> result = controller.wechatScanQr();
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("微信接口不可用"));
+        verify(wechatWebLoginService).removeState(anyString());
+    }
+
+    @Test
+    @DisplayName("wechat-scan-poll:state=null 返回 expired")
+    void poll_stateMissing_returnsExpired() {
+        when(wechatWebLoginService.getState("s1")).thenReturn(null);
+
+        Result<Map<String, Object>> result = controller.wechatScanPoll(body("s1"));
+
+        assertEquals(200, result.getCode());
+        assertEquals("expired", result.getData().get("status"));
+        verify(wechatWebLoginService, never()).removeState(anyString());
+    }
+
+    @Test
+    @DisplayName("wechat-scan-poll:state=P 返回 pending")
+    void poll_pending_returnsPending() {
+        when(wechatWebLoginService.getState("s2")).thenReturn("P");
+
+        Result<Map<String, Object>> result = controller.wechatScanPoll(body("s2"));
+
+        assertEquals("pending", result.getData().get("status"));
+    }
+
+    @Test
+    @DisplayName("wechat-scan-poll:有后台角色 → success,返回 token/adminId/role/roles")
+    void poll_backendRole_returnsSuccessWithToken() {
+        User user = user(9L, "admin", Collections.singletonList("admin"));
+        when(wechatWebLoginService.getState("s3")).thenReturn("U9");
+        when(userMapper.selectById(9L)).thenReturn(user);
+        when(userService.getUserRoles(9L)).thenReturn(Collections.singletonList("admin"));
+        when(jwtConfig.generateToken(eq(9L), anyList())).thenReturn("jwt-token");
+
+        Result<Map<String, Object>> result = controller.wechatScanPoll(body("s3"));
+
+        assertEquals(200, result.getCode());
+        Map<String, Object> data = result.getData();
+        assertEquals("success", data.get("status"));
+        assertEquals("jwt-token", data.get("token"));
+        assertEquals(9L, data.get("adminId"));
+        assertEquals("admin", data.get("role"));
+        assertEquals(Collections.singletonList("admin"), data.get("roles"));
+        assertEquals("管理员9", data.get("realName"));
+        // 成功后 state 清除,防重放
+        verify(wechatWebLoginService).removeState("s3");
+    }
+
+    @Test
+    @DisplayName("wechat-scan-poll:parent 无后台角色 → error 且不签发 token")
+    void poll_clientRole_returnsError() {
+        User user = user(18L, "parent", Collections.singletonList("parent"));
+        when(wechatWebLoginService.getState("s4")).thenReturn("U18");
+        when(userMapper.selectById(18L)).thenReturn(user);
+
+        Result<Map<String, Object>> result = controller.wechatScanPoll(body("s4"));
+
+        Map<String, Object> data = result.getData();
+        assertEquals("error", data.get("status"));
+        assertEquals("该账号没有管理权限", data.get("message"));
+        assertNull(data.get("token"));
+        verify(jwtConfig, never()).generateToken(any(Long.class), anyList());
+    }
+
+    @Test
+    @DisplayName("wechat-scan-poll:child 无后台角色(roles 为空)→ error")
+    void poll_childRole_returnsError() {
+        User user = user(19L, "child", null);
+        when(wechatWebLoginService.getState("s5")).thenReturn("U19");
+        when(userMapper.selectById(19L)).thenReturn(user);
+
+        Result<Map<String, Object>> result = controller.wechatScanPoll(body("s5"));
+
+        assertEquals("error", result.getData().get("status"));
+        assertEquals("该账号没有管理权限", result.getData().get("message"));
+    }
+
+    @Test
+    @DisplayName("wechat-scan-poll:指定 loginRole 命中多角色 → token role 为选中身份")
+    void poll_multiRoleWithSelectedRole_usesSelected() {
+        User user = user(20L, "teacher", Arrays.asList("teacher", "org_admin"));
+        when(wechatWebLoginService.getState("s6")).thenReturn("U20");
+        when(userMapper.selectById(20L)).thenReturn(user);
+        when(userService.getUserRoles(20L)).thenReturn(Arrays.asList("teacher", "org_admin"));
+        when(jwtConfig.generateToken(eq(20L), anyList())).thenReturn("jwt-org");
+
+        Map<String, String> body = body("s6");
+        body.put("loginRole", "org_admin");
+        Result<Map<String, Object>> result = controller.wechatScanPoll(body);
+
+        assertEquals("success", result.getData().get("status"));
+        assertEquals("org_admin", result.getData().get("role"));
+        assertEquals("jwt-org", result.getData().get("token"));
+    }
+
+    @Test
+    @DisplayName("wechat-scan-poll:指定 loginRole 不在账号角色内 → error")
+    void poll_selectedRoleNotOwned_returnsError() {
+        User user = user(21L, "teacher", Collections.singletonList("teacher"));
+        when(wechatWebLoginService.getState("s7")).thenReturn("U21");
+        when(userMapper.selectById(21L)).thenReturn(user);
+
+        Map<String, String> body = body("s7");
+        body.put("loginRole", "admin");
+        Result<Map<String, Object>> result = controller.wechatScanPoll(body);
+
+        assertEquals("error", result.getData().get("status"));
+        assertEquals("该账号没有【超级管理员】身份", result.getData().get("message"));
+        verify(jwtConfig, never()).generateToken(any(Long.class), anyList());
+    }
+
+    @Test
+    @DisplayName("wechat-scan-poll:成功后再次轮询同 state → expired(防重放)")
+    void poll_afterSuccess_stateCleared() {
+        User user = user(22L, "admin", Collections.singletonList("admin"));
+        when(wechatWebLoginService.getState("s8")).thenReturn("U22").thenReturn(null);
+        when(userMapper.selectById(22L)).thenReturn(user);
+        when(userService.getUserRoles(22L)).thenReturn(Collections.singletonList("admin"));
+        when(jwtConfig.generateToken(eq(22L), anyList())).thenReturn("jwt-2");
+
+        Result<Map<String, Object>> first = controller.wechatScanPoll(body("s8"));
+        Result<Map<String, Object>> second = controller.wechatScanPoll(body("s8"));
+
+        assertEquals("success", first.getData().get("status"));
+        assertEquals("expired", second.getData().get("status"));
+        verify(wechatWebLoginService).removeState("s8");
+    }
+
+    @Test
+    @DisplayName("wechat-scan-poll:缺少 state 返回错误提示")
+    void poll_blankState_returnsError() {
+        Map<String, String> body = new HashMap<>();
+        body.put("state", " ");
+
+        Result<Map<String, Object>> result = controller.wechatScanPoll(body);
+
+        assertEquals(500, result.getCode());
+        assertEquals("缺少 state", result.getMessage());
+    }
+
+    private static Map<String, String> body(String state) {
+        Map<String, String> body = new HashMap<>();
+        body.put("state", state);
+        return body;
+    }
+
+    private static User user(Long id, String role, List<String> roles) {
+        User user = new User();
+        user.setId(id);
+        user.setRole(role);
+        user.setRoles(roles == null ? null : new ArrayList<>(roles));
+        user.setNickname("nick" + id);
+        user.setRealName("管理员" + id);
+        user.setPhone("1380000" + id);
+        return user;
+    }
+
+    private void inject(String fieldName, Object value) throws Exception {
+        Field field = AdminAuthController.class.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(controller, value);
+    }
+}

+ 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>