Kaynağa Gözat

auth: PC 微信扫码对齐后端真实契约(qrcode 编码 + 复用两步②)

- wechat.ts:占位契约改真实契约——GET /auth/wechat/qrcode 返 {state,qrUrl,campusId,expiresIn};
  GET /auth/wechat/status 返 {status:pending|success|unbound|fail,campusId,loginTicket?,userId?,
  memberships?,expired?}。success 不再直发 TokenPair,改为回 loginTicket+memberships。
- login/index.vue:qrUrl 是微信网页授权 URL(非图片),前端用 qrcode 库编码成二维码图渲染;
  抽出 completeStep2(loginTicket, memberships) 统一②分流(单条自动 issue / 多条 applyStep1→
  /org/scope / 空提示),账密与微信扫码复用同一套②;status 状态机 pending/success/unbound/
  fail/expired 各自处理(success 走②、unbound/fail/expired 停轮询+提示)。
- deps:+qrcode(运行时业务依赖,Vite 打进业务层,非框架 UMD) +@types/qrcode。
- 门禁:typecheck/lint/build 全绿;dist grep 无 Vue 运行时(shims 转发层经 globalThis.Vue,
  登录 chunk 11.76kB 仅含 qrcode 编码 + 业务逻辑,未泄漏 ~400kB 运行时)。
Q 3 gün önce
ebeveyn
işleme
b629606fb8
4 değiştirilmiş dosya ile 456 ekleme ve 121 silme
  1. 4 0
      package.json
  2. 218 5
      pnpm-lock.yaml
  3. 54 35
      src/api/auth/wechat.ts
  4. 180 81
      src/views/login/index.vue

+ 4 - 0
package.json

@@ -13,6 +13,7 @@
   "packageManager": "pnpm@10.12.1+sha1.4bd977ce548dc9fcd1ca0c62838c55646cea6847",
   "devDependencies": {
     "@eslint/js": "^9.39.4",
+    "@types/qrcode": "^1.5.6",
     "@vitejs/plugin-vue": "^6.0.10",
     "axios": "1.21.0",
     "dayjs": "1.11.23",
@@ -29,5 +30,8 @@
     "vue": "3.5.45",
     "vue-router": "4.6.4",
     "vue-tsc": "^3.3.14"
+  },
+  "dependencies": {
+    "qrcode": "^1.5.4"
   }
 }

+ 218 - 5
pnpm-lock.yaml

@@ -7,13 +7,20 @@ settings:
 importers:
 
   .:
+    dependencies:
+      qrcode:
+        specifier: ^1.5.4
+        version: 1.5.4
     devDependencies:
       '@eslint/js':
         specifier: ^9.39.4
         version: 9.39.4
+      '@types/qrcode':
+        specifier: ^1.5.6
+        version: 1.5.6
       '@vitejs/plugin-vue':
         specifier: ^6.0.10
-        version: 6.0.10(vite@6.4.5(sass@1.93.2))(vue@3.5.45(typescript@5.8.3))
+        version: 6.0.10(vite@6.4.5(@types/node@26.6.6)(sass@1.93.2))(vue@3.5.45(typescript@5.8.3))
       axios:
         specifier: 1.21.0
         version: 1.21.0
@@ -49,7 +56,7 @@ importers:
         version: 8.72.1(eslint@9.39.4)(typescript@5.8.3)
       vite:
         specifier: ^6.4.5
-        version: 6.4.5(sass@1.93.2)
+        version: 6.4.5(@types/node@26.6.6)(sass@1.93.2)
       vue:
         specifier: 3.5.45
         version: 3.5.45(typescript@5.8.3)
@@ -560,6 +567,12 @@ packages:
   '@types/lodash@4.17.27':
     resolution: {integrity: sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew==}
 
+  '@types/node@26.6.6':
+    resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==}
+
+  '@types/qrcode@1.5.6':
+    resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
   '@types/web-bluetooth@0.0.21':
     resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
 
@@ -720,6 +733,10 @@ packages:
   alien-signals@3.2.1:
     resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
 
+  ansi-regex@5.0.1:
+    resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+    engines: {node: '>=8'}
+
   ansi-styles@4.3.0:
     resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
     engines: {node: '>=8'}
@@ -761,6 +778,10 @@ packages:
     resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
     engines: {node: '>=6'}
 
+  camelcase@5.3.1:
+    resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+    engines: {node: '>=6'}
+
   chalk@4.1.2:
     resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
     engines: {node: '>=10'}
@@ -769,6 +790,9 @@ packages:
     resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
     engines: {node: '>= 14.16.0'}
 
+  cliui@6.0.0:
+    resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -807,6 +831,10 @@ packages:
       supports-color:
         optional: true
 
+  decamelize@1.2.0:
+    resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+    engines: {node: '>=0.10.0'}
+
   deep-is@0.1.4:
     resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
 
@@ -818,6 +846,9 @@ packages:
     resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
     engines: {node: '>=8'}
 
+  dijkstrajs@1.0.3:
+    resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
   dunder-proto@1.0.1:
     resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
     engines: {node: '>= 0.4'}
@@ -827,6 +858,9 @@ packages:
     peerDependencies:
       vue: ^3.3.7
 
+  emoji-regex@8.0.0:
+    resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
   entities@7.0.1:
     resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
     engines: {node: '>=0.12'}
@@ -955,6 +989,10 @@ packages:
     resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
     engines: {node: '>=16.0.0'}
 
