| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- <script setup lang="ts">
- /**
- * 登录页(views/login/index.vue;公开路由,守卫 meta.public)。
- *
- * 职责:两种登录方式 tab 切换——① 账号密码(两步:/api/auth/login → /api/auth/issue)② 微信扫码
- *(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):
- * ① 拿一次性票据 + 作用域列表:
- * - 账密: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';
- const loginStore = useLogin();
- /** 路由实例(SPA 跳转,免全页刷新;裸标识符经 vite define 注入为 VueRouter.useRouter) */
- const router = useRouter();
- /** 登录方式 tab('password' 账号密码 / 'wechat' 微信扫码) */
- const activeTab = ref<'password' | 'wechat'>('password');
- /** 表单数据(单一数据源;rules 校验只拦格式,业务错误由后端 code 裁决) */
- const form = reactive({
- username: '',
- password: '',
- });
- /** 提交中(防重复提交;密码 tab 用,覆盖 ①② 两步全程) */
- const submitting = ref(false);
- /** 登录失败文案(后端 message 直显;空串 = 无错误;两 tab 共用) */
- const errorMsg = ref('');
- /**
- * 微信扫码态(对齐后端 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 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) */
- const rules = {
- username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
- 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。
- * 异常场景:
- * - 表单校验失败:不发请求(ElForm 标红);
- * - ① code≠200(如 1001 账号密码错):errorMsg = 后端 message;
- * - 网络层 reject:errorMsg = "网络连接异常"。
- */
- async function handleSubmit(): Promise<void> {
- if (submitting.value) return;
- errorMsg.value = '';
- submitting.value = true;
- try {
- // ① 验账密:拿一次性票据 + 作用域列表(不签 token)
- const step1 = await login({ username: form.username, password: form.password });
- if (step1.code !== 200 || !step1.data) {
- errorMsg.value = step1.message || '登录失败';
- return;
- }
- // ② 统一分流(completeStep2:单条自动② / 多条进选择页 / 空提示)
- await completeStep2(step1.data.loginTicket, step1.data.memberships ?? []);
- } catch {
- errorMsg.value = '网络连接异常';
- } finally {
- submitting.value = false;
- }
- }
- /** 停止扫码轮询(切 tab / 组件卸载 / 终态时调用;幂等)。入参:无。出参:无。 */
- function stopPolling(): void {
- if (pollTimer !== undefined) {
- clearInterval(pollTimer);
- pollTimer = undefined;
- }
- polling.value = false;
- }
- /**
- * 启动微信扫码状态轮询(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 回调值)。
- * 出参:无(异步启动扫码流程)。
- * 异常场景:
- * - 取码/编码失败:保持二维码为空,扫码区显占位文案 + 后端 message。
- */
- async function handleTabChange(tab: 'password' | 'wechat'): Promise<void> {
- activeTab.value = tab;
- stopPolling();
- errorMsg.value = '';
- if (tab === 'wechat') {
- void getQrcode();
- }
- }
- // 组件卸载清轮询定时器(防内存泄漏;登录页被守卫跳转销毁时必清)
- onUnmounted(() => {
- stopPolling();
- });
- </script>
- <template>
- <div class="login-page">
- <el-card class="login-card">
- <template #header>
- <span class="login-title">统一管理平台</span>
- </template>
- <el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" class="login-error" />
- <!-- 登录方式切换(账号密码 / 微信扫码):@tab-change 切到微信时取码 + 启动轮询 -->
- <el-tabs v-model="activeTab" class="login-tabs" @tab-change="handleTabChange">
- <!-- 账号密码 -->
- <el-tab-pane label="账号密码" name="password">
- <el-form :model="form" :rules="rules" label-width="72px" @submit.prevent="handleSubmit">
- <el-form-item label="用户名" prop="username">
- <el-input v-model="form.username" placeholder="请输入用户名" autocomplete="username" />
- </el-form-item>
- <el-form-item label="密码" prop="password">
- <el-input
- v-model="form.password"
- type="password"
- placeholder="请输入密码"
- autocomplete="current-password"
- show-password
- />
- </el-form-item>
- <el-form-item>
- <el-button type="primary" :loading="submitting" native-type="submit" class="login-submit">
- 登 录
- </el-button>
- </el-form-item>
- </el-form>
- </el-tab-pane>
- <!-- 微信扫码(qrUrl 经 qrcode 编码成图;扫码成功回 loginTicket 走同一套 ② issue) -->
- <el-tab-pane label="微信扫码" name="wechat">
- <div class="login-wechat">
- <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>
- </el-card>
- </div>
- </template>
- <style scoped>
- .login-page {
- min-height: 100vh;
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--app-bg-page);
- }
- .login-card {
- width: 400px;
- }
- .login-title {
- font-size: var(--app-font-lg);
- font-weight: 600;
- color: var(--app-text-primary);
- }
- .login-error {
- margin-bottom: var(--app-spacing-md);
- }
- .login-submit {
- width: 100%;
- }
- .login-tabs {
- margin-top: var(--app-spacing-sm);
- }
- .login-wechat {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: var(--app-spacing-md) 0;
- }
- .login-wechat__qrcode {
- width: 200px;
- height: 200px;
- border: 1px solid var(--app-bg-page);
- border-radius: var(--app-radius-md);
- }
- .login-wechat__placeholder {
- width: 200px;
- height: 200px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--app-bg-page);
- color: var(--app-text-secondary);
- 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);
- color: var(--app-text-secondary);
- }
- </style>
|