Files
vue3-element-admin/src/views/login/components/QrCodeLogin.vue

237 lines
5.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="qr-login">
<h2 class="qr-login__title">扫码登录</h2>
<p class="qr-login__desc">使用 youlai-app 扫描下方二维码登录</p>
<div class="qr-login__board">
<div v-if="state === 'loading'" class="qr-login__mask">
<el-icon class="is-loading"><Loading /></el-icon>
<span>二维码生成中...</span>
</div>
<div v-else-if="state === 'scanned'" class="qr-login__mask qr-login__mask--info">
<el-avatar :size="56" :src="avatar || undefined">
<el-icon><User /></el-icon>
</el-avatar>
<span class="qr-login__mask-text">{{ nickname }} 已扫码</span>
<span class="qr-login__mask-hint">请在手机上确认登录</span>
</div>
<div v-else-if="state === 'expired'" class="qr-login__mask">
<el-icon :size="28"><CircleClose /></el-icon>
<span>二维码已过期</span>
<el-button type="primary" link @click="start">点击刷新</el-button>
</div>
<div v-else-if="state === 'canceled'" class="qr-login__mask">
<el-icon :size="28"><CircleClose /></el-icon>
<span>已取消登录</span>
<el-button type="primary" link @click="start">重新生成</el-button>
</div>
<canvas v-show="state === 'waiting'" ref="canvasRef" class="qr-login__canvas" />
</div>
<div class="qr-login__footer">
<el-button text @click="emit('switch', 'login')">
<el-icon><ArrowLeft /></el-icon>
返回账号登录
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowLeft, CircleClose, Loading, User } from "@element-plus/icons-vue";
import QRCode from "qrcode";
import AuthAPI, { type QrCodeStatusResult } from "@/api/auth";
import { useUserStore } from "@/stores";
import router from "@/router";
import { useRoute } from "vue-router";
import { onBeforeUnmount, onMounted, ref } from "vue";
// 界面状态机loading(生成中) → waiting(待扫码) → scanned(已扫码待确认)
// → done(已登录) / expired(过期或已使用) / canceled(APP 取消)
type State = "loading" | "waiting" | "scanned" | "expired" | "canceled" | "done";
const emit = defineEmits<{ (e: "switch", mode: "login"): void }>();
const userStore = useUserStore();
const route = useRoute();
// 二维码画布元素引用renderQrCode 把 ticket 绘进它
const canvasRef = ref<HTMLCanvasElement>();
// 当前界面状态,驱动模板中遮罩层的显隐
const state = ref<State>("loading");
// 扫码用户脱敏昵称与头像scanned 状态展示
const nickname = ref("");
const avatar = ref("");
// 当前票据,轮询与登录换令牌都依赖它
let ticket = "";
// 轮询定时器句柄,组件卸载或停止轮询时清空
let pollTimer: ReturnType<typeof setTimeout> | null = null;
// 轮询间隔(毫秒)
const QR_POLL_INTERVAL = 2000;
// 申请票据并渲染二维码,开始轮询
async function start() {
stopPolling();
state.value = "loading";
nickname.value = "";
avatar.value = "";
try {
const res = await AuthAPI.qrGenerate();
ticket = res.ticket;
await renderQrCode(ticket);
state.value = "waiting";
startPolling();
} catch {
state.value = "expired";
}
}
async function renderQrCode(payload: string) {
if (!canvasRef.value) return;
await QRCode.toCanvas(canvasRef.value, payload, { width: 220, margin: 1 });
}
function startPolling() {
stopPolling();
pollTimer = setTimeout(pollOnce, QR_POLL_INTERVAL);
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
}
async function pollOnce() {
if (!ticket) return;
try {
const res = await AuthAPI.qrStatus(ticket);
handleStatus(res);
} catch {
startPolling();
}
}
// 根据后端返回状态推进界面与下一步动作
function handleStatus(res: QrCodeStatusResult) {
switch (res.status) {
case "WAITING":
state.value = "waiting";
startPolling();
break;
case "SCANNED":
state.value = "scanned";
nickname.value = res.nickname || "";
avatar.value = res.avatar || "";
startPolling();
break;
case "CONFIRMED":
doLogin();
break;
case "CANCELED":
state.value = "canceled";
break;
case "LOGGED_IN":
case "EXPIRED":
default:
state.value = "expired";
break;
}
}
async function doLogin() {
state.value = "done";
stopPolling();
try {
await userStore.loginByQrCode(ticket);
const redirectPath = (route.query.redirect as string) || "/";
await router.push(decodeURIComponent(redirectPath));
} catch {
state.value = "expired";
}
}
onMounted(start);
onBeforeUnmount(stopPolling);
</script>
<style lang="scss" scoped>
.qr-login {
width: 100%;
&__title {
margin: 0 0 4px;
font-size: 34px;
font-weight: 750;
color: var(--el-text-color-primary);
}
&__desc {
margin: 8px 0 24px;
font-size: 14px;
color: var(--el-text-color-placeholder);
}
&__board {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 240px;
height: 240px;
margin: 0 auto;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
}
&__canvas {
display: block;
border-radius: 8px;
}
&__mask {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
padding: 16px;
font-size: 13px;
color: var(--el-text-color-regular);
background: rgb(255 255 255 / 90%);
backdrop-filter: blur(2px);
&--info {
gap: 4px;
}
}
&__mask-text {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
&__mask-hint {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
&__footer {
display: flex;
justify-content: center;
margin-top: 20px;
}
}
</style>