+  find-up@4.1.0:
+    resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+    engines: {node: '>=8'}
+
   find-up@5.0.0:
     resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
     engines: {node: '>=10'}
@@ -987,6 +1025,10 @@ packages:
   function-bind@1.1.2:
     resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
 
+  get-caller-file@2.0.5:
+    resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+    engines: {node: 6.* || 8.* || >= 10.*}
+
   get-intrinsic@1.3.0:
     resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
     engines: {node: '>= 0.4'}
@@ -1050,6 +1092,10 @@ packages:
     resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
     engines: {node: '>=0.10.0'}
 
+  is-fullwidth-code-point@3.0.0:
+    resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+    engines: {node: '>=8'}
+
   is-glob@4.0.3:
     resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
     engines: {node: '>=0.10.0'}
@@ -1077,6 +1123,10 @@ packages:
     resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
     engines: {node: '>= 0.8.0'}
 
+  locate-path@5.0.0:
+    resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+    engines: {node: '>=8'}
+
   locate-path@6.0.0:
     resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
     engines: {node: '>=10'}
@@ -1149,14 +1199,26 @@ packages:
     resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
     engines: {node: '>= 0.8.0'}
 
+  p-limit@2.3.0:
+    resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+    engines: {node: '>=6'}
+
   p-limit@3.1.0:
     resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
     engines: {node: '>=10'}
 
+  p-locate@4.1.0:
+    resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+    engines: {node: '>=8'}
+
   p-locate@5.0.0:
     resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
     engines: {node: '>=10'}
 
+  p-try@2.2.0:
+    resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+    engines: {node: '>=6'}
+
   parent-module@1.0.1:
     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
     engines: {node: '>=6'}
@@ -1188,6 +1250,10 @@ packages:
       typescript:
         optional: true
 
+  pngjs@5.0.0:
+    resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+    engines: {node: '>=10.13.0'}
+
   postcss-selector-parser@7.1.7:
     resolution: {integrity: sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==}
     engines: {node: '>=4'}
@@ -1213,10 +1279,22 @@ packages:
     resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
     engines: {node: '>=6'}
 
+  qrcode@1.5.4:
+    resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+    engines: {node: '>=10.13.0'}
+    hasBin: true
+
   readdirp@4.1.2:
     resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
     engines: {node: '>= 14.18.0'}
 
+  require-directory@2.1.1:
+    resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+    engines: {node: '>=0.10.0'}
+
+  require-main-filename@2.0.0:
+    resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
   resolve-from@4.0.0:
     resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
     engines: {node: '>=4'}
@@ -1236,6 +1314,9 @@ packages:
     engines: {node: '>=10'}
     hasBin: true
 
+  set-blocking@2.0.0:
+    resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
   shebang-command@2.0.0:
     resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
     engines: {node: '>=8'}
@@ -1248,6 +1329,14 @@ packages:
     resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
     engines: {node: '>=0.10.0'}
 
+  string-width@4.2.3:
+    resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+    engines: {node: '>=8'}
+
+  strip-ansi@6.0.1:
+    resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+    engines: {node: '>=8'}
+
   strip-json-comments@3.1.1:
     resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
     engines: {node: '>=8'}
@@ -1282,6 +1371,9 @@ packages:
     engines: {node: '>=14.17'}
     hasBin: true
 
+  undici-types@8.9.0:
+    resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
+
   uri-js@4.4.1:
     resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
 
@@ -1383,6 +1475,9 @@ packages:
       typescript:
         optional: true
 
+  which-module@2.0.1:
+    resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
   which@2.0.2:
     resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
     engines: {node: '>= 8'}
@@ -1392,10 +1487,25 @@ packages:
     resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
     engines: {node: '>=0.10.0'}
 
+  wrap-ansi@6.2.0:
+    resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+    engines: {node: '>=8'}
+
   xml-name-validator@5.0.0:
     resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
     engines: {node: '>=18'}
 
+  y18n@4.0.3:
+    resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
+  yargs-parser@18.1.3:
+    resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+    engines: {node: '>=6'}
+
+  yargs@15.4.1:
+    resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+    engines: {node: '>=8'}
+
   yocto-queue@0.1.0:
     resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
     engines: {node: '>=10'}
@@ -1725,6 +1835,14 @@ snapshots:
 
   '@types/lodash@4.17.27': {}
 
+  '@types/node@26.6.6':
+    dependencies:
+      undici-types: 8.9.0
+
+  '@types/qrcode@1.5.6':
+    dependencies:
+      '@types/node': 26.6.6
+
   '@types/web-bluetooth@0.0.21': {}
 
   '@typescript-eslint/eslint-plugin@8.72.1(@typescript-eslint/parser@8.72.1(eslint@9.39.4)(typescript@5.8.3))(eslint@9.39.4)(typescript@5.8.3)':
@@ -1818,10 +1936,10 @@ snapshots:
       '@typescript-eslint/types': 8.72.1
       eslint-visitor-keys: 5.0.1
 
-  '@vitejs/plugin-vue@6.0.10(vite@6.4.5(sass@1.93.2))(vue@3.5.45(typescript@5.8.3))':
+  '@vitejs/plugin-vue@6.0.10(vite@6.4.5(@types/node@26.6.6)(sass@1.93.2))(vue@3.5.45(typescript@5.8.3))':
     dependencies:
       '@rolldown/pluginutils': 1.0.1
