index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. <script setup lang="ts">
  2. /**
  3. * 登录页(views/login/index.vue;公开路由,守卫 meta.public)。
  4. *
  5. * 职责:两种登录方式 tab 切换——① 账号密码(两步:/api/auth/login → /api/auth/issue)② 微信扫码
  6. *(GET /api/auth/wechat/qrcode 取码 + GET /api/auth/wechat/status 轮询;扫码成功回 loginTicket,
  7. * 与账密登录**同一套** ② /api/auth/issue 签 token);成功则写 token-store + router.push 跳机构;
  8. * 失败展示后端 message(直显,不包装)。
  9. *
  10. * 两步登录流程(对齐后端 identity ADR-014 / T4 契约 + 微信扫码复用②机制,2026-11-06):
  11. * ① 拿一次性票据 + 作用域列表:
  12. * - 账密:POST /login(验账密);
  13. * - 微信:扫码成功(status=success)后端回 loginTicket + memberships(**不直接发 token**)。
  14. * ② 凭票据 + 选定作用域签 token(POST /issue,completeStep2 统一分流):
  15. * - memberships 恰 1 条:自动 ②(取该条作用域)→ applyLogin → 跳机构列表;
  16. * - memberships 多条:applyStep1 暂存票据/作用域列表 → 跳作用域选择页由用户挑选后 ②;
  17. * - memberships 空:提示"该账号暂无机构归属,请联系管理员"。
  18. * 微信状态机:pending 继续轮 / success 走② / unbound(未绑账号)· fail(第三方失败)· expired(失效)
  19. * 停轮询并提示。
  20. * SFC 骨架顺序(README §3.2):imports → props/emits(无)→ store 注入 → 状态 → 生命周期 → 方法。
  21. */
  22. import { toDataURL } from 'qrcode';
  23. import { issue, login } from '@/api/auth/login';
  24. import type { MembershipView } from '@/api/auth/login';
  25. import { fetchWechatQrcode, pollWechatQrcodeStatus } from '@/api/auth/wechat';
  26. import { useLogin } from '@/store/login';
  27. const loginStore = useLogin();
  28. /** 路由实例(SPA 跳转,免全页刷新;裸标识符经 vite define 注入为 VueRouter.useRouter) */
  29. const router = useRouter();
  30. /** 登录方式 tab('password' 账号密码 / 'wechat' 微信扫码) */
  31. const activeTab = ref<'password' | 'wechat'>('password');
  32. /** 表单数据(单一数据源;rules 校验只拦格式,业务错误由后端 code 裁决) */
  33. const form = reactive({
  34. username: '',
  35. password: '',
  36. });
  37. /** 提交中(防重复提交;密码 tab 用,覆盖 ①② 两步全程) */
  38. const submitting = ref(false);
  39. /** 登录失败文案(后端 message 直显;空串 = 无错误;两 tab 共用) */
  40. const errorMsg = ref('');
  41. /**
  42. * 微信扫码态(对齐后端 GET /auth/wechat/qrcode + /status 契约,2026-11-06)。
  43. * - qrDataUrl string:二维码图 data URL(由 qrUrl 微信授权 URL 经 qrcode 库编码;'' = 未取到)。
  44. * 后端返回的 qrUrl 是**微信网页授权 URL(字符串)非图片**,故前端编码成可扫二维码。
  45. * - wechatState string:扫码会话标识(轮询 status 回传;'' = 无会话)。
  46. * - qrcodeLoading boolean:取码/编码进行中(防重复点击 + 占位提示)。
  47. * - polling boolean:是否正在轮询(驱动"扫码中"文案)。
  48. */
  49. const qrDataUrl = ref('');
  50. const wechatState = ref('');
  51. const qrcodeLoading = ref(false);
  52. const polling = ref(false);
  53. /** 轮询定时器句柄(组件卸载/切 tab/终态时必须清,防泄漏) */
  54. let pollTimer: ReturnType<typeof setInterval> | undefined;
  55. /** 表单校验规则(显式 rules 对象,README §3.3-5) */
  56. const rules = {
  57. username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  58. password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
  59. };
  60. /**
  61. * 完成两步登录② 的统一分流(账密 / 微信**同一套**;凭一次性票据 + 作用域列表签 token)。
  62. *
  63. * 入参:
  64. * - loginTicket string:① 返回的一次性票据(账密 ① 或 微信 success 回显;32 hex)。
  65. * - memberships MembershipView[]:可选作用域列表。
  66. * 出参:
  67. * - Promise<void>:恒 resolve;单条作用域自动② → applyLogin → 跳机构;多条进作用域选择页;空提示。
  68. * 异常场景:
  69. * - 票据缺失:提示"登录票据缺失,请重新操作";
  70. * - 单条② code≠200(1001 票据失效 / 1003 越权):errorMsg = 后端 message;
  71. * - 网络层 reject:抛给调用方 catch(账密/微信各自提示"网络连接异常")。
  72. */
  73. async function completeStep2(loginTicket: string, memberships: MembershipView[]): Promise<void> {
  74. if (!loginTicket) {
  75. errorMsg.value = '登录票据缺失,请重新操作';
  76. return;
  77. }
  78. // 无机构归属:提示联系管理员(该账号无法进入业务域)
  79. if (memberships.length === 0) {
  80. errorMsg.value = '该账号暂无机构归属,请联系管理员分配机构';
  81. return;
  82. }
  83. // 单条作用域:自动完成 ②(取该条 institution/campus),免进选择页
  84. if (memberships.length === 1) {
  85. const m = memberships[0];
  86. const pair = await issue({ loginTicket, institutionId: m.institutionId, campusId: m.campusId });
  87. if (pair.code === 200 && pair.data) {
  88. loginStore.applyLogin(pair.data);
  89. await router.push('/org/institutions');
  90. return;
  91. }
  92. errorMsg.value = pair.message || '登录失败';
  93. return;
  94. }
  95. // 多条作用域:暂存票据 + 作用域列表,进作用域选择页由用户挑选后 ②。
  96. // 微信 ① 不回显 user(后端仅回 loginTicket + memberships):构造最小 LoginStep1 形状喂给 applyStep1,
  97. // user 占位空视图;realName 由 ② issue 的 TokenPair.user 在 applyLogin 回填。
  98. loginStore.applyStep1({
  99. loginTicket,
  100. memberships,
  101. user: { id: '', username: '', realName: '', personType: '', status: 'active' },
  102. });
  103. await router.push('/org/scope');
  104. }
  105. /**
  106. * 提交登录(handle 前缀:用户触发的提交类方法,README §3.1 命名规则)。
  107. *
  108. * 入参:无(读 form 当前值)。
  109. * 出参:
  110. * - Promise<void>:恒 resolve;成功跳机构列表/作用域选择页,失败写 errorMsg。
  111. * 异常场景:
  112. * - 表单校验失败:不发请求(ElForm 标红);
  113. * - ① code≠200(如 1001 账号密码错):errorMsg = 后端 message;
  114. * - 网络层 reject:errorMsg = "网络连接异常"。
  115. */
  116. async function handleSubmit(): Promise<void> {
  117. if (submitting.value) return;
  118. errorMsg.value = '';
  119. submitting.value = true;
  120. try {
  121. // ① 验账密:拿一次性票据 + 作用域列表(不签 token)
  122. const step1 = await login({ username: form.username, password: form.password });
  123. if (step1.code !== 200 || !step1.data) {
  124. errorMsg.value = step1.message || '登录失败';
  125. return;
  126. }
  127. // ② 统一分流(completeStep2:单条自动② / 多条进选择页 / 空提示)
  128. await completeStep2(step1.data.loginTicket, step1.data.memberships ?? []);
  129. } catch {
  130. errorMsg.value = '网络连接异常';
  131. } finally {
  132. submitting.value = false;
  133. }
  134. }
  135. /** 停止扫码轮询(切 tab / 组件卸载 / 终态时调用;幂等)。入参:无。出参:无。 */
  136. function stopPolling(): void {
  137. if (pollTimer !== undefined) {
  138. clearInterval(pollTimer);
  139. pollTimer = undefined;
  140. }
  141. polling.value = false;
  142. }
  143. /**
  144. * 启动微信扫码状态轮询(1.5s 间隔;只读快照,不消费会话)。
  145. *
  146. * 入参:state string:扫码会话标识(fetchWechatQrcode 返回)。
  147. * 出参:无(异步轮询,命中终态时停轮询并写 errorMsg / 走 ②)。
  148. * 异常场景:
  149. * - 单次轮询网络故障 / 业务错误:静默跳过本次,下轮重试(不中断轮询)。
  150. */
  151. function startPolling(state: string): void {
  152. polling.value = true;
  153. pollTimer = setInterval(async () => {
  154. let s: Awaited<ReturnType<typeof pollWechatQrcodeStatus>>;
  155. try {
  156. s = await pollWechatQrcodeStatus(state);
  157. } catch {
  158. return; // 单次网络故障:静默(下轮重试)
  159. }
  160. if (s.code !== 200 || !s.data) return; // 单次业务错误:静默(下轮重试)
  161. const d = s.data;
  162. // expired(state 不存在/过期):终态,停轮询,提示刷新
  163. if (d.expired) {
  164. stopPolling();
  165. qrDataUrl.value = '';
  166. errorMsg.value = '二维码已失效,请重新扫码';
  167. return;
  168. }
  169. if (d.status === 'pending') return; // 继续轮询
  170. if (d.status === 'success') {
  171. // 扫码成功:后端回 loginTicket + memberships → 复用同一套 ② issue 分流
  172. stopPolling();
  173. void completeStep2(d.loginTicket ?? '', d.memberships ?? []);
  174. return;
  175. }
  176. // unbound(未绑账号)/ fail(第三方失败):终态停轮询
  177. stopPolling();
  178. qrDataUrl.value = '';
  179. errorMsg.value =
  180. d.status === 'unbound' ? '该微信号未绑定账号,请先用账号密码登录后绑定' : '微信扫码失败,请重新扫码';
  181. }, 1500);
  182. }
  183. /**
  184. * 获取微信扫码二维码(进入扫码 tab / 点"重新获取"时调用)。
  185. *
  186. * 入参:无。出参:Promise<void>(取码 + 编码 + 启动轮询全程)。
  187. * 异常场景:
  188. * - 取码 code≠200(4004 未配置 / 5002 Redis 故障):errorMsg 直显,不出二维码;
  189. * - 网络层 reject:errorMsg = "网络连接异常"。
  190. */
  191. async function getQrcode(): Promise<void> {
  192. stopPolling();
  193. qrDataUrl.value = '';
  194. wechatState.value = '';
  195. errorMsg.value = '';
  196. qrcodeLoading.value = true;
  197. try {
  198. const q = await fetchWechatQrcode();
  199. if (q.code !== 200 || !q.data) {
  200. errorMsg.value = q.message || '获取二维码失败';
  201. return;
  202. }
  203. // 后端 qrUrl 是微信网页授权 URL(字符串):编码成二维码图供用户扫
  204. const dataUrl = await toDataURL(q.data.qrUrl, { width: 440, margin: 2, errorCorrectionLevel: 'M' });
  205. qrDataUrl.value = dataUrl;
  206. wechatState.value = q.data.state;
  207. startPolling(q.data.state);
  208. } catch {
  209. errorMsg.value = '网络连接异常';
  210. } finally {
  211. qrcodeLoading.value = false;
  212. }
  213. }
  214. /**
  215. * 切换登录方式 tab(切到微信时取码 + 启动轮询;切走时停轮询)。
  216. *
  217. * 入参:tab 'password' | 'wechat'(el-tabs @tab-change 回调值)。
  218. * 出参:无(异步启动扫码流程)。
  219. * 异常场景:
  220. * - 取码/编码失败:保持二维码为空,扫码区显占位文案 + 后端 message。
  221. */
  222. async function handleTabChange(tab: 'password' | 'wechat'): Promise<void> {
  223. activeTab.value = tab;
  224. stopPolling();
  225. errorMsg.value = '';
  226. if (tab === 'wechat') {
  227. void getQrcode();
  228. }
  229. }
  230. // 组件卸载清轮询定时器(防内存泄漏;登录页被守卫跳转销毁时必清)
  231. onUnmounted(() => {
  232. stopPolling();
  233. });
  234. </script>
  235. <template>
  236. <div class="login-page">
  237. <el-card class="login-card">
  238. <template #header>
  239. <span class="login-title">统一管理平台</span>
  240. </template>
  241. <el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" class="login-error" />
  242. <!-- 登录方式切换(账号密码 / 微信扫码):@tab-change 切到微信时取码 + 启动轮询 -->
  243. <el-tabs v-model="activeTab" class="login-tabs" @tab-change="handleTabChange">
  244. <!-- 账号密码 -->
  245. <el-tab-pane label="账号密码" name="password">
  246. <el-form :model="form" :rules="rules" label-width="72px" @submit.prevent="handleSubmit">
  247. <el-form-item label="用户名" prop="username">
  248. <el-input v-model="form.username" placeholder="请输入用户名" autocomplete="username" />
  249. </el-form-item>
  250. <el-form-item label="密码" prop="password">
  251. <el-input
  252. v-model="form.password"
  253. type="password"
  254. placeholder="请输入密码"
  255. autocomplete="current-password"
  256. show-password
  257. />
  258. </el-form-item>
  259. <el-form-item>
  260. <el-button type="primary" :loading="submitting" native-type="submit" class="login-submit">
  261. 登 录
  262. </el-button>
  263. </el-form-item>
  264. </el-form>
  265. </el-tab-pane>
  266. <!-- 微信扫码(qrUrl 经 qrcode 编码成图;扫码成功回 loginTicket 走同一套 ② issue) -->
  267. <el-tab-pane label="微信扫码" name="wechat">
  268. <div class="login-wechat">
  269. <img v-if="qrDataUrl" :src="qrDataUrl" alt="微信扫码二维码" class="login-wechat__qrcode" />
  270. <div v-else class="login-wechat__placeholder">
  271. <el-button
  272. :loading="qrcodeLoading"
  273. class="login-wechat__btn"
  274. @click="getQrcode"
  275. >
  276. {{ qrDataUrl ? '重新获取二维码' : '获取二维码' }}
  277. </el-button>
  278. </div>
  279. <el-button
  280. v-if="qrDataUrl"
  281. link
  282. type="primary"
  283. :loading="qrcodeLoading"
  284. class="login-wechat__refresh"
  285. @click="getQrcode"
  286. >
  287. 重新获取二维码
  288. </el-button>
  289. <p class="login-wechat__tip">
  290. {{ polling ? '请使用微信扫描二维码…' : qrDataUrl ? '请用微信扫描上方二维码' : '点击下方按钮获取二维码' }}
  291. </p>
  292. </div>
  293. </el-tab-pane>
  294. </el-tabs>
  295. </el-card>
  296. </div>
  297. </template>
  298. <style scoped>
  299. .login-page {
  300. min-height: 100vh;
  301. display: flex;
  302. align-items: center;
  303. justify-content: center;
  304. background: var(--app-bg-page);
  305. }
  306. .login-card {
  307. width: 400px;
  308. }
  309. .login-title {
  310. font-size: var(--app-font-lg);
  311. font-weight: 600;
  312. color: var(--app-text-primary);
  313. }
  314. .login-error {
  315. margin-bottom: var(--app-spacing-md);
  316. }
  317. .login-submit {
  318. width: 100%;
  319. }
  320. .login-tabs {
  321. margin-top: var(--app-spacing-sm);
  322. }
  323. .login-wechat {
  324. display: flex;
  325. flex-direction: column;
  326. align-items: center;
  327. padding: var(--app-spacing-md) 0;
  328. }
  329. .login-wechat__qrcode {
  330. width: 200px;
  331. height: 200px;
  332. border: 1px solid var(--app-bg-page);
  333. border-radius: var(--app-radius-md);
  334. }
  335. .login-wechat__placeholder {
  336. width: 200px;
  337. height: 200px;
  338. display: flex;
  339. align-items: center;
  340. justify-content: center;
  341. background: var(--app-bg-page);
  342. color: var(--app-text-secondary);
  343. font-size: var(--app-font-md);
  344. border-radius: var(--app-radius-md);
  345. }
  346. .login-wechat__btn {
  347. background: transparent;
  348. }
  349. .login-wechat__refresh {
  350. margin: var(--app-spacing-xs, 8px) 0 0;
  351. }
  352. .login-wechat__tip {
  353. margin: var(--app-spacing-sm) 0 0;
  354. font-size: var(--app-font-sm);
  355. color: var(--app-text-secondary);
  356. }
  357. </style>