feat: 新增 APP 扫码登录(PC 端二维码轮询),版本 4.8.0
This commit is contained in:
@@ -1,6 +1,28 @@
|
||||
import request from "@/utils/request";
|
||||
import type { CaptchaInfo, LoginRequest, LoginResult } from "./types";
|
||||
|
||||
/** 申请扫码票据的响应:票据字符串与有效期(秒) */
|
||||
export interface QrCodeGenerateResult {
|
||||
/** 一次扫码登录会话的唯一票据,由 PC 端编码进二维码 */
|
||||
ticket: string;
|
||||
/** 票据有效期(秒),默认 300,过期后 Redis 自动清理 */
|
||||
expireSeconds: number;
|
||||
}
|
||||
|
||||
/** 轮询扫码状态的响应 */
|
||||
export interface QrCodeStatusResult {
|
||||
/** 当前票据 */
|
||||
ticket: string;
|
||||
/** 状态:WAITING/SCANNED/CONFIRMED/LOGGED_IN/CANCELED/EXPIRED */
|
||||
status: "WAITING" | "SCANNED" | "CONFIRMED" | "LOGGED_IN" | "CANCELED" | "EXPIRED";
|
||||
/** 脱敏昵称,仅 SCANNED 之后返回,WAITING 为 undefined */
|
||||
nickname?: string;
|
||||
/** 头像 URL,仅 SCANNED 之后返回 */
|
||||
avatar?: string;
|
||||
/** 票据剩余有效期(秒) */
|
||||
expireSeconds: number;
|
||||
}
|
||||
|
||||
const AUTH_BASE_URL = "/api/v1/auth";
|
||||
|
||||
const AuthAPI = {
|
||||
@@ -59,6 +81,31 @@ const AuthAPI = {
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
// ============ 扫码登录 ============
|
||||
|
||||
qrGenerate(): Promise<QrCodeGenerateResult> {
|
||||
return request<unknown, QrCodeGenerateResult>({
|
||||
url: `${AUTH_BASE_URL}/qr-code/generate`,
|
||||
method: "post",
|
||||
});
|
||||
},
|
||||
|
||||
qrStatus(ticket: string): Promise<QrCodeStatusResult> {
|
||||
return request<unknown, QrCodeStatusResult>({
|
||||
url: `${AUTH_BASE_URL}/qr-code/status`,
|
||||
method: "get",
|
||||
params: { ticket },
|
||||
});
|
||||
},
|
||||
|
||||
qrLogin(ticket: string): Promise<LoginResult> {
|
||||
return request<unknown, LoginResult>({
|
||||
url: `${AUTH_BASE_URL}/qr-code/login`,
|
||||
method: "post",
|
||||
data: { ticket },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AuthAPI;
|
||||
|
||||
@@ -26,6 +26,14 @@ export const useUserStore = defineStore("user", () => {
|
||||
AuthStorage.setTokens(accessToken, refreshToken, rememberMe.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码登录:用票据换取会话令牌
|
||||
*/
|
||||
async function loginByQrCode(ticket: string): Promise<void> {
|
||||
const { accessToken, refreshToken } = await AuthAPI.qrLogin(ticket);
|
||||
AuthStorage.setTokens(accessToken, refreshToken, false);
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
@@ -111,6 +119,7 @@ export const useUserStore = defineStore("user", () => {
|
||||
rememberMe,
|
||||
isLoggedIn: () => !!AuthStorage.getAccessToken(),
|
||||
login,
|
||||
loginByQrCode,
|
||||
logout,
|
||||
getUserInfo,
|
||||
resetAllState,
|
||||
|
||||
236
src/views/login/components/QrCodeLogin.vue
Normal file
236
src/views/login/components/QrCodeLogin.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<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>
|
||||
@@ -52,7 +52,14 @@
|
||||
<div class="login-card">
|
||||
<div class="login-card__inner">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div v-if="component === 'login'" key="login" class="login-card__form">
|
||||
<QrCodeLogin
|
||||
v-if="component === 'qrcode'"
|
||||
key="qrcode"
|
||||
class="login-card__form"
|
||||
@switch="component = 'login'"
|
||||
/>
|
||||
|
||||
<div v-else-if="component === 'login'" key="login" class="login-card__form">
|
||||
<h2 class="login-card__title">欢迎回来</h2>
|
||||
<p class="login-card__desc">请完成身份验证后进入系统</p>
|
||||
|
||||
@@ -126,7 +133,7 @@
|
||||
<div class="login-alt">
|
||||
<div class="login-alt__divider">其他登录方式</div>
|
||||
<div class="login-alt__buttons">
|
||||
<button class="login-alt__btn">
|
||||
<button class="login-alt__btn" @click="component = 'qrcode'">
|
||||
<span class="login-alt__icon i-svg:qr-code" />
|
||||
扫码登录
|
||||
</button>
|
||||
@@ -166,11 +173,12 @@ import { AuthStorage } from "@/utils/auth";
|
||||
import { appConfig } from "@/settings";
|
||||
import ThemeSwitch from "@/components/ThemeSwitch/index.vue";
|
||||
import ResetPwd from "./components/ResetPwd.vue";
|
||||
import QrCodeLogin from "./components/QrCodeLogin.vue";
|
||||
import logo from "@/assets/images/logo.png";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const route = useRoute();
|
||||
const component = ref<"login" | "resetPwd">("login");
|
||||
const component = ref<"login" | "resetPwd" | "qrcode">("login");
|
||||
|
||||
const loginFormRef = ref<FormInstance>();
|
||||
const loading = ref(false);
|
||||
|
||||
Reference in New Issue
Block a user