-      vite: 6.4.5(sass@1.93.2)
+      vite: 6.4.5(@types/node@26.6.6)(sass@1.93.2)
       vue: 3.5.45(typescript@5.8.3)
 
   '@volar/language-core@2.4.30':
@@ -1948,6 +2066,8 @@ snapshots:
 
   alien-signals@3.2.1: {}
 
+  ansi-regex@5.0.1: {}
+
   ansi-styles@4.3.0:
     dependencies:
       color-convert: 2.0.1
@@ -1990,6 +2110,8 @@ snapshots:
 
   callsites@3.1.0: {}
 
+  camelcase@5.3.1: {}
+
   chalk@4.1.2:
     dependencies:
       ansi-styles: 4.3.0
@@ -1999,6 +2121,12 @@ snapshots:
     dependencies:
       readdirp: 4.1.2
 
+  cliui@6.0.0:
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 6.2.0
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
@@ -2027,6 +2155,8 @@ snapshots:
     dependencies:
       ms: 2.1.3
 
+  decamelize@1.2.0: {}
+
   deep-is@0.1.4: {}
 
   delayed-stream@1.0.0: {}
@@ -2034,6 +2164,8 @@ snapshots:
   detect-libc@2.1.2:
     optional: true
 
+  dijkstrajs@1.0.3: {}
+
   dunder-proto@1.0.1:
     dependencies:
       call-bind-apply-helpers: 1.0.2
@@ -2059,6 +2191,8 @@ snapshots:
       vue: 3.5.45(typescript@5.8.3)
       vue-component-type-helpers: 3.3.14
 
+  emoji-regex@8.0.0: {}
+
   entities@7.0.1: {}
 
   es-define-property@1.0.1: {}
@@ -2221,6 +2355,11 @@ snapshots:
     dependencies:
       flat-cache: 4.0.1
 
+  find-up@4.1.0:
+    dependencies:
+      locate-path: 5.0.0
+      path-exists: 4.0.0
+
   find-up@5.0.0:
     dependencies:
       locate-path: 6.0.0
@@ -2248,6 +2387,8 @@ snapshots:
 
   function-bind@1.1.2: {}
 
+  get-caller-file@2.0.5: {}
+
   get-intrinsic@1.3.0:
     dependencies:
       call-bind-apply-helpers: 1.0.2
@@ -2308,6 +2449,8 @@ snapshots:
 
   is-extglob@2.1.1: {}
 
+  is-fullwidth-code-point@3.0.0: {}
+
   is-glob@4.0.3:
     dependencies:
       is-extglob: 2.1.1
@@ -2333,6 +2476,10 @@ snapshots:
       prelude-ls: 1.2.1
       type-check: 0.4.0
 
+  locate-path@5.0.0:
+    dependencies:
+      p-locate: 4.1.0
+
   locate-path@6.0.0:
     dependencies:
       p-locate: 5.0.0
@@ -2397,14 +2544,24 @@ snapshots:
       type-check: 0.4.0
       word-wrap: 1.2.5
 
+  p-limit@2.3.0:
+    dependencies:
+      p-try: 2.2.0
+
   p-limit@3.1.0:
     dependencies:
       yocto-queue: 0.1.0
 
+  p-locate@4.1.0:
+    dependencies:
+      p-limit: 2.3.0
+
   p-locate@5.0.0:
     dependencies:
       p-limit: 3.1.0
 
+  p-try@2.2.0: {}
+
   parent-module@1.0.1:
     dependencies:
       callsites: 3.1.0
@@ -2429,6 +2586,8 @@ snapshots:
     transitivePeerDependencies:
       - '@vue/composition-api'
 
+  pngjs@5.0.0: {}
+
   postcss-selector-parser@7.1.7:
     dependencies:
       cssesc: 3.0.0
@@ -2448,8 +2607,18 @@ snapshots:
 
   punycode@2.3.1: {}
 
+  qrcode@1.5.4:
+    dependencies:
+      dijkstrajs: 1.0.3
+      pngjs: 5.0.0
+      yargs: 15.4.1
+
   readdirp@4.1.2: {}
 
+  require-directory@2.1.1: {}
+
+  require-main-filename@2.0.0: {}
+
   resolve-from@4.0.0: {}
 
   rollup@4.67.5:
@@ -2494,6 +2663,8 @@ snapshots:
 
   semver@7.8.6: {}
 
+  set-blocking@2.0.0: {}
+
   shebang-command@2.0.0:
     dependencies:
       shebang-regex: 3.0.0
@@ -2502,6 +2673,16 @@ snapshots:
 
   source-map-js@1.2.2: {}
 
+  string-width@4.2.3:
+    dependencies:
+      emoji-regex: 8.0.0
+      is-fullwidth-code-point: 3.0.0
+      strip-ansi: 6.0.1
+
+  strip-ansi@6.0.1:
+    dependencies:
+      ansi-regex: 5.0.1
+
   strip-json-comments@3.1.1: {}
 
   supports-color@7.2.0:
@@ -2534,13 +2715,15 @@ snapshots:
 
   typescript@5.8.3: {}
 
+  undici-types@8.9.0: {}
+
   uri-js@4.4.1:
     dependencies:
       punycode: 2.3.1
 
   util-deprecate@1.0.2: {}
 
-  vite@6.4.5(sass@1.93.2):
+  vite@6.4.5(@types/node@26.6.6)(sass@1.93.2):
     dependencies:
       esbuild: 0.25.12
       fdir: 6.5.0(picomatch@4.0.7)
@@ -2549,6 +2732,7 @@ snapshots:
       rollup: 4.67.5
       tinyglobby: 0.2.17
     optionalDependencies:
+      '@types/node': 26.6.6
       fsevents: 2.3.3
       sass: 1.93.2
 
@@ -2604,12 +2788,41 @@ snapshots:
     optionalDependencies:
       typescript: 5.8.3
 
+  which-module@2.0.1: {}
+
   which@2.0.2:
     dependencies:
       isexe: 2.0.0
 
   word-wrap@1.2.5: {}
 
+  wrap-ansi@6.2.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+
   xml-name-validator@5.0.0: {}
 
+  y18n@4.0.3: {}
+
+  yargs-parser@18.1.3:
+    dependencies:
+      camelcase: 5.3.1
+      decamelize: 1.2.0
+
+  yargs@15.4.1:
+    dependencies:
+      cliui: 6.0.0
+      decamelize: 1.2.0
+      find-up: 4.1.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      require-main-filename: 2.0.0
+      set-blocking: 2.0.0
+      string-width: 4.2.3
+      which-module: 2.0.1
+      y18n: 4.0.3
+      yargs-parser: 18.1.3
+
   yocto-queue@0.1.0: {}

+ 54 - 35
src/api/auth/wechat.ts

@@ -1,74 +1,93 @@
 /**
  * wechat.ts —— 微信扫码登录接口封装(auth 域;一域一目录、一资源一文件纪律,README §3.2)。
  *
- * ⚠️ 占位说明(2026-11-06):后端微信登录端点**尚未就绪**。
- *   本文件的接口路径 / 字段为**占位**(按通用微信扫码流程假设),联调前必须与后端核对:
- *     - POST /api/auth/wechat/qrcode  获取扫码二维码(返回 wx 二维码 URL + 本地 state 标识)
- *     - GET  /api/auth/wechat/status   轮询扫码状态(pending/scanning/success/cancel/expire)
- *   后端就绪后按真实契约修订本文件字段名与路径(保持 camelCase,与后端 DTO 逐字段对齐)。
+ * 契约(对齐后端 identity 微信扫码端点,2026-11-06;HTTP 恒 200、真实状态在 body code):
+ *   - GET /api/auth/wechat/qrcode          生成扫码二维码:{state, qrUrl, campusId, expiresIn};
+ *     qrUrl 是**微信网页授权 URL(open.weixin.qq.com/connect/qrconnect)**——是 URL 字符串**非图片**,
+ *     前端用 qrcode 库把该 URL 编码成二维码图(见 views/login/index.vue),用户扫的是这个二维码。
+ *   - GET /api/auth/wechat/status?state=   轮询扫码状态:{status, campusId, loginTicket?, userId?,
+ *     memberships?, expired?}。status 取值 pending/success/unbound/fail;expired 为布尔(与 status 互斥,
+ *     expired=true 时 status 为空)。
+ *   - 微信扫码成功后**不直接发 token**:success 返回 loginTicket + memberships,前端拿这两样走
+ *     与账密登录**完全同一套** ② /api/auth/issue 签 token(单条自动②、多条进作用域选择页)——
+ *     复用 identity/service/wechat_service.go 的票据复用机制,不重造签发/作用域校验逻辑。
+ *
+ * 错误码(与后端 errors 包对齐):
+ *   - 4004 微信扫码未配置(后端 wechat 服务未注入 / appid·回调地址缺失);
+ *   - 2001 缺 state 参数;5002 Redis/DB 故障(fail-closed)。
  *
  * 典型使用(views/login/index.vue 扫码 tab):
  *
- *   const qr = ref<Awaited<ReturnType<typeof fetchWechatQrcode>>>;
- *   // 进入扫码 tab:
- *   const q = await fetchWechatQrcode();
- *   qr.url = q.url; qr.state = q.state;
- *   // 轮询:
+ *   const q = await fetchWechatQrcode();            // {state, qrUrl, campusId, expiresIn}
+ *   const img = await toDataURL(q.data.qrUrl);      // qrcode 库把授权 URL 编码成二维码图
  *   const timer = setInterval(async () => {
- *     const s = await pollWechatQrcodeStatus(q.state);
- *     if (s.status === 'success' && s.token) { loginStore.applyLogin(s.token); clearInterval(timer); }
- *     if (s.status === 'expire' || s.status === 'cancel') { clearInterval(timer); }
+ *     const s = await pollWechatQrcodeStatus(q.data.state);
+ *     if (s.data.expired) { clearInterval(timer); }                 // 二维码失效
+ *     else if (s.data.status === 'success') {                       // 携 ticket 走 ② issue
+ *       clearInterval(timer); completeStep2(s.data.loginTicket, s.data.memberships ?? []);
+ *     }
  *   }, 1500);
  */
 import { http } from '@/api/http';
+import type { MembershipView } from './login';
 import type { R } from '@/types/api';
 
-/** 扫码状态机(前端轮询得到的状态;与后端枚举对齐,后端未定前为占位) */
-export type WechatQrcodeStatus = 'pending' | 'scanning' | 'success' | 'cancel' | 'expire';
+/** 扫码状态机取值(与后端 WechatQrcode* 常量逐字对齐) */
+export type WechatQrcodeStatus = 'pending' | 'success' | 'unbound' | 'fail';
 
-/** 获取二维码返回体(占位 DTO,后端就绪后对齐) */
+/** 获取二维码返回体(后端 QRCodeResult;qrUrl 为微信网页授权 URL,需前端编码成二维码图) */
 export interface WechatQrcodeResult {
-  /** 二维码图片 URL(微信 wx 生成,前端 <img> 直显;过期后需重新取) */
-  url: string;
-  /** 本地 state 标识(轮询时回传,后端据此关联扫码会话) */
+  /** 扫码会话标识(32 hex;轮询 status 时回传,后端据此关联会话) */
   state: string;
-  /** 二维码有效期(秒;前端据此展示"已过期请刷新") */
-  expireSeconds: number;
+  /** 微信网页授权 URL(open.weixin.qq.com/connect/qrconnect…&state=…);前端编码为二维码图后供用户扫 */
+  qrUrl: string;
+  /** 本次会话选定的校区(提示"正在登录 X 校区") */
+  campusId: number;
+  /** 二维码有效期(秒;前端据此展示倒计时/到期停轮询) */
+  expiresIn: number;
 }
 
-/** 轮询扫码状态返回体(占位 DTO;success 时携带登录凭证,结构同账号密码两步②的 TokenPair) */
+/** 轮询扫码状态返回体(后端 StatusResult;success 携 loginTicket+memberships 走 ② issue,非直发 token) */
 export interface WechatStatusResult {
-  /** 当前扫码状态 */
+  /** 当前扫码状态(pending 继续轮 / success 走② / unbound·fail 终态停轮) */
   status: WechatQrcodeStatus;
-  /** 登录凭证(仅 status === 'success' 时非空;其余状态省略;后端就绪后核对是否两步或直发) */
-  token?: import('./login').TokenPair;
+  /** 本次会话的校区(success/unbound 时回显,供前端提示) */
+  campusId: number;
+  /** 登录票据(仅 status=success 时非空;前端携它走 ② /auth/issue 签 token) */
+  loginTicket?: string;
+  /** 绑定的用户 ULID(仅 success;前端可据此展示"已登录",可选用) */
+  userId?: string;
+  /** 绑定用户的可选作用域列表(仅 success;与账密登录同源,据此走 ② 分流) */
+  memberships?: MembershipView[];
+  /** true = state 不存在/已过期(与 status 互斥;前端停轮询提示"二维码已失效,请重新扫码") */
+  expired?: boolean;
 }
 
 /**
- * 获取微信扫码二维码(POST /api/auth/wechat/qrcode)。
+ * 获取微信扫码二维码(GET /api/auth/wechat/qrcode,公开)。
  *
- * 入参:无(后端自行生成 wx 二维码 + state)。
+ * 入参:无(后端自行选第一个配置的校区 + 生成 state 落 Redis(pending))。
  * 出参:
- *   - Promise<R<WechatQrcodeResult>>:code=200 时 data 为二维码 URL + state;失败展示 message。
+ *   - Promise<R<WechatQrcodeResult>>:code=200 时 data 为 {state, qrUrl, campusId, expiresIn};
+ *     失败(4004 未配置 / 5002 Redis 写失败)由调用方 catch 或读 code 展示 message。
  * 异常场景:
- *   - 后端未就绪(404/501):按占位语义,调用方 catch 提示"微信扫码暂未开放"。
+ *   - 网络层故障:Promise reject,调用方 catch 提示"网络连接异常"。
  */
 export function fetchWechatQrcode(): Promise<R<WechatQrcodeResult>> {
-  // TODO(后端就绪后):核对路径与返回结构
-  return http.post<R<WechatQrcodeResult>>('/api/auth/wechat/qrcode', {}).then((r) => r.data);
+  return http.get<R<WechatQrcodeResult>>('/api/auth/wechat/qrcode').then((r) => r.data);
 }
 
 /**
- * 轮询微信扫码状态(GET /api/auth/wechat/status?state=xxx)。
+ * 轮询微信扫码状态(GET /api/auth/wechat/status?state=xxx,公开;只读快照,不消费会话)。
  *
  * 入参:
- *   - state string:fetchWechatQrcode 返回的本地 state 标识。
+ *   - state string:fetchWechatQrcode 返回的会话标识。
  * 出参:
- *   - Promise<R<WechatStatusResult>>:code=200 时 data.status 为状态机值;success 时 data.token 非空。
+ *   - Promise<R<WechatStatusResult>>:code=200 时 data.expired=true → 二维码失效;
+ *     否则 data.status 为状态机值,success 时 data.loginTicket + data.memberships 非空。
  * 异常场景:
  *   - 网络故障:Promise reject,调用方 catch(轮询里静默跳过本次,下轮重试)。
  */
 export function pollWechatQrcodeStatus(state: string): Promise<R<WechatStatusResult>> {
-  // TODO(后端就绪后):核对路径、query 参数名与状态枚举
   return http.get<R<WechatStatusResult>>('/api/auth/wechat/status', { params: { state } }).then((r) => r.data);
 }

+ 180 - 81
src/views/login/index.vue

@@ -3,18 +3,25 @@
  * 登录页(views/login/index.vue;公开路由,守卫 meta.public)。
  *
  * 职责:两种登录方式 tab 切换——① 账号密码(两步:/api/auth/login → /api/auth/issue)② 微信扫码
- *(占位接口,后端端点未就绪,UI 骨架完整可联调);成功则写 token-store + router.push 跳机构;
- * 失败展示后端 message(直显,不包装)。
+ *(GET /api/auth/wechat/qrcode 取码 + GET /api/auth/wechat/status 轮询;扫码成功回 loginTicket,
+ *  与账密登录**同一套** ② /api/auth/issue 签 token);成功则写 token-store + router.push 跳机构;
+ *  失败展示后端 message(直显,不包装)。
  *
- * 两步登录流程(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
- *   账号密码提交 → ① /login(验账密):
- *     - code=200 且 memberships 恰 1 条:自动 ② /issue(取该条作用域)→ applyLogin → 跳机构列表;
- *     - code=200 且 memberships 多条:applyStep1 暂存票据/作用域列表 → 跳作用域选择页由用户挑选后 ②;
- *     - code=200 且 memberships 空:提示"该账号暂无机构归属,请联系管理员";
- *     - code≠200(1001 账号密码错 / 2001 参数 / 5002 系统):errorMsg 直显后端 message。
+ * 两步登录流程(对齐后端 identity ADR-014 / T4 契约 + 微信扫码复用②机制,2026-11-06):
+ *   ① 拿一次性票据 + 作用域列表:
+ *     - 账密:POST /login(验账密);
+ *     - 微信:扫码成功(status=success)后端回 loginTicket + memberships(**不直接发 token**)。
+ *   ② 凭票据 + 选定作用域签 token(POST /issue,completeStep2 统一分流):
+ *     - memberships 恰 1 条:自动 ②(取该条作用域)→ applyLogin → 跳机构列表;
+ *     - memberships 多条:applyStep1 暂存票据/作用域列表 → 跳作用域选择页由用户挑选后 ②;
+ *     - memberships 空:提示"该账号暂无机构归属,请联系管理员"。
+ *   微信状态机:pending 继续轮 / success 走② / unbound(未绑账号)· fail(第三方失败)· expired(失效)
+ *   停轮询并提示。
  * SFC 骨架顺序(README §3.2):imports → props/emits(无)→ store 注入 → 状态 → 生命周期 → 方法。
  */
+import { toDataURL } from 'qrcode';
 import { issue, login } from '@/api/auth/login';
+import type { MembershipView } from '@/api/auth/login';
 import { fetchWechatQrcode, pollWechatQrcodeStatus } from '@/api/auth/wechat';
 import { useLogin } from '@/store/login';
 
@@ -37,17 +44,18 @@ const submitting = ref(false);
 const errorMsg = ref('');
 
 /**
- * 微信扫码态(占位,后端端点未就绪——接口路径/字段见 api/auth/wechat.ts 占位注释)。
- * 入参:无。
- * 出参:
- *   - qrcodeUrl string:二维码图片 URL('' = 未取到);
- *   - polling boolean:是否正在轮询(驱动"扫码中"动效/文案)。
- * 异常场景:
- *   - 后端未就绪(fetchWechatQrcode reject):qrcodeUrl 保持 '',扫码区展示"暂未开放"占位。
+ * 微信扫码态(对齐后端 GET /auth/wechat/qrcode + /status 契约,2026-11-06)。
+ *   - qrDataUrl string:二维码图 data URL(由 qrUrl 微信授权 URL 经 qrcode 库编码;'' = 未取到)。
+ *     后端返回的 qrUrl 是**微信网页授权 URL(字符串)非图片**,故前端编码成可扫二维码。
+ *   - wechatState string:扫码会话标识(轮询 status 回传;'' = 无会话)。
+ *   - qrcodeLoading boolean:取码/编码进行中(防重复点击 + 占位提示)。
+ *   - polling boolean:是否正在轮询(驱动"扫码中"文案)。
  */
-const qrcodeUrl = ref('');
+const qrDataUrl = ref('');
+const wechatState = ref('');
+const qrcodeLoading = ref(false);
 const polling = ref(false);
-/** 轮询定时器句柄(组件卸载时必须清,防泄漏;占位接口下逻辑完整仍保留) */
+/** 轮询定时器句柄(组件卸载/切 tab/终态时必须清,防泄漏) */
 let pollTimer: ReturnType<typeof setInterval> | undefined;
 
 /** 表单校验规则(显式 rules 对象,README §3.3-5) */
@@ -56,18 +64,61 @@ const rules = {
   password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
 };
 
+/**
+ * 完成两步登录② 的统一分流(账密 / 微信**同一套**;凭一次性票据 + 作用域列表签 token)。
+ *
+ * 入参:
+ *   - loginTicket string:① 返回的一次性票据(账密 ① 或 微信 success 回显;32 hex)。
+ *   - memberships MembershipView[]:可选作用域列表。
+ * 出参:
+ *   - Promise<void>:恒 resolve;单条作用域自动② → applyLogin → 跳机构;多条进作用域选择页;空提示。
+ * 异常场景:
+ *   - 票据缺失:提示"登录票据缺失,请重新操作";
+ *   - 单条② code≠200(1001 票据失效 / 1003 越权):errorMsg = 后端 message;
+ *   - 网络层 reject:抛给调用方 catch(账密/微信各自提示"网络连接异常")。
+ */
+async function completeStep2(loginTicket: string, memberships: MembershipView[]): Promise<void> {
+  if (!loginTicket) {
+    errorMsg.value = '登录票据缺失,请重新操作';
+    return;
+  }
+  // 无机构归属:提示联系管理员(该账号无法进入业务域)
+  if (memberships.length === 0) {
+    errorMsg.value = '该账号暂无机构归属,请联系管理员分配机构';
+    return;
+  }
+  // 单条作用域:自动完成 ②(取该条 institution/campus),免进选择页
+  if (memberships.length === 1) {
+    const m = memberships[0];
+    const pair = await issue({ loginTicket, institutionId: m.institutionId, campusId: m.campusId });
+    if (pair.code === 200 && pair.data) {
+      loginStore.applyLogin(pair.data);
+      await router.push('/org/institutions');
+      return;
+    }
+    errorMsg.value = pair.message || '登录失败';
+    return;
+  }
+  // 多条作用域:暂存票据 + 作用域列表,进作用域选择页由用户挑选后 ②。
+  // 微信 ① 不回显 user(后端仅回 loginTicket + memberships):构造最小 LoginStep1 形状喂给 applyStep1,
+  // user 占位空视图;realName 由 ② issue 的 TokenPair.user 在 applyLogin 回填。
+  loginStore.applyStep1({
+    loginTicket,
+    memberships,
+    user: { id: '', username: '', realName: '', personType: '', status: 'active' },
+  });
+  await router.push('/org/scope');
+}
+
 /**
  * 提交登录(handle 前缀:用户触发的提交类方法,README §3.1 命名规则)。
  *
  * 入参:无(读 form 当前值)。
- *
  * 出参:
- *   - Promise<void>:恒 resolve;成功跳机构列表,失败写 errorMsg。
+ *   - Promise<void>:恒 resolve;成功跳机构列表/作用域选择页,失败写 errorMsg。
  * 异常场景:
  *   - 表单校验失败:不发请求(ElForm 标红);
  *   - ① code≠200(如 1001 账号密码错):errorMsg = 后端 message;
- *   - ① 成功但 memberships 空:errorMsg 提示无机构归属;
- *   - ② code≠200(1001 票据失效 / 1003 越权):errorMsg = 后端 message;
  *   - 网络层 reject:errorMsg = "网络连接异常"。
  */
 async function handleSubmit(): Promise<void> {
@@ -81,30 +132,8 @@ async function handleSubmit(): Promise<void> {
       errorMsg.value = step1.message || '登录失败';
       return;
     }
-    const memberships = step1.data.memberships ?? [];
-
-    // 无机构归属:提示联系管理员(该账号无法进入业务域)
-    if (memberships.length === 0) {
-      errorMsg.value = '该账号暂无机构归属,请联系管理员分配机构';
-      return;
-    }
-
-    // 单条作用域:自动完成 ②(取该条 institution/campus),免进选择页
-    if (memberships.length === 1) {
-      const m = memberships[0];
-      const pair = await issue({ loginTicket: step1.data.loginTicket, institutionId: m.institutionId, campusId: m.campusId });
-      if (pair.code === 200 && pair.data) {
-        loginStore.applyLogin(pair.data);
-        await router.push('/org/institutions');
-        return;
-      }
-      errorMsg.value = pair.message || '登录失败';
-      return;
-    }
-
-    // 多条作用域:暂存票据 + 作用域列表,进作用域选择页由用户挑选后 ②
-    loginStore.applyStep1(step1.data);
-    await router.push('/org/scope');
+    // ② 统一分流(completeStep2:单条自动② / 多条进选择页 / 空提示)
+    await completeStep2(step1.data.loginTicket, step1.data.memberships ?? []);
   } catch {
     errorMsg.value = '网络连接异常';
   } finally {
@@ -122,48 +151,92 @@ function stopPolling(): void {
 }
 
 /**
- * 切换登录方式 tab(切到微信时取二维码 + 启动轮询;切走时停轮询)。
+ * 启动微信扫码状态轮询(1.5s 间隔;只读快照,不消费会话)。
+ *
+ * 入参:state string:扫码会话标识(fetchWechatQrcode 返回)。
+ * 出参:无(异步轮询,命中终态时停轮询并写 errorMsg / 走 ②)。
+ * 异常场景:
+ *   - 单次轮询网络故障 / 业务错误:静默跳过本次,下轮重试(不中断轮询)。
+ */
+function startPolling(state: string): void {
+  polling.value = true;
+  pollTimer = setInterval(async () => {
+    let s: Awaited<ReturnType<typeof pollWechatQrcodeStatus>>;
+    try {
+      s = await pollWechatQrcodeStatus(state);
+    } catch {
+      return; // 单次网络故障:静默(下轮重试)
+    }
+    if (s.code !== 200 || !s.data) return; // 单次业务错误:静默(下轮重试)
+    const d = s.data;
+    // expired(state 不存在/过期):终态,停轮询,提示刷新
+    if (d.expired) {
+      stopPolling();
+      qrDataUrl.value = '';
+      errorMsg.value = '二维码已失效,请重新扫码';
+      return;
+    }
+    if (d.status === 'pending') return; // 继续轮询
+    if (d.status === 'success') {
+      // 扫码成功:后端回 loginTicket + memberships → 复用同一套 ② issue 分流
+      stopPolling();
+      void completeStep2(d.loginTicket ?? '', d.memberships ?? []);
+      return;
+    }
+    // unbound(未绑账号)/ fail(第三方失败):终态停轮询
+    stopPolling();
+    qrDataUrl.value = '';
+    errorMsg.value =
+      d.status === 'unbound' ? '该微信号未绑定账号,请先用账号密码登录后绑定' : '微信扫码失败,请重新扫码';
+  }, 1500);
+}
+
+/**
+ * 获取微信扫码二维码(进入扫码 tab / 点"重新获取"时调用)。
+ *
+ * 入参:无。出参:Promise<void>(取码 + 编码 + 启动轮询全程)。
+ * 异常场景:
+ *   - 取码 code≠200(4004 未配置 / 5002 Redis 故障):errorMsg 直显,不出二维码;
+ *   - 网络层 reject:errorMsg = "网络连接异常"。
+ */
+async function getQrcode(): Promise<void> {
+  stopPolling();
+  qrDataUrl.value = '';
+  wechatState.value = '';
+  errorMsg.value = '';
+  qrcodeLoading.value = true;
+  try {
+    const q = await fetchWechatQrcode();
+    if (q.code !== 200 || !q.data) {
+      errorMsg.value = q.message || '获取二维码失败';
+      return;
+    }
+    // 后端 qrUrl 是微信网页授权 URL(字符串):编码成二维码图供用户扫
+    const dataUrl = await toDataURL(q.data.qrUrl, { width: 440, margin: 2, errorCorrectionLevel: 'M' });
+    qrDataUrl.value = dataUrl;
+    wechatState.value = q.data.state;
+    startPolling(q.data.state);
+  } catch {
+    errorMsg.value = '网络连接异常';
+  } finally {
+    qrcodeLoading.value = false;
+  }
+}
+
+/**
+ * 切换登录方式 tab(切到微信时取码 + 启动轮询;切走时停轮询)。
  *
  * 入参:tab 'password' | 'wechat'(el-tabs @tab-change 回调值)。
  * 出参:无(异步启动扫码流程)。
  * 异常场景:
- *   - 后端未就绪:fetchWechatQrcode reject → 停轮询,扫码区展示占位文案。
+ *   - 取码/编码失败:保持二维码为空,扫码区显占位文案 + 后端 message。
  */
 async function handleTabChange(tab: 'password' | 'wechat'): Promise<void> {
   activeTab.value = tab;
   stopPolling();
   errorMsg.value = '';
   if (tab === 'wechat') {
-    qrcodeUrl.value = '';
-    try {
-      const q = await fetchWechatQrcode();
-      if (q.code === 200 && q.data) {
-        qrcodeUrl.value = q.data.url;
-        // 轮询扫码状态(1.5s 间隔;占位节奏,后端就绪后可调)
-        const state = q.data.state;
-        polling.value = true;
-        pollTimer = setInterval(async () => {
-          try {
-            const s = await pollWechatQrcodeStatus(state);
-            if (s.code !== 200 || !s.data) return; // 单次轮询业务错误:静默,下轮重试
-            const { status, token } = s.data;
-            if (status === 'success' && token) {
-              stopPolling();
-              loginStore.applyLogin(token);
-              await router.push('/org/institutions');
-            } else if (status === 'cancel' || status === 'expire') {
-              stopPolling();
-              qrcodeUrl.value = ''; // 终态:清空二维码,提示重新扫码
-            }
-          } catch {
-            // 单次轮询网络故障:静默(不中断轮询,下轮重试)
-          }
-        }, 1500);
-      }
-    } catch {
-      // 后端未就绪 / 取码失败:占位(保持 qrcodeUrl 空,扫码区显"暂未开放")
-      stopPolling();
-    }
+    void getQrcode();
   }
 }
 
@@ -207,12 +280,32 @@ onUnmounted(() => {
           </el-form>
         </el-tab-pane>
 
-        <!-- 微信扫码(占位:后端端点未就绪,UI 骨架完整;二维码取到前显占位框) -->
+        <!-- 微信扫码(qrUrl 经 qrcode 编码成图;扫码成功回 loginTicket 走同一套 ② issue) -->
         <el-tab-pane label="微信扫码" name="wechat">
           <div class="login-wechat">
-            <img v-if="qrcodeUrl" :src="qrcodeUrl" alt="微信扫码二维码" class="login-wechat__qrcode" />
-            <div v-else class="login-wechat__placeholder">微信扫码暂未开放</div>
-            <p class="login-wechat__tip">{{ polling ? '请使用微信扫描二维码…' : '切换至此自动刷新二维码' }}</p>
+            <img v-if="qrDataUrl" :src="qrDataUrl" alt="微信扫码二维码" class="login-wechat__qrcode" />
+            <div v-else class="login-wechat__placeholder">
+              <el-button
+                :loading="qrcodeLoading"
+                class="login-wechat__btn"
+                @click="getQrcode"
+              >
+                {{ qrDataUrl ? '重新获取二维码' : '获取二维码' }}
+              </el-button>
+            </div>
+            <el-button
+              v-if="qrDataUrl"
+              link
+              type="primary"
+              :loading="qrcodeLoading"
+              class="login-wechat__refresh"
+              @click="getQrcode"
+            >
+              重新获取二维码
+            </el-button>
+            <p class="login-wechat__tip">
+              {{ polling ? '请使用微信扫描二维码…' : qrDataUrl ? '请用微信扫描上方二维码' : '点击下方按钮获取二维码' }}
+            </p>
           </div>
         </el-tab-pane>
       </el-tabs>
@@ -268,6 +361,12 @@ onUnmounted(() => {
   font-size: var(--app-font-md);
   border-radius: var(--app-radius-md);
 }
+.login-wechat__btn {
+  background: transparent;
+}
+.login-wechat__refresh {
+  margin: var(--app-spacing-xs, 8px) 0 0;
+}
 .login-wechat__tip {
   margin: var(--app-spacing-sm) 0 0;
   font-size: var(--app-font-sm);